@umang-boss/claudemon 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/src/engine/constants.js +9 -3
  2. package/dist/src/engine/encounters.js +50 -10
  3. package/dist/src/engine/mood.js +187 -0
  4. package/dist/src/engine/types.js +2 -0
  5. package/dist/src/gamification/achievements.js +3 -3
  6. package/dist/src/gamification/legendary-quests.js +4 -4
  7. package/dist/src/hooks/award-xp.js +60 -5
  8. package/dist/src/server/index.js +8 -0
  9. package/dist/src/server/instructions.js +23 -0
  10. package/dist/src/server/tools/catch.js +3 -0
  11. package/dist/src/server/tools/evolve.js +3 -0
  12. package/dist/src/server/tools/feed.js +120 -0
  13. package/dist/src/server/tools/play.js +310 -0
  14. package/dist/src/server/tools/settings.js +80 -0
  15. package/dist/src/server/tools/show.js +5 -0
  16. package/dist/src/server/tools/train.js +144 -0
  17. package/dist/src/state/schemas.js +17 -1
  18. package/dist/src/state/state-manager.js +22 -6
  19. package/package.json +1 -1
  20. package/skills/buddy/SKILL.md +15 -0
  21. package/src/engine/constants.ts +12 -3
  22. package/src/engine/encounters.ts +65 -9
  23. package/src/engine/mood.ts +220 -0
  24. package/src/engine/types.ts +23 -0
  25. package/src/gamification/achievements.ts +3 -3
  26. package/src/gamification/legendary-quests.ts +4 -4
  27. package/src/hooks/award-xp.ts +82 -5
  28. package/src/server/index.ts +8 -0
  29. package/src/server/instructions.ts +25 -0
  30. package/src/server/tools/catch.ts +4 -0
  31. package/src/server/tools/evolve.ts +4 -0
  32. package/src/server/tools/feed.ts +145 -0
  33. package/src/server/tools/play.ts +378 -0
  34. package/src/server/tools/settings.ts +101 -0
  35. package/src/server/tools/show.ts +7 -0
  36. package/src/server/tools/train.ts +180 -0
  37. package/src/state/schemas.ts +19 -0
  38. package/src/state/state-manager.ts +25 -6
  39. package/statusline/buddy-status.sh +77 -62
@@ -3,7 +3,7 @@
3
3
  * Catches disk corruption early at load boundaries.
4
4
  */
5
5
  import { z } from "zod";
6
- import { BADGE_TYPES, CODING_STATS, EVENT_COUNTER_KEYS, } from "../engine/types.js";
6
+ import { BADGE_TYPES, CODING_STATS, EVENT_COUNTER_KEYS, MOOD_TYPES, } from "../engine/types.js";
7
7
  // ---- Shared Primitives ----
8
8
  const BadgeTypeSchema = z.enum(BADGE_TYPES);
9
9
  // ---- Coding Stats ----
@@ -47,6 +47,7 @@ export const BuddyConfigSchema = z.object({
47
47
  reactionCooldownMs: z.number().int().min(0).default(30_000),
48
48
  statusLineEnabled: z.boolean().default(true),
49
49
  bellEnabled: z.boolean().default(true),
50
+ encounterSpeed: z.enum(["fast", "normal", "slow"]).default("normal"),
50
51
  });
51
52
  // ---- Pokedex ----
