@umang-boss/claudemon 1.3.0 → 1.4.1

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 (43) hide show
  1. package/cli/doctor.ts +18 -2
  2. package/cli/index.ts +26 -8
  3. package/dist/cli/doctor.js +19 -2
  4. package/dist/cli/index.js +26 -9
  5. package/dist/src/engine/constants.js +9 -3
  6. package/dist/src/engine/encounters.js +50 -10
  7. package/dist/src/engine/mood.js +187 -0
  8. package/dist/src/engine/types.js +2 -0
  9. package/dist/src/gamification/achievements.js +3 -3
  10. package/dist/src/gamification/legendary-quests.js +4 -4
  11. package/dist/src/hooks/award-xp.js +75 -5
  12. package/dist/src/server/index.js +8 -0
  13. package/dist/src/server/instructions.js +23 -0
  14. package/dist/src/server/tools/catch.js +3 -0
  15. package/dist/src/server/tools/evolve.js +3 -0
  16. package/dist/src/server/tools/feed.js +120 -0
  17. package/dist/src/server/tools/play.js +310 -0
  18. package/dist/src/server/tools/settings.js +116 -0
  19. package/dist/src/server/tools/show.js +5 -0
  20. package/dist/src/server/tools/train.js +144 -0
  21. package/dist/src/state/schemas.js +18 -1
  22. package/dist/src/state/state-manager.js +23 -6
  23. package/package.json +1 -1
  24. package/skills/buddy/SKILL.md +16 -0
  25. package/src/engine/constants.ts +12 -3
  26. package/src/engine/encounters.ts +65 -9
  27. package/src/engine/mood.ts +220 -0
  28. package/src/engine/types.ts +24 -0
  29. package/src/gamification/achievements.ts +3 -3
  30. package/src/gamification/legendary-quests.ts +4 -4
  31. package/src/hooks/award-xp.ts +97 -5
  32. package/src/server/index.ts +8 -0
  33. package/src/server/instructions.ts +25 -0
  34. package/src/server/tools/catch.ts +4 -0
  35. package/src/server/tools/evolve.ts +4 -0
  36. package/src/server/tools/feed.ts +145 -0
  37. package/src/server/tools/play.ts +378 -0
  38. package/src/server/tools/settings.ts +142 -0
  39. package/src/server/tools/show.ts +7 -0
  40. package/src/server/tools/train.ts +180 -0
  41. package/src/state/schemas.ts +20 -0
  42. package/src/state/state-manager.ts +26 -6
  43. package/statusline/buddy-status.sh +77 -62
