@umang-boss/claudemon 2.1.3 → 2.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umang-boss/claudemon",
3
- "version": "2.1.3",
3
+ "version": "2.2.0",
4
4
  "description": "Pokemon coding companion for Claude Code — Gotta code 'em all!",
5
5
  "type": "module",
6
6
  "main": "dist/server.mjs",
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Pre-built encounter pools — computed once at module load.
3
- * Maps each PokemonType to base-stage Pokemon IDs grouped by rarity.
3
+ * Provides all base-stage wild-eligible Pokemon grouped by rarity,
4
+ * with helper lookups by type and generation for smart weighting.
4
5
  */
5
6
 
6
7
  import type { PokemonType, RarityTier } from "./types.js";
@@ -22,22 +23,64 @@ function isBaseStage(pokemonId: number): boolean {
22
23
 
23
24
  const WILD_RARITIES: ReadonlySet<RarityTier> = new Set(["common", "uncommon", "rare"]);
24
25
 
26
+ // ── Generation ranges ─────────────────────────────────────────
27
+
28
+ export const GEN_RANGES: readonly { gen: number; start: number; end: number }[] = [
29
+ { gen: 1, start: 1, end: 151 },
30
+ { gen: 2, start: 152, end: 251 },
31
+ { gen: 3, start: 252, end: 386 },
32
+ { gen: 4, start: 387, end: 493 },
33
+ { gen: 5, start: 494, end: 649 },
34
+ { gen: 6, start: 650, end: 721 },
35
+ { gen: 7, start: 722, end: 809 },
36
+ { gen: 8, start: 810, end: 905 },
37
+ ];
38
+
39
+ /** Get the generation number for a Pokemon ID. */
40
+ export function getGeneration(pokemonId: number): number {
41
+ for (const { gen, start, end } of GEN_RANGES) {
42
+ if (pokemonId >= start && pokemonId <= end) return gen;
43
+ }
44
+ return 1; // fallback
45
+ }
46
+
25
47
  // ── Build pools at module load ────────────────────────────────
26
48
 
27
- interface RarityPool {
49
+ export interface RarityPool {
28
50
  readonly common: readonly number[];
29
51
  readonly uncommon: readonly number[];
30
52
  readonly rare: readonly number[];
31
53
  }
32
54
 
55
+ /** All wild-eligible base-stage Pokemon, grouped by rarity. */
56
+ function buildAllPool(): RarityPool {
57
+ const common: number[] = [];
58
+ const uncommon: number[] = [];
59
+ const rare: number[] = [];
60
+
61
+ for (const pokemon of POKEDEX) {
62
+ if (!isBaseStage(pokemon.id)) continue;
63
+ if (!WILD_RARITIES.has(pokemon.rarity)) continue;
64
+
65
+ const rarity = pokemon.rarity as "common" | "uncommon" | "rare";
66
+ if (rarity === "common") common.push(pokemon.id);
67
+ else if (rarity === "uncommon") uncommon.push(pokemon.id);
68
+ else rare.push(pokemon.id);
69
+ }
70
+
71
+ return {
72
+ common: Object.freeze(common),
73
+ uncommon: Object.freeze(uncommon),
74
+ rare: Object.freeze(rare),
75
+ };
76
+ }
77
+
78
+ /** Type-indexed pools for legacy compatibility and type lookups. */
33
79
  function buildTypePools(): ReadonlyMap<PokemonType, RarityPool> {
34
80
  const pools = new Map<PokemonType, { common: number[]; uncommon: number[]; rare: number[] }>();
35
81
 
36
82
  for (const pokemon of POKEDEX) {
37
- // Only base-stage Pokemon appear in wild encounters
38
83
  if (!isBaseStage(pokemon.id)) continue;
39
-
40
- // Legendary and mythical never appear in wild encounters
41
84
  if (!WILD_RARITIES.has(pokemon.rarity)) continue;
42
85
 
43
86
  for (const pokemonType of pokemon.types) {
@@ -54,7 +97,6 @@ function buildTypePools(): ReadonlyMap<PokemonType, RarityPool> {
54
97
  }
55
98
  }
56
99
 
57
- // Freeze all inner arrays for immutability
58
100
  const frozen = new Map<PokemonType, RarityPool>();
59
101
  for (const [type, pool] of pools) {
60
102
  frozen.set(type, {
@@ -69,3 +111,6 @@ function buildTypePools(): ReadonlyMap<PokemonType, RarityPool> {
69
111
 
70
112
  /** Map of PokemonType to Pokemon IDs of that type, grouped by rarity. */
71
113
  export const TYPE_POOLS: ReadonlyMap<PokemonType, RarityPool> = buildTypePools();
114
+
115
+ /** All wild-eligible base-stage Pokemon, grouped by rarity (type-agnostic). */
116
+ export const ALL_WILD_POOL: RarityPool = buildAllPool();
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Wild encounter system.
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.
3
+ * 100% random encounters with Pokedex-aware smart weighting.
4
+ * No activity-type or time-of-day bias any Pokemon can appear anytime.
5
+ * Unseen types, gens, and species are boosted to fill the Pokedex faster.
6
6
  */
7
7
 
8
8
  import type {
@@ -15,33 +15,11 @@ import type {
15
15
  CodingStat,
16
16
  RarityTier,
17
17
  } from "./types.js";
18
+ import { POKEMON_TYPES } from "./types.js";
18
19
  import { ENCOUNTER_THRESHOLDS } from "./constants.js";
19
20
  import type { EncounterSpeed } from "./constants.js";
20
21
  import { POKEMON_BY_ID } from "./pokemon-data.js";
21
- import { TYPE_POOLS } from "./encounter-pool.js";
22
-
23
- // ── Activity to Pokemon Type Mapping ──────────────────────────
24
-
25
- const ENCOUNTER_TYPE_MAP: Readonly<Record<XpEventType, readonly PokemonType[]>> = {
26
- commit: ["Normal", "Flying"],
27
- test_pass: ["Fighting", "Normal"],
28
- test_written: ["Fighting", "Normal"],
29
- build_success: ["Fire", "Rock"],
30
- bug_fix: ["Bug", "Poison"],
31
- lint_fix: ["Bug", "Poison"],
32
- file_create: ["Normal", "Ground"],
33
- file_edit: ["Normal", "Ground"],
34
- search: ["Flying", "Ground"],
35
- large_refactor: ["Psychic", "Dragon"],
36
- session_start: ["Grass", "Fairy"],
37
- daily_streak: ["Water", "Electric"],
38
- pet: ["Normal", "Fairy"],
39
- };
40
-
41
- /** Maps an XP event type to the Pokemon types that can appear. */
42
- export function getEncounterTypes(eventType: XpEventType): readonly PokemonType[] {
43
- return ENCOUNTER_TYPE_MAP[eventType];
44
- }
22
+ import { ALL_WILD_POOL, getGeneration, GEN_RANGES } from "./encounter-pool.js";
45
23
 
46
24
  // ── Encounter Context ─────────────────────────────────────────
47
25
 
@@ -49,8 +27,8 @@ export interface EncounterContext {
49
27
  xpSinceLastEncounter: number;
50
28
  encounterSpeed: EncounterSpeed;
51
29
  currentStreak: number;
52
- recentToolTypes: string[]; // tool types used recently
53
- currentHour: number; // 0-23
30
+ recentToolTypes: string[];
31
+ currentHour: number;
54
32
  }
55
33
 
56
34
  // ── Encounter Trigger ─────────────────────────────────────────
@@ -62,14 +40,10 @@ export interface EncounterContext {
62
40
  */
63
41
  export function shouldTriggerEncounter(ctx: EncounterContext): boolean {
64
42
  const threshold = ENCOUNTER_THRESHOLDS[ctx.encounterSpeed];
65
-
66
- // Streak bonus: 7+ day streak = halve the threshold
67
43
  const streakMultiplier = ctx.currentStreak >= 7 ? 0.5 : 1;
68
44
  const effectiveThreshold = Math.floor(threshold * streakMultiplier);
69
45
 
70
- if (ctx.xpSinceLastEncounter < effectiveThreshold) return false;
71
-
72
- return true;
46
+ return ctx.xpSinceLastEncounter >= effectiveThreshold;
73
47
  }
74
48
 
75
49
  /** Check for bonus encounter (10% chance after a regular encounter). */
@@ -83,20 +57,8 @@ export function shouldDiversityBonus(recentToolTypes: string[]): boolean {
83
57
  return uniqueTypes.size >= 3;
84
58
  }
85
59
 
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", "Dark"]; // Night: Ghost/Dark types
91
- if (hour >= 5 && hour < 9) return ["Grass", "Fairy"]; // Morning: Grass/Fairy types
92
- if (hour >= 12 && hour < 14) return ["Fire", "Steel"]; // Midday: Fire/Steel types
93
- if (hour >= 17 && hour < 20) return ["Water", "Flying"]; // Evening: Water types
94
- return []; // No bias
95
- }
96
-
97
60
  // ── Rarity Weights ────────────────────────────────────────────
98
61
 
99
- /** Relative weights for rarity-based selection. Higher = more likely to appear. */
100
62
  const RARITY_WEIGHTS: Readonly<Record<"common" | "uncommon" | "rare", number>> = {
101
63
  common: 70,
102
64
  uncommon: 25,
@@ -105,10 +67,6 @@ const RARITY_WEIGHTS: Readonly<Record<"common" | "uncommon" | "rare", number>> =
105
67
 
106
68
  // ── Catch Condition Mapping ───────────────────────────────────
107
69
 
108
- /**
109
- * Stat most relevant to each Pokemon type for catch condition evaluation.
110
- * Used for uncommon+ encounters to determine the required stat.
111
- */
112
70
  const TYPE_TO_STAT: Readonly<Record<PokemonType, CodingStat>> = {
113
71
  Normal: "velocity",
114
72
  Fire: "debugging",
@@ -130,7 +88,6 @@ const TYPE_TO_STAT: Readonly<Record<PokemonType, CodingStat>> = {
130
88
  Fairy: "wisdom",
131
89
  };
132
90
 
133
- /** Minimum stat thresholds by rarity tier. */
134
91
  const RARITY_STAT_THRESHOLD: Readonly<Record<RarityTier, number>> = {
135
92
  common: 0,
136
93
  uncommon: 20,
@@ -139,7 +96,6 @@ const RARITY_STAT_THRESHOLD: Readonly<Record<RarityTier, number>> = {
139
96
  mythical: 80,
140
97
  };
141
98
 
142
- /** Minimum Pokemon level required to catch by rarity tier. */
143
99
  const RARITY_LEVEL_THRESHOLD: Readonly<Record<RarityTier, number>> = {
144
100
  common: 1,
145
101
  uncommon: 10,
@@ -150,7 +106,6 @@ const RARITY_LEVEL_THRESHOLD: Readonly<Record<RarityTier, number>> = {
150
106
 
151
107
  // ── Catch Condition ───────────────────────────────────────────
152
108
 
153
- /** Get the catch condition for a Pokemon based on its rarity. */
154
109
  export function getCatchCondition(pokemonId: number): CatchCondition {
155
110
  const pokemon = POKEMON_BY_ID.get(pokemonId);
156
111
  if (!pokemon) {
@@ -161,75 +116,138 @@ export function getCatchCondition(pokemonId: number): CatchCondition {
161
116
  const requiredLevel = RARITY_LEVEL_THRESHOLD[rarity];
162
117
  const minStatValue = RARITY_STAT_THRESHOLD[rarity];
163
118
 
164
- // Common Pokemon have no stat requirement
165
119
  if (rarity === "common") {
166
120
  return { requiredStat: null, minStatValue: 0, requiredLevel };
167
121
  }
168
122
 
169
- // Use the primary type to determine which stat is required
170
123
  const primaryType = pokemon.types[0];
171
124
  const requiredStat = TYPE_TO_STAT[primaryType];
172
125
 
173
126
  return { requiredStat, minStatValue, requiredLevel };
174
127
  }
175
128
 
176
- // ── Encounter Generation ──────────────────────────────────────
129
+ // ── Pokedex Analysis ──────────────────────────────────────────
130
+
131
+ /** Count how many caught Pokemon the player has of each type. */
132
+ function getTypeCounts(state: PlayerState): Map<PokemonType, number> {
133
+ const counts = new Map<PokemonType, number>();
134
+ for (const t of POKEMON_TYPES) counts.set(t, 0);
135
+
136
+ for (const [idStr, entry] of Object.entries(state.pokedex.entries)) {
137
+ if (!entry.caught) continue;
138
+ const pokemon = POKEMON_BY_ID.get(Number(idStr));
139
+ if (!pokemon) continue;
140
+ for (const t of pokemon.types) {
141
+ if (t !== undefined) counts.set(t, (counts.get(t) ?? 0) + 1);
142
+ }
143
+ }
144
+ return counts;
145
+ }
146
+
147
+ /** Count how many caught Pokemon the player has from each generation. */
148
+ function getGenCounts(state: PlayerState): Map<number, number> {
149
+ const counts = new Map<number, number>();
150
+ for (const { gen } of GEN_RANGES) counts.set(gen, 0);
151
+
152
+ for (const [idStr, entry] of Object.entries(state.pokedex.entries)) {
153
+ if (!entry.caught) continue;
154
+ const gen = getGeneration(Number(idStr));
155
+ counts.set(gen, (counts.get(gen) ?? 0) + 1);
156
+ }
157
+ return counts;
158
+ }
159
+
160
+ // ── Smart Candidate Pool ──────────────────────────────────────
177
161
 
178
162
  /**
179
- * Collect all candidate Pokemon IDs for a set of types, respecting
180
- * ownership rules and rarity constraints.
163
+ * Build a weighted candidate pool from ALL wild-eligible Pokemon.
164
+ * Weighting considers:
165
+ * - Rarity (common 70, uncommon 25, rare 5)
166
+ * - Unseen species boost (2x if not seen, 4x in early game)
167
+ * - Type diversity (3x if player has 0 caught of this type)
168
+ * - Gen diversity (2x if player has < 3 caught from this gen)
169
+ * - 10% "same again" chance (pure random, no diversity boosts)
181
170
  */
182
- function buildCandidatePool(
183
- types: readonly PokemonType[],
184
- state: PlayerState,
185
- ): { id: number; weight: number }[] {
171
+ function buildSmartPool(state: PlayerState): { id: number; weight: number }[] {
186
172
  const candidates: { id: number; weight: number }[] = [];
187
- const seen = new Set<number>();
188
173
 
189
- // Determine the player's starter Pokemon ID
190
174
  const starterPokemon = [...state.party, ...state.pcBox].find((p) => p.isStarter);
191
175
  const starterPokemonId = starterPokemon?.pokemonId ?? -1;
192
176
 
193
- // Set of already-caught Pokemon IDs (for duplicate filtering)
194
177
  const caughtIds = new Set<number>();
178
+ const seenIds = new Set<number>();
195
179
  for (const [idStr, entry] of Object.entries(state.pokedex.entries)) {
196
- if (entry.caught) {
197
- caughtIds.add(Number(idStr));
198
- }
180
+ const id = Number(idStr);
181
+ if (entry.caught) caughtIds.add(id);
182
+ if (entry.seen) seenIds.add(id);
199
183
  }
200
184
 
201
- for (const pokemonType of types) {
202
- const pool = TYPE_POOLS.get(pokemonType);
203
- if (!pool) continue;
204
-
205
- for (const rarity of ["common", "uncommon", "rare"] as const) {
206
- const ids = pool[rarity];
207
- const weight = RARITY_WEIGHTS[rarity];
185
+ const totalCaught = state.pokedex.totalCaught ?? 0;
186
+ const isEarlyGame = totalCaught < 50;
208
187
 
209
- for (const id of ids) {
210
- // Skip if already added from another type overlap
211
- if (seen.has(id)) continue;
212
- seen.add(id);
213
-
214
- // Exclude the player's starter from wild encounters
215
- if (id === starterPokemonId) continue;
216
-
217
- // Common Pokemon: always available, duplicates allowed
218
- // Uncommon+: skip if already caught
219
- if (rarity !== "common" && caughtIds.has(id)) continue;
188
+ // 10% chance: pure random (no diversity boosting)
189
+ const seed = Math.floor(Date.now() / 1000);
190
+ const sameAgainRoll = seededRandom(seed + 99);
191
+ const applyDiversity = sameAgainRoll >= 0.1;
192
+
193
+ // Pre-compute diversity data only when needed
194
+ let typeCounts: Map<PokemonType, number> | null = null;
195
+ let genCounts: Map<number, number> | null = null;
196
+ if (applyDiversity) {
197
+ typeCounts = getTypeCounts(state);
198
+ genCounts = getGenCounts(state);
199
+ }
220
200
 
221
- candidates.push({ id, weight });
201
+ for (const rarity of ["common", "uncommon", "rare"] as const) {
202
+ const ids = ALL_WILD_POOL[rarity];
203
+ const baseWeight = RARITY_WEIGHTS[rarity];
204
+
205
+ for (const id of ids) {
206
+ // Exclude starter
207
+ if (id === starterPokemonId) continue;
208
+
209
+ // Uncommon+: skip if already caught (one-time only)
210
+ if (rarity !== "common" && caughtIds.has(id)) continue;
211
+
212
+ let weight = baseWeight;
213
+
214
+ if (applyDiversity) {
215
+ const pokemon = POKEMON_BY_ID.get(id);
216
+ if (pokemon) {
217
+ // Unseen species boost
218
+ if (!seenIds.has(id)) {
219
+ weight *= isEarlyGame ? 4 : 2;
220
+ }
221
+
222
+ // Type diversity: boost types player hasn't caught much
223
+ const primaryType = pokemon.types[0];
224
+ const typeCount = typeCounts!.get(primaryType) ?? 0;
225
+ if (typeCount === 0) {
226
+ weight *= 3; // Never caught this type
227
+ } else if (typeCount < 3) {
228
+ weight *= 1.5; // Few of this type
229
+ }
230
+
231
+ // Gen diversity: boost underrepresented generations
232
+ const gen = getGeneration(id);
233
+ const genCount = genCounts!.get(gen) ?? 0;
234
+ if (genCount === 0) {
235
+ weight *= 2; // Never caught from this gen
236
+ } else if (genCount < 3) {
237
+ weight *= 1.3; // Few from this gen
238
+ }
239
+ }
222
240
  }
241
+
242
+ candidates.push({ id, weight: Math.round(weight) });
223
243
  }
224
244
  }
225
245
 
226
246
  return candidates;
227
247
  }
228
248
 
229
- /**
230
- * Deterministic pseudo-random number generator seeded by the current second.
231
- * Uses a simple mulberry32 approach for reproducibility within the same second.
232
- */
249
+ // ── PRNG ──────────────────────────────────────────────────────
250
+
233
251
  function seededRandom(seed: number): number {
234
252
  let t = (seed + 0x6d2b79f5) | 0;
235
253
  t = Math.imul(t ^ (t >>> 15), t | 1);
@@ -237,10 +255,6 @@ function seededRandom(seed: number): number {
237
255
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
238
256
  }
239
257
 
240
- /**
241
- * Select a Pokemon ID from the weighted candidate pool using a deterministic seed.
242
- * Returns null if the pool is empty.
243
- */
244
258
  function weightedSelect(
245
259
  candidates: readonly { id: number; weight: number }[],
246
260
  seed: number,
@@ -258,19 +272,15 @@ function weightedSelect(
258
272
  }
259
273
  }
260
274
 
261
- // Fallback to last candidate (floating-point edge case)
262
275
  return candidates[candidates.length - 1]!.id;
263
276
  }
264
277
 
265
- /**
266
- * Determine the level of a wild encounter Pokemon.
267
- * Scales with the player's strongest Pokemon level, with some variance.
268
- */
278
+ // ── Level Determination ───────────────────────────────────────
279
+
269
280
  function determineEncounterLevel(state: PlayerState, seed: number): number {
270
281
  const allPokemon = [...state.party, ...state.pcBox];
271
282
  const maxLevel = allPokemon.reduce((max, p) => Math.max(max, p.level), 1);
272
283
 
273
- // Wild Pokemon appear at 60-100% of the player's max level, minimum 2
274
284
  const minLevel = Math.max(2, Math.floor(maxLevel * 0.6));
275
285
  const range = Math.max(1, maxLevel - minLevel + 1);
276
286
  const level = minLevel + Math.floor(seededRandom(seed + 7) * range);
@@ -278,31 +288,22 @@ function determineEncounterLevel(state: PlayerState, seed: number): number {
278
288
  return Math.min(level, 100);
279
289
  }
280
290
 
291
+ // ── Encounter Generation ──────────────────────────────────────
292
+
281
293
  /**
282
- * Generate a wild encounter based on the activity type.
283
- * Picks a Pokemon from the matching type pool, weighted by rarity.
284
- * If time-of-day bias types are provided, there is a 40% chance to
285
- * use those types instead of the activity-based types.
286
- * Excludes Pokemon already in the player's party/box (unless common tier).
287
- * Returns null if no eligible Pokemon found.
294
+ * Generate a wild encounter 100% random with Pokedex-aware weighting.
295
+ * No activity-type or time-of-day bias. Any Pokemon can appear anytime.
296
+ * Unseen types, gens, and species are boosted to fill the Pokedex faster.
297
+ *
298
+ * The eventType parameter is kept for API compatibility but is not used
299
+ * for type selection.
288
300
  */
289
301
  export function generateEncounter(
290
- eventType: XpEventType,
302
+ _eventType: XpEventType,
291
303
  state: PlayerState,
292
- timeOfDayTypes?: readonly PokemonType[],
304
+ _timeOfDayTypes?: readonly PokemonType[],
293
305
  ): WildEncounter | null {
294
- let types = getEncounterTypes(eventType);
295
-
296
- // 40% chance to use time-of-day biased types if available
297
- if (timeOfDayTypes && timeOfDayTypes.length > 0) {
298
- const seed = Math.floor(Date.now() / 1000);
299
- const biasRoll = seededRandom(seed + 42);
300
- if (biasRoll < 0.4) {
301
- types = timeOfDayTypes;
302
- }
303
- }
304
-
305
- const candidates = buildCandidatePool(types, state);
306
+ const candidates = buildSmartPool(state);
306
307
 
307
308
  if (candidates.length === 0) return null;
308
309
 
@@ -319,10 +320,6 @@ export function generateEncounter(
319
320
 
320
321
  // ── Catch Evaluation ──────────────────────────────────────────
321
322
 
322
- /**
323
- * Check if the active Pokemon can catch the encountered Pokemon.
324
- * Based on catch rate, required stats, and level.
325
- */
326
323
  export function canCatch(
327
324
  encounter: WildEncounter,
328
325
  activePokemon: OwnedPokemon,
@@ -334,7 +331,6 @@ export function canCatch(
334
331
 
335
332
  const { requiredStat, minStatValue, requiredLevel } = encounter.catchCondition;
336
333
 
337
- // Check level requirement
338
334
  if (activePokemon.level < requiredLevel) {
339
335
  return {
340
336
  success: false,
@@ -342,7 +338,6 @@ export function canCatch(
342
338
  };
343
339
  }
344
340
 
345
- // Check stat requirement
346
341
  if (requiredStat !== null) {
347
342
  const currentStat = activePokemon.codingStats[requiredStat];
348
343
  if (currentStat < minStatValue) {
@@ -354,7 +349,6 @@ export function canCatch(
354
349
  }
355
350
  }
356
351
 
357
- // Requirements met — roll against catch rate
358
352
  const seed = Math.floor(Date.now() / 1000);
359
353
  const roll = seededRandom(seed + encounter.pokemonId) * 255;
360
354
  const success = pokemon.catchRate > roll;