52
53
  export const PokedexEntrySchema = z.object({
@@ -65,6 +66,13 @@ export const UnlockedAchievementSchema = z.object({
65
66
  achievementId: z.string(),
66
67
  unlockedAt: z.string(),
67
68
  });
69
+ // ---- Pending Quiz ----
70
+ export const PendingQuizSchema = z.object({
71
+ type: z.enum(["type_matchup", "stat_compare", "evolution", "pokedex_trivia"]),
72
+ question: z.string(),
73
+ options: z.array(z.string()),
74
+ correctAnswer: z.number().int().min(1).max(4),
75
+ });
68
76
  // ---- Catch Condition ----
69
77
  export const CatchConditionSchema = z.object({
70
78
  requiredStat: z.enum(CODING_STATS).nullable(),
@@ -94,4 +102,12 @@ export const PlayerStateSchema = z.object({
94
102
  totalSessions: z.number().int().min(0).default(0),
95
103
  pendingEncounter: WildEncounterSchema.nullable().default(null),
96
104
  xpSinceLastEncounter: z.number().int().min(0).default(0),
105
+ recentToolTypes: z.array(z.string()).default([]),
106
+ lastEncounterTime: z.number().int().min(0).default(0),
107
+ mood: z.enum(MOOD_TYPES).default("neutral"),
108
+ moodSetAt: z.number().default(0),
109
+ lastFedAt: z.number().default(0),
110
+ lastTrainedAt: z.number().default(0),
111
+ lastPlayedAt: z.number().default(0),
112
+ pendingQuiz: PendingQuizSchema.nullable().default(null),
97
113
  });
@@ -30,6 +30,7 @@ function defaultConfig() {
30
30
  reactionCooldownMs: 30_000,
31
31
  statusLineEnabled: true,
32
32
  bellEnabled: true,
33
+ encounterSpeed: "normal",
33
34
  };
34
35
  }
35
36
  /** Build empty pokedex */
@@ -150,6 +151,14 @@ export class StateManager {
150
151
  totalSessions: 0,
151
152
  pendingEncounter: null,
152
153
  xpSinceLastEncounter: 0,
154
+ recentToolTypes: [],
155
+ lastEncounterTime: 0,
156
+ mood: "neutral",
157
+ moodSetAt: 0,
158
+ lastFedAt: 0,
159
+ lastTrainedAt: 0,
160
+ lastPlayedAt: 0,
161
+ pendingQuiz: null,
153
162
  };
154
163
  this.state = state;
155
164
  await this.save();
@@ -168,17 +177,19 @@ export class StateManager {
168
177
  state.counters[key] += amount;
169
178
  await this.save();
170
179
  }
171
- /** Update daily streak based on today vs lastActiveDate, then save */
180
+ /**
181
+ * Update daily streak with weekend grace period.
182
+ * Allows up to 2 days off without breaking the streak (covers weekends).
183
+ * Streak counts "coding days" not "consecutive calendar days".
184
+ */
172
185
  async updateStreak() {
173
186
  const state = this.getState();
174
187
  const today = todayDateString();
175
188
  const { streak } = state;
176
189
  if (streak.lastActiveDate === today) {
177
- // Already recorded today, nothing to do
178
190
  return;
179
191
  }
180
192
  if (streak.lastActiveDate === null) {
181
- // First ever activity
182
193
  streak.currentStreak = 1;
183
194
  streak.totalDaysActive = 1;
184
195
  }
@@ -187,12 +198,15 @@ export class StateManager {
187
198
  const now = new Date(today);
188
199
  const diffMs = now.getTime() - last.getTime();
189
200
  const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
190
- if (diffDays === 1) {
191
- // Consecutive day
201
+ // Grace period: up to 2 days off (covers weekends)
202
+ // 1 day gap = next day (consecutive) ✓
203
+ // 2 day gap = skipped 1 day (e.g., Friday → Sunday) ✓
204
+ // 3 day gap = skipped 2 days (e.g., Friday → Monday) ✓
205
+ // 4+ day gap = streak broken
206
+ if (diffDays <= 3) {
192
207
  streak.currentStreak += 1;
193
208
  }
194
209
  else {
195
- // Streak broken
196
210
  streak.currentStreak = 1;
197
211
  }
198
212
  streak.totalDaysActive += 1;
@@ -209,6 +223,7 @@ export class StateManager {
209
223
  if (!active) {
210
224
  return;
211
225
  }
226
+ const state = this.getState();
212
227
  // XP percent is currentXp as a rough percentage toward next level
213
228
  // Exact formula depends on exp group; use a simple ratio for the status line
214
229
  const xpPercent = active.level >= 100 ? 100 : Math.min(100, Math.floor((active.currentXp / Math.max(1, active.currentXp + 50)) * 100));
@@ -225,6 +240,7 @@ export class StateManager {
225
240
  xpPercent,
226
241
  speciesId: active.pokemonId,
227
242
  evolutionReady,
243
+ mood: state.mood ?? "neutral",
228
244
  };
229
245
  const stateDir = getStateDir();
230
246
  await ensureDir(stateDir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umang-boss/claudemon",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Pokemon coding companion for Claude Code — Gotta code 'em all!",
5
5
  "type": "module",
6
6
  "main": "dist/src/server/index.js",
@@ -41,6 +41,21 @@ This ensures first-time users get the starter selection flow automatically.
41
41
  | `rename` (empty) | `buddy_rename` with name="" — reset to species name |
42
42
  | `hide` | `buddy_hide` — hide sprite from status line |
43
43
  | `unhide` | `buddy_unhide` — show sprite in status line |
44
+ | `feed` | `buddy_feed` — feed your Pokemon (+10 happiness, 1h cooldown) |
45
+ | `train` | `buddy_train` — train a random stat (+3 stat, +5 XP, 30m cooldown) |
46
+ | `train debugging` | `buddy_train` with stat="debugging" |
47
+ | `train stability` | `buddy_train` with stat="stability" |
48
+ | `train velocity` | `buddy_train` with stat="velocity" |
49
+ | `train wisdom` | `buddy_train` with stat="wisdom" |
50
+ | `train stamina` | `buddy_train` with stat="stamina" |
51
+ | `play` | `buddy_play` — start a Pokemon trivia quiz (15m cooldown after completion) |
52
+ | `play answer 1` | `buddy_play` with answer=1 |
53
+ | `play answer 2` | `buddy_play` with answer=2 |
54
+ | `play answer 3` | `buddy_play` with answer=3 |
55
+ | `play answer 4` | `buddy_play` with answer=4 |
56
+ | `settings encounter-speed fast` | `buddy_settings` with setting="encounter-speed", value="fast" — fastest encounters (100 XP) |
57
+ | `settings encounter-speed normal` | `buddy_settings` with setting="encounter-speed", value="normal" — default (250 XP) |
58
+ | `settings encounter-speed slow` | `buddy_settings` with setting="encounter-speed", value="slow" — less interruptions (500 XP) |
44
59
  | `help` | List all available /buddy commands |
45
60
 
46
61
  Pass $ARGUMENTS to determine which subcommand to route to.
@@ -46,8 +46,17 @@ export const STAT_DISPLAY_NAMES: Record<CodingStat, string> = {
46
46
 
47
47
  // ── Encounter Rate ─────────────────────────────────────────
48
48
 
49
- /** XP earned between wild encounters */
50
- export const XP_PER_ENCOUNTER = 500;
49
+ /** XP thresholds for encounter triggers by speed setting */
50
+ export const ENCOUNTER_THRESHOLDS = {
51
+ fast: 100,
52
+ normal: 250,
53
+ slow: 500,
54
+ } as const;
55
+
56
+ export type EncounterSpeed = keyof typeof ENCOUNTER_THRESHOLDS;
57
+
58
+ /** Default encounter speed */
59
+ export const DEFAULT_ENCOUNTER_SPEED: EncounterSpeed = "normal";
51
60
 
52
61
  // ── Reaction Cooldown ──────────────────────────────────────
53
62
 
@@ -92,7 +101,7 @@ export const BADGES: readonly Badge[] = [
92
101
  {
93
102
  type: "lunar",
94
103
  name: "Lunar Badge",
95
- description: "Maintain a 30-day coding streak — unlocks Moon Stone evolutions",
104
+ description: "Code for 30 days (weekends off OK) — unlocks Moon Stone evolutions",
96
105
  condition: { type: "streak", minDays: 30 },
97
106
  },
98
107
  {
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Wild encounter system.
3
- * Pokemon appear based on coding activity type and are catchable
4
- * based on the active Pokemon's stats and level.
3
+ * Pokemon appear based on coding activity type, streak bonuses,
4
+ * tool diversity, and time-of-day biases. Catch eligibility is
5
+ * determined by the active Pokemon's stats and level.
5
6
  */
6
7
 
7
8
  import type {
@@ -14,7 +15,8 @@ import type {
14
15
  CodingStat,
15
16
  RarityTier,
16
17
  } from "./types.js";
17
- import { XP_PER_ENCOUNTER } from "./constants.js";
18
+ import { ENCOUNTER_THRESHOLDS } from "./constants.js";
19
+ import type { EncounterSpeed } from "./constants.js";
18
20
  import { POKEMON_BY_ID } from "./pokemon-data.js";
19
21
  import { TYPE_POOLS } from "./encounter-pool.js";
20
22
 
@@ -41,14 +43,55 @@ export function getEncounterTypes(eventType: XpEventType): readonly PokemonType[
41
43
  return ENCOUNTER_TYPE_MAP[eventType];
42
44
  }
43
45
 
46
+ // ── Encounter Context ─────────────────────────────────────────
47
+
48
+ export interface EncounterContext {
49
+ xpSinceLastEncounter: number;
50
+ encounterSpeed: EncounterSpeed;
51
+ currentStreak: number;
52
+ recentToolTypes: string[]; // tool types used recently
53
+ currentHour: number; // 0-23
54
+ }
55
+
44
56
  // ── Encounter Trigger ─────────────────────────────────────────
45
57
 
46
58
  /**
47
- * Check if a wild encounter should trigger based on XP earned since last encounter.
48
- * Returns true roughly every XP_PER_ENCOUNTER (500) XP.
59
+ * Check if a wild encounter should trigger based on XP earned,
60
+ * encounter speed setting, and streak bonus.
61
+ * Streak bonus: 7+ day streak halves the threshold.
49
62
  */
50
- export function shouldTriggerEncounter(xpSinceLastEncounter: number): boolean {
51
- return xpSinceLastEncounter >= XP_PER_ENCOUNTER;
63
+ export function shouldTriggerEncounter(ctx: EncounterContext): boolean {
64
+ const threshold = ENCOUNTER_THRESHOLDS[ctx.encounterSpeed];
65
+
66
+ // Streak bonus: 7+ day streak = halve the threshold
67
+ const streakMultiplier = ctx.currentStreak >= 7 ? 0.5 : 1;
68
+ const effectiveThreshold = Math.floor(threshold * streakMultiplier);
69
+
70
+ if (ctx.xpSinceLastEncounter < effectiveThreshold) return false;
71
+
72
+ return true;
73
+ }
74
+
75
+ /** Check for bonus encounter (10% chance after a regular encounter). */
76
+ export function shouldBonusEncounter(): boolean {
77
+ return Math.random() < 0.1;
78
+ }
79
+
80
+ /** Check for activity diversity bonus (3+ unique tool types in recent history). */
81
+ export function shouldDiversityBonus(recentToolTypes: string[]): boolean {
82
+ const uniqueTypes = new Set(recentToolTypes);
83
+ return uniqueTypes.size >= 3;
84
+ }
85
+
86
+ // ── Time-of-Day Bias ──────────────────────────────────────────
87
+
88
+ /** Get time-of-day type biases for encounter generation. */
89
+ export function getTimeOfDayBias(hour: number): PokemonType[] {
90
+ if (hour >= 22 || hour < 5) return ["Ghost", "Poison"]; // Night: Ghost types
91
+ if (hour >= 5 && hour < 9) return ["Grass", "Bug"]; // Morning: Grass types
92
+ if (hour >= 12 && hour < 14) return ["Fire", "Rock"]; // Midday: Fire types
93
+ if (hour >= 17 && hour < 20) return ["Water", "Flying"]; // Evening: Water types
94
+ return []; // No bias
52
95
  }
53
96
 
54
97
  // ── Rarity Weights ────────────────────────────────────────────
@@ -235,14 +278,27 @@ function determineEncounterLevel(state: PlayerState, seed: number): number {
235
278
  /**
236
279
  * Generate a wild encounter based on the activity type.
237
280
  * Picks a Pokemon from the matching type pool, weighted by rarity.
238
- * Excludes Pokemon already in the player's party/box (unless duplicates are common tier).
281
+ * If time-of-day bias types are provided, there is a 40% chance to
282
+ * use those types instead of the activity-based types.
283
+ * Excludes Pokemon already in the player's party/box (unless common tier).
239
284
  * Returns null if no eligible Pokemon found.
240
285
  */
241
286
  export function generateEncounter(
242
287
  eventType: XpEventType,
243
288
  state: PlayerState,
289
+ timeOfDayTypes?: readonly PokemonType[],
244
290
  ): WildEncounter | null {
245
- const types = getEncounterTypes(eventType);
291
+ let types = getEncounterTypes(eventType);
292
+
293
+ // 40% chance to use time-of-day biased types if available
294
+ if (timeOfDayTypes && timeOfDayTypes.length > 0) {
295
+ const seed = Math.floor(Date.now() / 1000);
296
+ const biasRoll = seededRandom(seed + 42);
297
+ if (biasRoll < 0.4) {
298
+ types = timeOfDayTypes;
299
+ }
300
+ }
301
+
246
302
  const candidates = buildCandidatePool(types, state);
247
303
 
248
304
  if (candidates.length === 0) return null;
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Mood engine for Claudemon.
3
+ * Pure functions that calculate mood based on recent events, time of day,
4
+ * and special triggers (evolution, achievements, catches).
5
+ */
6
+
7
+ import type { MoodType, EventCounters } from "./types.js";
8
+
9
+ // ── Mood Decay Durations (milliseconds) ───────────────────
10
+
11
+ /** How long each mood lasts before decaying back to neutral */
12
+ const MOOD_DECAY_MS: Record<MoodType, number> = {
13
+ happy: 600_000, // 10 minutes
14
+ worried: 300_000, // 5 minutes
15
+ sleepy: Infinity, // Resets based on time-of-day, not duration
16
+ energetic: 900_000, // 15 minutes
17
+ proud: 600_000, // 10 minutes
18
+ neutral: Infinity, // Never decays (it IS the default)
19
+ };
20
+
21
+ // ── XP Event Types That Trigger Moods ─────────────────────
22
+
23
+ const POSITIVE_EVENTS = new Set(["test_pass", "build_success", "commit"]);
24
+
25
+ const NEGATIVE_EVENTS = new Set(["test_fail", "build_fail", "error"]);
26
+
27
+ // ── Mood Calculation ──────────────────────────────────────
28
+
29
+ /**
30
+ * Calculate the current mood based on recent events, time, and special triggers.
31
+ *
32
+ * Priority order:
33
+ * 1. Sleepy (midnight to 5 AM)
34
+ * 2. Proud (just evolved/achieved/caught)
35
+ * 3. Worried (recent negative event)
36
+ * 4. Happy (recent positive event)
37
+ * 5. Energetic (morning + active streak)
38
+ * 6. Keep current mood if it hasn't decayed
39
+ * 7. Neutral (default fallback)
40
+ *
41
+ * @param recentEvent - The last XP event type, or null
42
+ * @param counters - Current event counters (for context)
43
+ * @param currentHour - Hour of day (0-23)
44
+ * @param lastMood - The previous mood
45
+ * @param moodSetAt - Timestamp when last mood was set
46
+ * @param hadEvolution - Whether the Pokemon just evolved
47
+ * @param hadAchievement - Whether the player just unlocked an achievement
48
+ * @param hadCatch - Whether the player just caught a Pokemon
49
+ * @returns The calculated mood
50
+ */
51
+ export function calculateMood(
52
+ recentEvent: string | null,
53
+ counters: EventCounters,
54
+ currentHour: number,
55
+ lastMood: MoodType,
56
+ moodSetAt: number,
57
+ hadEvolution: boolean,
58
+ hadAchievement: boolean,
59
+ hadCatch: boolean,
60
+ ): MoodType {
61
+ // 1. Sleepy: midnight to 5 AM (hours 0-4)
62
+ if (currentHour >= 0 && currentHour < 5) {
63
+ return "sleepy";
64
+ }
65
+
66
+ // 2. Proud: just evolved, achieved, or caught a Pokemon
67
+ if (hadEvolution || hadAchievement || hadCatch) {
68
+ return "proud";
69
+ }
70
+
71
+ // 3. Worried: recent negative event
72
+ if (recentEvent !== null && NEGATIVE_EVENTS.has(recentEvent)) {
73
+ return "worried";
74
+ }
75
+
76
+ // 4. Happy: recent positive event
77
+ if (recentEvent !== null && POSITIVE_EVENTS.has(recentEvent)) {
78
+ return "happy";
79
+ }
80
+
81
+ // 5. Energetic: morning coding (5 AM - 10 AM) with an active streak
82
+ if (currentHour >= 5 && currentHour < 10) {
83
+ // Use a simple heuristic: if there have been any sessions, consider it an active streak
84
+ const hasStreak = counters.sessions > 0;
85
+ if (hasStreak) {
86
+ return "energetic";
87
+ }
88
+ }
89
+
90
+ // 6. Keep current mood if it hasn't decayed
91
+ if (!hasMoodDecayed(lastMood, moodSetAt, Date.now())) {
92
+ return lastMood;
93
+ }
94
+
95
+ // 7. Default fallback
96
+ return "neutral";
97
+ }
98
+
99
+ /**
100
+ * Check whether a mood has expired based on its decay duration.
101
+ *
102
+ * @param mood - The mood to check
103
+ * @param setAt - Timestamp when the mood was set
104
+ * @param now - Current timestamp
105
+ * @returns true if the mood has decayed (expired)
106
+ */
107
+ export function hasMoodDecayed(mood: MoodType, setAt: number, now: number): boolean {
108
+ const duration = MOOD_DECAY_MS[mood];
109
+ if (duration === Infinity) {
110
+ // Sleepy decays when it's no longer midnight-5 AM (handled in calculateMood)
111
+ // Neutral never decays
112
+ return false;
113
+ }
114
+ return now - setAt >= duration;
115
+ }
116
+
117
+ // ── Mood Speeches ─────────────────────────────────────────
118
+
119
+ /** Mood-specific speech lines for the status line. Name placeholder {name} is replaced at call time. */
120
+ const MOOD_SPEECHES: Record<MoodType, readonly string[]> = {
121
+ happy: [
122
+ "*{name} is beaming with pride!*",
123
+ "*{name} does a little victory dance*",
124
+ "*{name} radiates positive energy*",
125
+ "*{name} bounces happily*",
126
+ "*{name} gives you a thumbs up*",
127
+ ],
128
+ worried: [
129
+ "*{name} looks concerned...*",
130
+ "*{name} nervously watches the errors*",
131
+ "*{name} hides behind the terminal*",
132
+ "*{name} paces back and forth*",
133
+ "*{name} offers you a virtual hug*",
134
+ ],
135
+ sleepy: [
136
+ "*{name} yawns widely*",
137
+ "*{name} dozes off... zzz*",
138
+ "*{name} rubs its eyes*",
139
+ "*{name} curls up near the keyboard*",
140
+ "*{name} mumbles in its sleep*",
141
+ ],
142
+ energetic: [
143
+ "*{name} is fired up! Let's go!*",
144
+ "*{name} bounces off the walls*",
145
+ "*{name} can't sit still!*",
146
+ "*{name} is ready to code all day!*",
147
+ "*{name} stretches and flexes*",
148
+ ],
149
+ proud: [
150
+ "*{name} puffs up with pride*",
151
+ "*{name} strikes a victory pose*",
152
+ "*{name} shows off to everyone*",
153
+ "*{name} earned bragging rights!*",
154
+ "*{name} stands tall and proud*",
155
+ ],
156
+ neutral: [
157
+ "*{name} looks at your code curiously*",
158
+ "*{name} nods along as you type*",
159
+ "*{name} is watching closely*",
160
+ "*{name} hums softly*",
161
+ "*{name} waits patiently*",
162
+ "*{name} tilts head at the screen*",
163
+ "*{name} chirps encouragingly*",
164
+ "*{name} peers at a variable name*",
165
+ ],
166
+ };
167
+
168
+ /**
169
+ * Get mood-specific speech messages with the Pokemon's name filled in.
170
+ *
171
+ * @param name - The Pokemon's display name
172
+ * @param mood - The current mood
173
+ * @returns Array of speech strings with the name interpolated
174
+ */
175
+ export function getMoodSpeeches(name: string, mood: MoodType): string[] {
176
+ const templates = MOOD_SPEECHES[mood];
177
+ return templates.map((t) => t.replace("{name}", name));
178
+ }
179
+
180
+ // ── Mood Display Helpers ──────────────────────────────────
181
+
182
+ /** Emoji representation for each mood */
183
+ const MOOD_EMOJIS: Record<MoodType, string> = {
184
+ happy: "\u{1F60A}", // 😊
185
+ worried: "\u{1F61F}", // 😟
186
+ sleepy: "\u{1F634}", // 😴
187
+ energetic: "\u{26A1}", // ⚡
188
+ proud: "\u{1F451}", // 👑
189
+ neutral: "\u{1F610}", // 😐
190
+ };
191
+
192
+ /** Human-readable mood descriptions */
193
+ const MOOD_DESCRIPTIONS: Record<MoodType, string> = {
194
+ happy: "Happy",
195
+ worried: "Worried",
196
+ sleepy: "Sleepy",
197
+ energetic: "Energetic",
198
+ proud: "Proud",
199
+ neutral: "Neutral",
200
+ };
201
+
202
+ /**
203
+ * Get the emoji for a mood.
204
+ *
205
+ * @param mood - The mood type
206
+ * @returns The emoji string
207
+ */
208
+ export function getMoodEmoji(mood: MoodType): string {
209
+ return MOOD_EMOJIS[mood];
210
+ }
211
+
212
+ /**
213
+ * Get a human-readable description for a mood.
214
+ *
215
+ * @param mood - The mood type
216
+ * @returns The description string (e.g. "Happy")
217
+ */
218
+ export function getMoodDescription(mood: MoodType): string {
219
+ return MOOD_DESCRIPTIONS[mood];
220
+ }
@@ -3,6 +3,11 @@
3
3
  * Single source of truth for all shared interfaces and types.
4
4
  */
5
5
 
6
+ // ── Mood Types ────────────────────────────────────────────
7
+
8
+ export const MOOD_TYPES = ["happy", "worried", "sleepy", "energetic", "proud", "neutral"] as const;
9
+ export type MoodType = (typeof MOOD_TYPES)[number];
10
+
6
11
  // ── Pokemon Types ──────────────────────────────────────────
7
12
 
8
13
  export const POKEMON_TYPES = [
@@ -159,6 +164,14 @@ export interface PlayerState {
159
164
  totalSessions: number;
160
165
  pendingEncounter: WildEncounter | null;
161
166
  xpSinceLastEncounter: number;
167
+ recentToolTypes: string[]; // Track tool diversity for bonus encounters
168
+ lastEncounterTime: number; // Timestamp of last encounter (for cooldown)
169
+ mood: MoodType; // Current mood of the active Pokemon
170
+ moodSetAt: number; // Timestamp when mood was last set
171
+ lastFedAt: number; // Timestamp of last feed action
172
+ lastTrainedAt: number; // Timestamp of last train action
173
+ lastPlayedAt: number; // Timestamp of last play (quiz completed) action
174
+ pendingQuiz: PendingQuiz | null; // Active quiz awaiting answer
162
175
  }
163
176
 
164
177
  // ── Pokedex ────────────────────────────────────────────────
@@ -240,6 +253,7 @@ export interface BuddyConfig {
240
253
  reactionCooldownMs: number; // Default 30000
241
254
  statusLineEnabled: boolean;
242
255
  bellEnabled: boolean; // Terminal bell on level-up/encounters
256
+ encounterSpeed: "fast" | "normal" | "slow"; // Configurable encounter frequency
243
257
  }
244
258
 
245
259
  // ── XP Events (what triggers XP awards) ────────────────────
@@ -269,6 +283,15 @@ export interface XpEvent {
269
283
  readonly boostAmount: number;
270
284
  }
271
285
 
286
+ // ── Pending Quiz (for /buddy play) ────────────────────────
287
+
288
+ export interface PendingQuiz {
289
+ readonly type: "type_matchup" | "stat_compare" | "evolution" | "pokedex_trivia";
290
+ readonly question: string;
291
+ readonly options: readonly string[];
292
+ readonly correctAnswer: number; // 1-4
293
+ }
294
+
272
295
  // ── Encounters ─────────────────────────────────────────────
273
296
 
274
297
  export interface WildEncounter {
@@ -103,21 +103,21 @@ export const ACHIEVEMENTS: readonly Achievement[] = [
103
103
  {
104
104
  id: "iron_coder",
105
105
  name: "Iron Coder",
106
- description: "Maintain a 7-day coding streak",
106
+ description: "Code for 7 days (weekends off OK)",
107
107
  category: "coding",
108
108
  condition: { type: "streak", minDays: 7 },
109
109
  },
110
110
  {
111
111
  id: "marathon",
112
112
  name: "Marathon",
113
- description: "Maintain a 30-day coding streak",
113
+ description: "Code for 30 days (weekends off OK)",
114
114
  category: "coding",
115
115
  condition: { type: "streak", minDays: 30 },
116
116
  },
117
117
  {
118
118
  id: "centurion",
119
119
  name: "Centurion",
120
- description: "Maintain a 100-day coding streak",
120
+ description: "Code for 100 days (weekends off OK)",
121
121
  category: "coding",
122
122
  condition: { type: "streak", minDays: 100 },
123
123
  },
@@ -16,7 +16,7 @@ export const LEGENDARY_QUESTS: readonly LegendaryQuest[] = [
16
16
  name: "The Ice Bird of Endurance",
17
17
  steps: [
18
18
  {
19
- description: "Maintain a 30-day coding streak",
19
+ description: "Code for 30 days (weekends off OK)",
20
20
  condition: { type: "streak", minDays: 30 },
21
21
  },
22
22
  {
@@ -25,7 +25,7 @@ export const LEGENDARY_QUESTS: readonly LegendaryQuest[] = [
25
25
  condition: { type: "pokedex", minCaught: 3 },
26
26
  },
27
27
  {
28
- description: "Maintain a 100-day coding streak",
28
+ description: "Code for 100 days (weekends off OK)",
29
29
  condition: { type: "streak", minDays: 100 },
30
30
  },
31
31
  {
@@ -113,7 +113,7 @@ export const LEGENDARY_QUESTS: readonly LegendaryQuest[] = [
113
113
  name: "The Myth",
114
114
  steps: [
115
115
  {
116
- description: "Maintain a 100-day coding streak",
116
+ description: "Code for 100 days (weekends off OK)",
117
117
  condition: { type: "streak", minDays: 100 },
118
118
  },
119
119
  {
@@ -129,7 +129,7 @@ export const LEGENDARY_QUESTS: readonly LegendaryQuest[] = [
129
129
  condition: { type: "pokedex", minCaught: 149 },
130
130
  },
131
131
  {
132
- description: "Maintain a 365-day coding streak",
132
+ description: "Code for 365 days (weekends off OK)",
133
133
  condition: { type: "streak", minDays: 365 },
134
134
  },
135
135
  ],