@@ -0,0 +1,116 @@
1
+ /**
2
+ * buddy_settings tool — Configure Claudemon settings.
3
+ * Supports: encounter-speed, xp-share.
4
+ */
5
+ import { z } from "zod";
6
+ import { StateManager } from "../../state/state-manager.js";
7
+ import { ENCOUNTER_THRESHOLDS } from "../../engine/constants.js";
8
+ const VALID_ENCOUNTER_SPEEDS = ["fast", "normal", "slow"];
9
+ const SPEED_DESCRIPTIONS = {
10
+ fast: "Fastest encounters — wild Pokemon appear every ~100 XP",
11
+ normal: "Default pace — wild Pokemon appear every ~250 XP",
12
+ slow: "Less interruptions — wild Pokemon appear every ~500 XP",
13
+ };
14
+ /** Registers the buddy_settings tool on the MCP server. */
15
+ export function registerSettingsTool(server) {
16
+ server.tool("buddy_settings", "Configure Claudemon settings (encounter-speed, xp-share)", {
17
+ setting: z.enum(["encounter-speed", "xp-share"]).describe("The setting to configure"),
18
+ value: z.string().describe("The value to set"),
19
+ }, async (params) => {
20
+ const stateManager = StateManager.getInstance();
21
+ const state = await stateManager.load();
22
+ if (!state) {
23
+ return {
24
+ content: [
25
+ {
26
+ type: "text",
27
+ text: "No save data found. Use buddy_starter to pick your first Pokemon before changing settings.",
28
+ },
29
+ ],
30
+ };
31
+ }
32
+ // ── Encounter Speed ──────────────────────────────────
33
+ if (params.setting === "encounter-speed") {
34
+ const speed = params.value.toLowerCase();
35
+ if (!VALID_ENCOUNTER_SPEEDS.includes(speed)) {
36
+ return {
37
+ content: [
38
+ {
39
+ type: "text",
40
+ text: [
41
+ `Invalid encounter speed: "${params.value}"`,
42
+ "",
43
+ "Valid options:",
44
+ ...VALID_ENCOUNTER_SPEEDS.map((s) => ` ${s} — ${SPEED_DESCRIPTIONS[s]} (${ENCOUNTER_THRESHOLDS[s]} XP threshold)`),
45
+ ].join("\n"),
46
+ },
47
+ ],
48
+ isError: true,
49
+ };
50
+ }
51
+ const validSpeed = speed;
52
+ const previousSpeed = state.config.encounterSpeed ?? "normal";
53
+ state.config.encounterSpeed = validSpeed;
54
+ await stateManager.save();
55
+ return {
56
+ content: [
57
+ {
58
+ type: "text",
59
+ text: [
60
+ `Encounter speed: ${previousSpeed} → ${validSpeed}`,
61
+ "",
62
+ SPEED_DESCRIPTIONS[validSpeed],
63
+ `XP threshold: ${ENCOUNTER_THRESHOLDS[validSpeed]}`,
64
+ ].join("\n"),
65
+ },
66
+ ],
67
+ };
68
+ }
69
+ // ── XP Share ─────────────────────────────────────────
70
+ if (params.setting === "xp-share") {
71
+ const percent = parseInt(params.value, 10);
72
+ if (isNaN(percent) || percent < 0 || percent > 100) {
73
+ return {
74
+ content: [
75
+ {
76
+ type: "text",
77
+ text: [
78
+ `Invalid XP share value: "${params.value}"`,
79
+ "",
80
+ "Enter a number 0-100 (percentage of XP shared to inactive party):",
81
+ " 0 — No XP sharing (only active Pokemon earns)",
82
+ " 25 — Default (inactive get 25% of earned XP)",
83
+ " 50 — Half XP shared to inactive party",
84
+ " 100 — Full XP to everyone",
85
+ ].join("\n"),
86
+ },
87
+ ],
88
+ isError: true,
89
+ };
90
+ }
91
+ const previous = state.config.xpSharePercent ?? 25;
92
+ state.config.xpSharePercent = percent;
93
+ await stateManager.save();
94
+ const desc = percent === 0
95
+ ? "Disabled — only active Pokemon earns XP"
96
+ : `Inactive party members receive ${percent}% of earned XP`;
97
+ return {
98
+ content: [
99
+ {
100
+ type: "text",
101
+ text: [`XP share: ${previous}% → ${percent}%`, "", desc].join("\n"),
102
+ },
103
+ ],
104
+ };
105
+ }
106
+ return {
107
+ content: [
108
+ {
109
+ type: "text",
110
+ text: `Unknown setting: "${params.setting}". Available: encounter-speed, xp-share`,
111
+ },
112
+ ],
113
+ isError: true,
114
+ };
115
+ });
116
+ }
@@ -10,6 +10,7 @@ import { STAT_DISPLAY_NAMES } from "../../engine/constants.js";
10
10
  import { renderStatBar, getTrainerTitle } from "../../engine/stats.js";
11
11
  import { xpProgressPercent, xpToNextLevel } from "../../engine/xp.js";
12
12
  import { StateManager } from "../../state/state-manager.js";
13
+ import { getMoodEmoji, getMoodDescription } from "../../engine/mood.js";
13
14
  import { formatTypes, renderXpBar, pad } from "./display-helpers.js";
14
15
  /** Registers the buddy_show tool on the MCP server. */
