@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
@@ -4,6 +4,8 @@
4
4
  *
5
5
  * Loads state, awards XP to the active Pokemon, applies stat boosts,
6
6
  * increments the event counter, updates the streak, and saves.
7
+ * Supports enhanced encounter triggers: streak bonuses, time-of-day bias,
8
+ * bonus encounters (10%), and tool diversity encounters.
7
9
  * Emits a terminal bell on level-up.
8
10
  */
9
11
  import { BELL } from "../engine/constants.js";
@@ -12,7 +14,9 @@ import { addXp, createXpEvent } from "../engine/xp.js";
12
14
  import { applyStatBoost } from "../engine/stats.js";
13
15
  import { POKEMON_BY_ID } from "../engine/pokemon-data.js";
14
16
  import { checkEvolution, getNewlyEarnedBadges } from "../engine/evolution.js";
15
- import { shouldTriggerEncounter, generateEncounter } from "../engine/encounters.js";
17
+ import { shouldTriggerEncounter, generateEncounter, shouldBonusEncounter, shouldDiversityBonus, getTimeOfDayBias, } from "../engine/encounters.js";
18
+ import { calculateMood } from "../engine/mood.js";
19
+ const MAX_RECENT_TOOL_TYPES = 20;
16
20
  const eventType = process.argv[2];
17
21
  const counterKey = process.argv[3];