15
16
  export function registerShowTool(server) {
@@ -99,6 +100,10 @@ export function registerShowTool(server) {
99
100
  ? `Streak: ${state.streak.currentStreak} days`
100
101
  : "Streak: 0 days";
101
102
  lines.push(`\u2502 ${pad(streakDisplay, W - 2)}\u2502`);
103
+ const currentMood = state.mood ?? "neutral";
104
+ const moodEmoji = getMoodEmoji(currentMood);
105
+ const moodDesc = getMoodDescription(currentMood);
106
+ lines.push(`\u2502 ${pad(`Mood: ${moodEmoji} ${moodDesc}`, W - 2)}\u2502`);
102
107
  lines.push(`\u2514${border}\u2518`);
103
108
  // Prepend colorscript sprite if available
104
109
  // ANSI colorscripts don't render in MCP text output — skip sprites here
@@ -0,0 +1,144 @@
1
+ /**
2
+ * buddy_train tool — Train your active Pokemon's coding stats.
3
+ * Has a 30-minute cooldown. Awards +3 to a stat and +5 XP.
4
+ */
5
+ import { z } from "zod";
6
+ import { CODING_STATS } from "../../engine/types.js";
7
+ import { POKEMON_BY_ID } from "../../engine/pokemon-data.js";
8
+ import { STAT_DISPLAY_NAMES } from "../../engine/constants.js";
9
+ import { addXp } from "../../engine/xp.js";
10
+ import { applyStatBoost, renderStatBar } from "../../engine/stats.js";
11
+ import { StateManager } from "../../state/state-manager.js";
12
+ /** Cooldown: 30 minutes in milliseconds. */
13
+ const TRAIN_COOLDOWN_MS = 1_800_000;
14
+ /** Stat boost per training session. */
15
+ const TRAIN_STAT_BOOST = 3;
16
+ /** XP awarded per training session. */
17
+ const TRAIN_XP_AWARD = 5;
18
+ /** Pick a random element from a non-empty array. */
19
+ function randomPick(arr) {
20
+ const index = Math.floor(Math.random() * arr.length);
21
+ return arr[index];
22
+ }
23
+ /** Format remaining cooldown as "Xm Ys". */
24
+ function formatCooldownRemaining(remainingMs) {
25
+ const totalSeconds = Math.ceil(remainingMs / 1000);
26
+ const minutes = Math.floor(totalSeconds / 60);
27
+ const seconds = totalSeconds % 60;
28
+ if (minutes > 0 && seconds > 0) {
29
+ return `${minutes}m ${seconds}s`;
30
+ }
31
+ if (minutes > 0) {
32
+ return `${minutes}m`;
33
+ }
34
+ return `${seconds}s`;
35
+ }
36
+ /** Registers the buddy_train tool on the MCP server. */
37
+ export function registerTrainTool(server) {
38
+ server.tool("buddy_train", "Train your active Pokemon's coding stats. Optionally specify a stat (debugging, stability, velocity, wisdom, stamina). 30-minute cooldown.", {
39
+ stat: z
40
+ .string()
41
+ .optional()
42
+ .describe("Stat to train: debugging, stability, velocity, wisdom, or stamina. Random if omitted."),
43
+ }, async ({ stat: statInput }) => {
44
+ const stateManager = StateManager.getInstance();
45
+ const state = await stateManager.load();
46
+ if (!state || state.party.length === 0) {
47
+ return {
48
+ content: [
49
+ {
50
+ type: "text",
51
+ text: "You don't have a Pokemon yet! Use buddy_starter to pick your first companion.",
52
+ },
53
+ ],
54
+ };
55
+ }
56
+ const active = stateManager.getActivePokemon();
57
+ if (!active) {
58
+ return {
59
+ content: [
60
+ {
61
+ type: "text",
62
+ text: "No active Pokemon found. Use buddy_party to set one active.",
63
+ },
64
+ ],
65
+ };
66
+ }
67
+ const species = POKEMON_BY_ID.get(active.pokemonId);
68
+ if (!species) {
69
+ return {
70
+ content: [
71
+ {
72
+ type: "text",
73
+ text: `Could not find species data for Pokemon ID ${active.pokemonId}.`,
74
+ },
75
+ ],
76
+ isError: true,
77
+ };
78
+ }
79
+ // Check cooldown
80
+ const now = Date.now();
81
+ const elapsed = now - state.lastTrainedAt;
82
+ if (elapsed < TRAIN_COOLDOWN_MS) {
83
+ const remaining = TRAIN_COOLDOWN_MS - elapsed;
84
+ return {
85
+ content: [
86
+ {
87
+ type: "text",
88
+ text: `Still resting from training! Try again in ${formatCooldownRemaining(remaining)}.`,
89
+ },
90
+ ],
91
+ };
92
+ }
93
+ // Determine which stat to train
94
+ let targetStat;
95
+ if (statInput !== undefined && statInput !== "") {
96
+ const normalized = statInput.toLowerCase().trim();
97
+ if (!CODING_STATS.includes(normalized)) {
98
+ const validStats = CODING_STATS.map((s) => STAT_DISPLAY_NAMES[s].toLowerCase()).join(", ");
99
+ return {
100
+ content: [
101
+ {
102
+ type: "text",
103
+ text: `Invalid stat "${statInput}". Valid stats: ${validStats}`,
104
+ },
105
+ ],
106
+ };
107
+ }
108
+ targetStat = normalized;
109
+ }
110
+ else {
111
+ targetStat = randomPick(CODING_STATS);
112
+ }
113
+ const displayName = active.nickname ?? species.name;
114
+ // Record stat before boost for display
115
+ const statBefore = active.codingStats[targetStat];
116
+ // Apply stat boost
117
+ applyStatBoost(active, targetStat, TRAIN_STAT_BOOST);
118
+ const statAfter = active.codingStats[targetStat];
119
+ // Award XP
120
+ const levelUp = addXp(active, TRAIN_XP_AWARD, species);
121
+ // Set mood to energetic
122
+ state.mood = "energetic";
123
+ state.moodSetAt = now;
124
+ // Record train timestamp
125
+ state.lastTrainedAt = now;
126
+ // Save state and update status line
127
+ await stateManager.save();
128
+ await stateManager.writeStatus();
129
+ // Build response
130
+ const statDisplayName = STAT_DISPLAY_NAMES[targetStat];
131
+ const lines = [];
132
+ lines.push(`*${displayName} trains its ${statDisplayName}! +${TRAIN_STAT_BOOST}*`);
133
+ lines.push("");
134
+ lines.push(`${statDisplayName}: ${statBefore} \u2192 ${statAfter} ${renderStatBar(statAfter)}`);
135
+ lines.push(`+${TRAIN_XP_AWARD} XP`);
136
+ if (levelUp) {
137
+ lines.push("");
138
+ lines.push(`*** ${displayName} grew to Lv.${levelUp.newLevel}! ***`);
139
+ }
140
+ return {
141
+ content: [{ type: "text", text: lines.join("\n") }],
142
+ };
143
+ });
144
+ }
@@ -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,8 @@ 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"),
51
+ xpSharePercent: z.number().min(0).max(100).default(25),
50
52
  });
51
53
  // ---- Pokedex ----
52
54
  export const PokedexEntrySchema = z.object({
@@ -65,6 +67,13 @@ export const UnlockedAchievementSchema = z.object({
65
67
  achievementId: z.string(),
66
68
  unlockedAt: z.string(),
67
69
  });
70
+ // ---- Pending Quiz ----
71
+ export const PendingQuizSchema = z.object({
72
+ type: z.enum(["type_matchup", "stat_compare", "evolution", "pokedex_trivia"]),
73
+ question: z.string(),
74
+ options: z.array(z.string()),
75
+ correctAnswer: z.number().int().min(1).max(4),
76
+ });
68
77
  // ---- Catch Condition ----
69
78
  export const CatchConditionSchema = z.object({
70
79
  requiredStat: z.enum(CODING_STATS).nullable(),
@@ -94,4 +103,12 @@ export const PlayerStateSchema = z.object({
94
103
  totalSessions: z.number().int().min(0).default(0),
95
104
  pendingEncounter: WildEncounterSchema.nullable().default(null),
96
105
  xpSinceLastEncounter: z.number().int().min(0).default(0),
106
+ recentToolTypes: z.array(z.string()).default([]),
107
+ lastEncounterTime: z.number().int().min(0).default(0),
108
+ mood: z.enum(MOOD_TYPES).default("neutral"),
109
+ moodSetAt: z.number().default(0),
110
+ lastFedAt: z.number().default(0),
111
+ lastTrainedAt: z.number().default(0),
112
+ lastPlayedAt: z.number().default(0),
113
+ pendingQuiz: PendingQuizSchema.nullable().default(null),
97
114
  });
@@ -30,6 +30,8 @@ function defaultConfig() {
30
30
  reactionCooldownMs: 30_000,
31
31
  statusLineEnabled: true,
32
32
  bellEnabled: true,
33
+ encounterSpeed: "normal",
34
+ xpSharePercent: 25,
33
35
  };
34
36
  }
35
37
  /** Build empty pokedex */
@@ -150,6 +152,14 @@ export class StateManager {
150
152
  totalSessions: 0,
151
153
  pendingEncounter: null,
152
154
  xpSinceLastEncounter: 0,
155
+ recentToolTypes: [],
156
+ lastEncounterTime: 0,
157
+ mood: "neutral",
158
+ moodSetAt: 0,
159
+ lastFedAt: 0,
160
+ lastTrainedAt: 0,
161
+ lastPlayedAt: 0,
162
+ pendingQuiz: null,
153
163
  };
154
164
  this.state = state;
155
165
  await this.save();
@@ -168,17 +178,19 @@ export class StateManager {
168
178
  state.counters[key] += amount;
169
179
  await this.save();
170
180
  }
171
- /** Update daily streak based on today vs lastActiveDate, then save */
181
+ /**
182
+ * Update daily streak with weekend grace period.
183
+ * Allows up to 2 days off without breaking the streak (covers weekends).
184
+ * Streak counts "coding days" not "consecutive calendar days".
185
+ */
172
186
  async updateStreak() {
173
187
  const state = this.getState();
174
188
  const today = todayDateString();
175
189
  const { streak } = state;
176
190
  if (streak.lastActiveDate === today) {
177
- // Already recorded today, nothing to do
178
191
  return;
179
192
  }
180
193
  if (streak.lastActiveDate === null) {
181
- // First ever activity
182
194
  streak.currentStreak = 1;
183
195
  streak.totalDaysActive = 1;
184
196
  }
@@ -187,12 +199,15 @@ export class StateManager {
187
199
  const now = new Date(today);
188
200
  const diffMs = now.getTime() - last.getTime();
189
201
  const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
190
- if (diffDays === 1) {
191
- // Consecutive day
202
+ // Grace period: up to 2 days off (covers weekends)
203
+ // 1 day gap = next day (consecutive) ✓
204
+ // 2 day gap = skipped 1 day (e.g., Friday → Sunday) ✓
205
+ // 3 day gap = skipped 2 days (e.g., Friday → Monday) ✓
206
+ // 4+ day gap = streak broken
207
+ if (diffDays <= 3) {
192
208
  streak.currentStreak += 1;
193
209
  }
194
210
  else {
195
- // Streak broken
196
211
  streak.currentStreak = 1;
197
212
  }
198
213
  streak.totalDaysActive += 1;
@@ -209,6 +224,7 @@ export class StateManager {
209
224
  if (!active) {
210
225
  return;
211
226
  }
227
+ const state = this.getState();
212
228
  // XP percent is currentXp as a rough percentage toward next level
213
229
  // Exact formula depends on exp group; use a simple ratio for the status line
214
230
  const xpPercent = active.level >= 100 ? 100 : Math.min(100, Math.floor((active.currentXp / Math.max(1, active.currentXp + 50)) * 100));
@@ -225,6 +241,7 @@ export class StateManager {
225
241
  xpPercent,
226
242
  speciesId: active.pokemonId,
227
243
  evolutionReady,
244
+ mood: state.mood ?? "neutral",
228
245
  };
229
246
  const stateDir = getStateDir();
230
247
  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.1",
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,22 @@ 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) |
59
+ | `settings xp-share N` | `buddy_settings` with setting="xp-share", value="N" — share N% XP to inactive party (0-100) |
44
60
  | `help` | List all available /buddy commands |
45
61
 
46
62
  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;