18
22
  if (!eventType) {
@@ -40,12 +44,34 @@ const levelUp = addXp(pokemon, xpEvent.xp, pokemonData);
40
44
  if (xpEvent.statBoost && xpEvent.boostAmount > 0) {
41
45
  applyStatBoost(pokemon, xpEvent.statBoost, xpEvent.boostAmount);
42
46
  }
47
+ // XP sharing: give inactive party members a percentage
48
+ const sharePercent = state.config.xpSharePercent ?? 0;
49
+ if (sharePercent > 0 && state.party.length > 1) {
50
+ const sharedXp = Math.floor((xpEvent.xp * sharePercent) / 100);
51
+ if (sharedXp > 0) {
52
+ for (const member of state.party) {
53
+ if (member.isActive)
54
+ continue;
55
+ const memberSpecies = POKEMON_BY_ID.get(member.pokemonId);
56
+ if (memberSpecies) {
57
+ addXp(member, sharedXp, memberSpecies);
58
+ }
59
+ }
60
+ }
61
+ }
43
62
  // Track total XP earned by the trainer
44
63
  state.totalXpEarned += xpEvent.xp;
45
64
  // Increment the event counter directly on state (avoids extra save from incrementCounter)
46
65
  if (counterKey) {
47
66
  state.counters[counterKey] += 1;
48
67
  }
68
+ // Track tool type for diversity bonus (keep last MAX_RECENT_TOOL_TYPES entries)
69
+ const recentToolTypes = state.recentToolTypes ?? [];
70
+ recentToolTypes.push(eventType);
71
+ if (recentToolTypes.length > MAX_RECENT_TOOL_TYPES) {
72
+ recentToolTypes.splice(0, recentToolTypes.length - MAX_RECENT_TOOL_TYPES);
73
+ }
74
+ state.recentToolTypes = recentToolTypes;
49
75
  // Update the daily coding streak
50
76
  // Inline the streak logic to avoid the extra save from updateStreak()
51
77
  const today = new Date();
@@ -81,17 +107,61 @@ for (const badge of newBadges) {
81
107
  }
82
108
  // Check if evolution is available (sets flag in status for status line indicator)
83
109
  const evolutionReady = checkEvolution(pokemon, state) !== null;
84
- // Track XP toward next encounter and trigger if threshold met
85
- state.xpSinceLastEncounter = (state.xpSinceLastEncounter ?? 0) + xpEvent.xp;
110
+ // Build encounter context for the enhanced trigger system
111
+ const encounterSpeed = state.config.encounterSpeed ?? "normal";
112
+ const currentHour = new Date().getHours();
113
+ const timeOfDayTypes = getTimeOfDayBias(currentHour);
114
+ const encounterCtx = {
115
+ xpSinceLastEncounter: (state.xpSinceLastEncounter ?? 0) + xpEvent.xp,
116
+ encounterSpeed,
117
+ currentStreak: streak.currentStreak,
118
+ recentToolTypes: state.recentToolTypes,
119
+ currentHour,
120
+ };
121
+ // Track XP toward next encounter
122
+ state.xpSinceLastEncounter = encounterCtx.xpSinceLastEncounter;
86
123
  let encounterTriggered = false;
87
- if (shouldTriggerEncounter(state.xpSinceLastEncounter) && !state.pendingEncounter) {
88
- const encounter = generateEncounter(eventType, state);
124
+ if (shouldTriggerEncounter(encounterCtx) && !state.pendingEncounter) {
125
+ const encounter = generateEncounter(eventType, state, timeOfDayTypes);
89
126
  if (encounter) {
90
127
  state.pendingEncounter = encounter;
91
128
  state.xpSinceLastEncounter = 0;
129
+ state.lastEncounterTime = Date.now();
130
+ encounterTriggered = true;
131
+ // 10% chance for a bonus encounter after a regular one
132
+ // (bonus replaces the pending encounter with a second roll)
133
+ if (shouldBonusEncounter()) {
134
+ const bonusEncounter = generateEncounter(eventType, state, timeOfDayTypes);
135
+ if (bonusEncounter) {
136
+ // The bonus encounter replaces the first; first is already set as pending
137
+ // In practice the player still sees one encounter per trigger,
138
+ // but the bonus gives them a fresh roll (potentially rarer Pokemon)
139
+ state.pendingEncounter = bonusEncounter;
140
+ }
141
+ }
142
+ }
143
+ }
144
+ // Tool diversity bonus: if 3+ unique tool types used recently and no pending encounter,
145
+ // grant an extra encounter opportunity
146
+ if (!encounterTriggered && !state.pendingEncounter && shouldDiversityBonus(state.recentToolTypes)) {
147
+ const diversityEncounter = generateEncounter(eventType, state, timeOfDayTypes);
148
+ if (diversityEncounter) {
149
+ state.pendingEncounter = diversityEncounter;
150
+ state.xpSinceLastEncounter = 0;
151
+ state.lastEncounterTime = Date.now();
152
+ // Clear recent tool types after diversity bonus triggers
153
+ state.recentToolTypes = [];
92
154
  encounterTriggered = true;
93
155
  }
94
156
  }
157
+ // Calculate mood based on the event that just happened
158
+ const newMood = calculateMood(eventType, state.counters, currentHour, state.mood ?? "neutral", state.moodSetAt ?? 0, evolutionReady, // evolution ready counts as a proud trigger
159
+ false, // achievements are checked in catch/evolve tools
160
+ false);
161
+ if (newMood !== (state.mood ?? "neutral")) {
162
+ state.mood = newMood;
163
+ state.moodSetAt = Date.now();
164
+ }
95
165
  // Single atomic save for all mutations
96
166
  await stateManager.save();
97
167
  // Write status for the status line (includes evolution flag and encounter notification)
@@ -17,6 +17,10 @@ import { registerAchievementsTool } from "./tools/achievements.js";
17
17
  import { registerLegendaryTool } from "./tools/legendary.js";
18
18
  import { registerHideTool, registerUnhideTool } from "./tools/visibility.js";
19
19
  import { registerRenameTool } from "./tools/rename.js";
20
+ import { registerSettingsTool } from "./tools/settings.js";
21
+ import { registerFeedTool } from "./tools/feed.js";
22
+ import { registerTrainTool } from "./tools/train.js";
23
+ import { registerPlayTool } from "./tools/play.js";
20
24
  import { buildInstructions } from "./instructions.js";
21
25
  /** Safely register a tool, logging to stderr on failure instead of crashing. */
22
26
  function safeRegister(name, register, server) {
@@ -56,6 +60,10 @@ async function main() {
56
60
  safeRegister("buddy_hide", registerHideTool, server);
57
61
  safeRegister("buddy_unhide", registerUnhideTool, server);
58
62
  safeRegister("buddy_rename", registerRenameTool, server);
63
+ safeRegister("buddy_settings", registerSettingsTool, server);
64
+ safeRegister("buddy_feed", registerFeedTool, server);
65
+ safeRegister("buddy_train", registerTrainTool, server);
66
+ safeRegister("buddy_play", registerPlayTool, server);
59
67
  // Connect via stdio transport
60
68
  const transport = new StdioServerTransport();
61
69
  await server.connect(transport);
@@ -7,6 +7,7 @@ import { POKEMON_BY_ID } from "../engine/pokemon-data.js";
7
7
  import { cumulativeXpForLevel } from "../engine/xp.js";
8
8
  import { getEvolutionLinks } from "../engine/evolution.js";
9
9
  import { getTypePersonality } from "../engine/reactions.js";
10
+ import { getMoodDescription } from "../engine/mood.js";
10
11
  // ── Public API ──────────────────────────────────────────────
11
12
  /**
12
13
  * Build dynamic instructions based on current player state.
@@ -45,11 +46,16 @@ function buildActiveInstructions(state, active, species) {
45
46
  const personality = getTypePersonality(primaryType);
46
47
  const evolutionNote = buildEvolutionNote(active, species);
47
48
  const encounterNote = buildEncounterNote(state);
49
+ const currentMood = state.mood ?? "neutral";
50
+ const moodDesc = getMoodDescription(currentMood);
51
+ const moodHint = buildMoodHint(currentMood);
48
52
  const lines = [
49
53
  "You have a Claudemon Pokemon companion.",
50
54
  "",
51
55
  `Active Pokemon: ${displayName}, Level ${active.level}, ${typeStr} type.`,
52
56
  `Personality: ${personality}`,
57
+ `Current mood: ${moodDesc.toLowerCase()}${moodHint}`,
58
+ "Adjust your buddy references to match the mood.",
53
59
  "",
54
60
  `Occasionally (not every message), naturally reference ${displayName}:`,
55
61
  `- When an error occurs: ${displayName} reacts (use ${primaryType} type personality)`,
@@ -75,6 +81,23 @@ function buildActiveInstructions(state, active, species) {
75
81
  return lines.join("\n");
76
82
  }
77
83
  // ── Helper Functions ────────────────────────────────────────
84
+ /** Build a short contextual hint for the current mood. */
85
+ function buildMoodHint(mood) {
86
+ switch (mood) {
87
+ case "happy":
88
+ return " (tests passing, feeling good)";
89
+ case "worried":
90
+ return " (errors detected, feeling anxious)";
91
+ case "sleepy":
92
+ return " (late night coding, very drowsy)";
93
+ case "energetic":
94
+ return " (morning energy, fired up!)";
95
+ case "proud":
96
+ return " (just accomplished something big!)";
97
+ default:
98
+ return " (calm, waiting for action)";
99
+ }
100
+ }
78
101
  /** Find the active Pokemon in the player's party. */
79
102
  function findActivePokemon(state) {
80
103
  return state.party.find((p) => p.isActive) ?? null;
@@ -210,6 +210,9 @@ export function registerCatchTool(server) {
210
210
  for (const achievement of newAchievements) {
211
211
  state.achievements.push(unlockAchievement(achievement.id));
212
212
  }
213
+ // Set mood to proud after a successful catch
214
+ state.mood = "proud";
215
+ state.moodSetAt = Date.now();
213
216
  // Save state
214
217
  await stateManager.save();
215
218
  await stateManager.writeStatus();
@@ -157,6 +157,9 @@ export function registerEvolveTool(server) {
157
157
  }
158
158
  // Confirm mode: apply evolution
159
159
  const { newName, newTypes } = applyEvolution(active, eligibleLink.to);
160
+ // Set mood to proud after evolution
161
+ state.mood = "proud";
162
+ state.moodSetAt = Date.now();
160
163
  // Save state
161
164
  await stateManager.save();
162
165
  await stateManager.writeStatus();
@@ -0,0 +1,120 @@
1
+ /**
2
+ * buddy_feed tool — Feed your active Pokemon to boost happiness.
3
+ * Has a 1-hour cooldown. Returns type-flavored food reactions.
4
+ */
5
+ import { POKEMON_BY_ID } from "../../engine/pokemon-data.js";
6
+ import { MAX_HAPPINESS } from "../../engine/constants.js";
7
+ import { StateManager } from "../../state/state-manager.js";
8
+ /** Cooldown: 1 hour in milliseconds. */
9
+ const FEED_COOLDOWN_MS = 3_600_000;
10
+ /** Happiness increase per feed. */
11
+ const FEED_HAPPINESS_BOOST = 10;
12
+ /** Food reactions keyed by primary Pokemon type. */
13
+ const FEED_REACTIONS = {
14
+ Normal: (n) => `*${n} enjoys a PokePuff!* \u{1F9C1}`,
15
+ Fire: (n) => `*${n} gobbles down a Spicy Berry!* \u{1F336}\u{FE0F}`,
16
+ Water: (n) => `*${n} slurps an Oran Berry!* \u{1F4A7}`,
17
+ Electric: (n) => `*${n} munches a Cheri Berry!* \u{26A1}`,
18
+ Grass: (n) => `*${n} nibbles a fresh Leaf Salad!* \u{1F33F}`,
19
+ Ice: (n) => `*${n} crunches a Frozen Berry!* \u{2744}\u{FE0F}`,
20
+ Fighting: (n) => `*${n} devours a Protein Shake!* \u{1F4AA}`,
21
+ Poison: (n) => `*${n} sips a Pecha Smoothie!* \u{1F49C}`,
22
+ Ground: (n) => `*${n} chews a Rawst Root!* \u{1F33E}`,
23
+ Flying: (n) => `*${n} pecks at Skyberry Seeds!* \u{1F426}`,
24
+ Psychic: (n) => `*${n} absorbs a Mind Melon!* \u{1F52E}`,
25
+ Bug: (n) => `*${n} nibbles on Sweet Honey!* \u{1F36F}`,
26
+ Rock: (n) => `*${n} crunches a Mineral Cookie!* \u{1FAA8}`,
27
+ Ghost: (n) => `*${n} absorbs a Shadow Treat!* \u{1F47B}`,
28
+ Dragon: (n) => `*${n} feasts on a Dragon Scale Fruit!* \u{1F409}`,
29
+ };
30
+ /** Get a feed reaction based on the Pokemon's primary type. */
31
+ function getFeedReaction(species, displayName) {
32
+ const primaryType = species.types[0];
33
+ return FEED_REACTIONS[primaryType](displayName);
34
+ }
35
+ /** Format remaining cooldown as "Xm Ys". */
36
+ function formatCooldownRemaining(remainingMs) {
37
+ const totalSeconds = Math.ceil(remainingMs / 1000);
38
+ const minutes = Math.floor(totalSeconds / 60);
39
+ const seconds = totalSeconds % 60;
40
+ if (minutes > 0 && seconds > 0) {
41
+ return `${minutes}m ${seconds}s`;
42
+ }
43
+ if (minutes > 0) {
44
+ return `${minutes}m`;
45
+ }
46
+ return `${seconds}s`;
47
+ }
48
+ /** Registers the buddy_feed tool on the MCP server. */
49
+ export function registerFeedTool(server) {
50
+ server.tool("buddy_feed", "Feed your active Pokemon to boost happiness. 1-hour cooldown.", {}, async () => {
51
+ const stateManager = StateManager.getInstance();
52
+ const state = await stateManager.load();
53
+ if (!state || state.party.length === 0) {
54
+ return {
55
+ content: [
56
+ {
57
+ type: "text",
58
+ text: "You don't have a Pokemon yet! Use buddy_starter to pick your first companion.",
59
+ },
60
+ ],
61
+ };
62
+ }
63
+ const active = stateManager.getActivePokemon();
64
+ if (!active) {
65
+ return {
66
+ content: [
67
+ {
68
+ type: "text",
69
+ text: "No active Pokemon found. Use buddy_party to set one active.",
70
+ },
71
+ ],
72
+ };
73
+ }
74
+ const species = POKEMON_BY_ID.get(active.pokemonId);
75
+ if (!species) {
76
+ return {
77
+ content: [
78
+ {
79
+ type: "text",
80
+ text: `Could not find species data for Pokemon ID ${active.pokemonId}.`,
81
+ },
82
+ ],
83
+ isError: true,
84
+ };
85
+ }
86
+ // Check cooldown
87
+ const now = Date.now();
88
+ const elapsed = now - state.lastFedAt;
89
+ if (elapsed < FEED_COOLDOWN_MS) {
90
+ const remaining = FEED_COOLDOWN_MS - elapsed;
91
+ return {
92
+ content: [
93
+ {
94
+ type: "text",
95
+ text: `Already fed! Try again in ${formatCooldownRemaining(remaining)}.`,
96
+ },
97
+ ],
98
+ };
99
+ }
100
+ const displayName = active.nickname ?? species.name;
101
+ // Increase happiness (cap at MAX_HAPPINESS)
102
+ const previousHappiness = active.happiness;
103
+ active.happiness = Math.min(MAX_HAPPINESS, active.happiness + FEED_HAPPINESS_BOOST);
104
+ // Set mood to happy
105
+ state.mood = "happy";
106
+ state.moodSetAt = now;
107
+ // Record feed timestamp
108
+ state.lastFedAt = now;
109
+ // Save state and update status line
110
+ await stateManager.save();
111
+ await stateManager.writeStatus();
112
+ // Build response
113
+ const reaction = getFeedReaction(species, displayName);
114
+ const lines = [reaction, ""];
115
+ lines.push(`Happiness: ${previousHappiness} \u2192 ${active.happiness}`);
116
+ return {
117
+ content: [{ type: "text", text: lines.join("\n") }],
118
+ };
119
+ });
120
+ }
@@ -0,0 +1,310 @@
1
+ /**
2
+ * buddy_play tool — Play a Pokemon trivia quiz with your active Pokemon.
3
+ * Has a 15-minute cooldown (applied after quiz completion, not between question and answer).
4
+ * Awards +20 XP for correct answers, +5 XP for wrong answers.
5
+ */
6
+ import { z } from "zod";
7
+ import { POKEMON_TYPES } from "../../engine/types.js";
8
+ import { POKEDEX, POKEMON_BY_ID } from "../../engine/pokemon-data.js";
9
+ import { EVOLUTION_CHAINS } from "../../engine/evolution-data.js";
10
+ import { addXp } from "../../engine/xp.js";
11
+ import { StateManager } from "../../state/state-manager.js";
12
+ /** Cooldown: 15 minutes in milliseconds (applied after completing a quiz). */
13
+ const PLAY_COOLDOWN_MS = 900_000;
14
+ /** XP awarded for correct answer. */
15
+ const CORRECT_XP = 20;
16
+ /** XP awarded for wrong answer (participation). */
17
+ const WRONG_XP = 5;
18
+ // ── Gen 1 Type Effectiveness Chart ────────────────────────
19
+ /** Maps each type to the types that are super effective against it. */
20
+ const TYPE_WEAKNESSES = {
21
+ Normal: ["Fighting"],
22
+ Fire: ["Water", "Ground", "Rock"],
23
+ Water: ["Electric", "Grass"],
24
+ Electric: ["Ground"],
25
+ Grass: ["Fire", "Ice", "Poison", "Flying", "Bug"],
26
+ Ice: ["Fire", "Fighting", "Rock"],
27
+ Fighting: ["Flying", "Psychic"],
28
+ Poison: ["Ground", "Psychic"],
29
+ Ground: ["Water", "Grass", "Ice"],
30
+ Flying: ["Electric", "Ice", "Rock"],
31
+ Psychic: ["Bug", "Ghost"],
32
+ Bug: ["Fire", "Flying", "Rock"],
33
+ Rock: ["Water", "Grass", "Fighting", "Ground"],
34
+ Ghost: ["Ghost"],
35
+ Dragon: ["Ice", "Dragon"],
36
+ };
37
+ // ── Utility Functions ─────────────────────────────────────
38
+ /** Pick a random element from a non-empty array. */
39
+ function randomPick(arr) {
40
+ const index = Math.floor(Math.random() * arr.length);
41
+ return arr[index];
42
+ }
43
+ /** Shuffle an array in place (Fisher-Yates) and return it. */
44
+ function shuffle(arr) {
45
+ for (let i = arr.length - 1; i > 0; i--) {
46
+ const j = Math.floor(Math.random() * (i + 1));
47
+ const temp = arr[i];
48
+ arr[i] = arr[j];
49
+ arr[j] = temp;
50
+ }
51
+ return arr;
52
+ }
53
+ /** Format remaining cooldown as "Xm Ys". */
54
+ function formatCooldownRemaining(remainingMs) {
55
+ const totalSeconds = Math.ceil(remainingMs / 1000);
56
+ const minutes = Math.floor(totalSeconds / 60);
57
+ const seconds = totalSeconds % 60;
58
+ if (minutes > 0 && seconds > 0) {
59
+ return `${minutes}m ${seconds}s`;
60
+ }
61
+ if (minutes > 0) {
62
+ return `${minutes}m`;
63
+ }
64
+ return `${seconds}s`;
65
+ }
66
+ // ── Quiz Generators ───────────────────────────────────────
67
+ /** Generate a type matchup quiz question. */
68
+ function generateTypeMatchup() {
69
+ // Pick a type that has weaknesses
70
+ const typesWithWeaknesses = POKEMON_TYPES.filter((t) => TYPE_WEAKNESSES[t].length > 0);
71
+ const targetType = randomPick(typesWithWeaknesses);
72
+ const weaknesses = TYPE_WEAKNESSES[targetType];
73
+ const correctType = randomPick(weaknesses);
74
+ // Generate 3 wrong answers (types NOT in the weakness list)
75
+ const wrongTypes = POKEMON_TYPES.filter((t) => !weaknesses.includes(t) && t !== targetType);
76
+ const wrongAnswers = shuffle([...wrongTypes]).slice(0, 3);
77
+ // Combine and shuffle, tracking correct position
78
+ const options = shuffle([correctType, ...wrongAnswers]);
79
+ const correctAnswer = options.indexOf(correctType) + 1; // 1-indexed
80
+ return {
81
+ type: "type_matchup",
82
+ question: `What type is super effective against ${targetType}?`,
83
+ options,
84
+ correctAnswer,
85
+ };
86
+ }
87
+ /** Generate a stat comparison quiz question. */
88
+ function generateStatCompare() {
89
+ const statPairs = [
90
+ { name: "HP", key: "hp" },
91
+ { name: "Attack", key: "attack" },
92
+ { name: "Defense", key: "defense" },
93
+ { name: "Speed", key: "speed" },
94
+ { name: "Special", key: "special" },
95
+ ];
96
+ const pair = randomPick(statPairs);
97
+ const statName = pair.name;
98
+ const statKey = pair.key;
99
+ // Pick 2 different Pokemon
100
+ let pokemon1 = randomPick(POKEDEX);
101
+ let pokemon2 = randomPick(POKEDEX);
102
+ // Ensure they are different and have different stat values for a clear answer
103
+ let attempts = 0;
104
+ while ((pokemon2.id === pokemon1.id || pokemon1.baseStats[statKey] === pokemon2.baseStats[statKey]) &&
105
+ attempts < 50) {
106
+ pokemon2 = randomPick(POKEDEX);
107
+ attempts++;
108
+ }
109
+ const stat1 = pokemon1.baseStats[statKey];
110
+ const stat2 = pokemon2.baseStats[statKey];
111
+ // If still tied after attempts, just pick a winner
112
+ const correctName = stat1 >= stat2 ? pokemon1.name : pokemon2.name;
113
+ const options = [pokemon1.name, pokemon2.name];
114
+ const correctAnswer = options.indexOf(correctName) + 1; // 1 or 2
115
+ return {
116
+ type: "stat_compare",
117
+ question: `Who has higher ${statName}: ${pokemon1.name} or ${pokemon2.name}?`,
118
+ options,
119
+ correctAnswer,
120
+ };
121
+ }
122
+ /** Generate an evolution quiz question. */
123
+ function generateEvolutionQuiz() {
124
+ // Get chains that have at least one evolution link
125
+ const chainsWithEvolutions = EVOLUTION_CHAINS.filter((c) => c.links.length > 0);
126
+ const chain = randomPick(chainsWithEvolutions);
127
+ const link = randomPick(chain.links);
128
+ const fromPokemon = POKEMON_BY_ID.get(link.from);
129
+ const toPokemon = POKEMON_BY_ID.get(link.to);
130
+ if (!fromPokemon || !toPokemon) {
131
+ // Fallback to a known evolution if data lookup fails
132
+ return generateTypeMatchup();
133
+ }
134
+ // Generate 3 wrong answers (random Pokemon that are NOT the correct evolution)
135
+ const wrongPokemon = shuffle(POKEDEX.filter((p) => p.id !== toPokemon.id && p.id !== fromPokemon.id)).slice(0, 3);
136
+ const options = shuffle([toPokemon.name, ...wrongPokemon.map((p) => p.name)]);
137
+ const correctAnswer = options.indexOf(toPokemon.name) + 1;
138
+ return {
139
+ type: "evolution",
140
+ question: `What does ${fromPokemon.name} evolve into?`,
141
+ options,
142
+ correctAnswer,
143
+ };
144
+ }
145
+ /** Generate a Pokedex type trivia question. */
146
+ function generatePokedexTrivia() {
147
+ const pokemon = randomPick(POKEDEX);
148
+ const correctType = pokemon.types[0];
149
+ // Generate 3 wrong type answers
150
+ const wrongTypes = shuffle([...POKEMON_TYPES].filter((t) => t !== correctType && !pokemon.types.includes(t))).slice(0, 3);
151
+ const options = shuffle([correctType, ...wrongTypes]);
152
+ const correctAnswer = options.indexOf(correctType) + 1;
153
+ return {
154
+ type: "pokedex_trivia",
155
+ question: `What is ${pokemon.name}'s primary type?`,
156
+ options,
157
+ correctAnswer,
158
+ };
159
+ }
160
+ /** Generate a random quiz question. */
161
+ function generateQuiz() {
162
+ const generators = [
163
+ generateTypeMatchup,
164
+ generateStatCompare,
165
+ generateEvolutionQuiz,
166
+ generatePokedexTrivia,
167
+ ];
168
+ return randomPick(generators)();
169
+ }
170
+ /** Format a quiz for display. */
171
+ function formatQuiz(quiz) {
172
+ const lines = [];
173
+ lines.push(`**${quiz.question}**`);
174
+ lines.push("");
175
+ for (let i = 0; i < quiz.options.length; i++) {
176
+ lines.push(` ${i + 1}. ${quiz.options[i]}`);
177
+ }
178
+ lines.push("");
179
+ lines.push("Use `/buddy play answer 1`, `2`, `3`, or `4` to answer.");
180
+ return lines.join("\n");
181
+ }
182
+ /** Registers the buddy_play tool on the MCP server. */
183
+ export function registerPlayTool(server) {
184
+ server.tool("buddy_play", "Play a Pokemon trivia quiz with your active Pokemon. Use without arguments to get a question, or with answer=N (1-4) to answer.", {
185
+ answer: z.number().int().min(1).max(4).optional().describe("Your answer: 1, 2, 3, or 4."),
186
+ }, async ({ answer }) => {
187
+ const stateManager = StateManager.getInstance();
188
+ const state = await stateManager.load();
189
+ if (!state || state.party.length === 0) {
190
+ return {
191
+ content: [
192
+ {
193
+ type: "text",
194
+ text: "You don't have a Pokemon yet! Use buddy_starter to pick your first companion.",
195
+ },
196
+ ],
197
+ };
198
+ }
199
+ const active = stateManager.getActivePokemon();
200
+ if (!active) {
201
+ return {
202
+ content: [
203
+ {
204
+ type: "text",
205
+ text: "No active Pokemon found. Use buddy_party to set one active.",
206
+ },
207
+ ],
208
+ };
209
+ }
210
+ const species = POKEMON_BY_ID.get(active.pokemonId);
211
+ if (!species) {
212
+ return {
213
+ content: [
214
+ {
215
+ type: "text",
216
+ text: `Could not find species data for Pokemon ID ${active.pokemonId}.`,
217
+ },
218
+ ],
219
+ isError: true,
220
+ };
221
+ }
222
+ const displayName = active.nickname ?? species.name;
223
+ // ── Answer an existing quiz ────────────────────────────
224
+ if (answer !== undefined) {
225
+ if (!state.pendingQuiz) {
226
+ return {
227
+ content: [
228
+ {
229
+ type: "text",
230
+ text: "No quiz in progress! Use `/buddy play` to start a new one.",
231
+ },
232
+ ],
233
+ };
234
+ }
235
+ const quiz = state.pendingQuiz;
236
+ const isCorrect = answer === quiz.correctAnswer;
237
+ const xpAmount = isCorrect ? CORRECT_XP : WRONG_XP;
238
+ // Award XP
239
+ const levelUp = addXp(active, xpAmount, species);
240
+ // Set mood based on result
241
+ if (isCorrect) {
242
+ state.mood = "happy";
243
+ }
244
+ state.moodSetAt = Date.now();
245
+ // Clear quiz and set cooldown
246
+ state.pendingQuiz = null;
247
+ state.lastPlayedAt = Date.now();
248
+ // Save state
249
+ await stateManager.save();
250
+ await stateManager.writeStatus();
251
+ // Build response
252
+ const lines = [];
253
+ if (isCorrect) {
254
+ lines.push(`*${displayName} cheers! Correct!* \u{1F389}`);
255
+ }
256
+ else {
257
+ const correctOption = quiz.options[quiz.correctAnswer - 1];
258
+ lines.push(`*${displayName} shrugs. Better luck next time!*`);
259
+ lines.push(`The correct answer was: **${quiz.correctAnswer}. ${correctOption}**`);
260
+ }
261
+ lines.push("");
262
+ lines.push(`+${xpAmount} XP`);
263
+ if (levelUp) {
264
+ lines.push("");
265
+ lines.push(`*** ${displayName} grew to Lv.${levelUp.newLevel}! ***`);
266
+ }
267
+ return {
268
+ content: [{ type: "text", text: lines.join("\n") }],
269
+ };
270
+ }
271
+ // ── Start a new quiz ───────────────────────────────────
272
+ // If there's already a pending quiz, show it again
273
+ if (state.pendingQuiz) {
274
+ const lines = [];
275
+ lines.push(`*${displayName} is still waiting for your answer!*`);
276
+ lines.push("");
277
+ lines.push(formatQuiz(state.pendingQuiz));
278
+ return {
279
+ content: [{ type: "text", text: lines.join("\n") }],
280
+ };
281
+ }
282
+ // Check cooldown (only applies when starting a new quiz after completing one)
283
+ const now = Date.now();
284
+ const elapsed = now - state.lastPlayedAt;
285
+ if (state.lastPlayedAt > 0 && elapsed < PLAY_COOLDOWN_MS) {
286
+ const remaining = PLAY_COOLDOWN_MS - elapsed;
287
+ return {
288
+ content: [
289
+ {
290
+ type: "text",
291
+ text: `${displayName} needs a break from quizzing! Try again in ${formatCooldownRemaining(remaining)}.`,
292
+ },
293
+ ],
294
+ };
295
+ }
296
+ // Generate and store a new quiz
297
+ const quiz = generateQuiz();
298
+ state.pendingQuiz = quiz;
299
+ // Save state (stores the pending quiz)
300
+ await stateManager.save();
301
+ // Build response
302
+ const lines = [];
303
+ lines.push(`*${displayName} perks up for quiz time!* \u{1F4DA}`);
304
+ lines.push("");
305
+ lines.push(formatQuiz(quiz));
306
+ return {
307
+ content: [{ type: "text", text: lines.join("\n") }],
308
+ };
309
+ });
310
+ }