@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,145 @@
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
+
6
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ import type { PokemonType, Pokemon } from "../../engine/types.js";
8
+ import { POKEMON_BY_ID } from "../../engine/pokemon-data.js";
9
+ import { MAX_HAPPINESS } from "../../engine/constants.js";
10
+ import { StateManager } from "../../state/state-manager.js";
11
+
12
+ /** Cooldown: 1 hour in milliseconds. */
13
+ const FEED_COOLDOWN_MS = 3_600_000;
14
+
15
+ /** Happiness increase per feed. */
16
+ const FEED_HAPPINESS_BOOST = 10;
17
+
18
+ /** Food reactions keyed by primary Pokemon type. */
19
+ const FEED_REACTIONS: Record<PokemonType, (name: string) => string> = {
20
+ Normal: (n) => `*${n} enjoys a PokePuff!* \u{1F9C1}`,
21
+ Fire: (n) => `*${n} gobbles down a Spicy Berry!* \u{1F336}\u{FE0F}`,
22
+ Water: (n) => `*${n} slurps an Oran Berry!* \u{1F4A7}`,
23
+ Electric: (n) => `*${n} munches a Cheri Berry!* \u{26A1}`,
24
+ Grass: (n) => `*${n} nibbles a fresh Leaf Salad!* \u{1F33F}`,
25
+ Ice: (n) => `*${n} crunches a Frozen Berry!* \u{2744}\u{FE0F}`,
26
+ Fighting: (n) => `*${n} devours a Protein Shake!* \u{1F4AA}`,
27
+ Poison: (n) => `*${n} sips a Pecha Smoothie!* \u{1F49C}`,
28
+ Ground: (n) => `*${n} chews a Rawst Root!* \u{1F33E}`,
29
+ Flying: (n) => `*${n} pecks at Skyberry Seeds!* \u{1F426}`,
30
+ Psychic: (n) => `*${n} absorbs a Mind Melon!* \u{1F52E}`,
31
+ Bug: (n) => `*${n} nibbles on Sweet Honey!* \u{1F36F}`,
32
+ Rock: (n) => `*${n} crunches a Mineral Cookie!* \u{1FAA8}`,
33
+ Ghost: (n) => `*${n} absorbs a Shadow Treat!* \u{1F47B}`,
34
+ Dragon: (n) => `*${n} feasts on a Dragon Scale Fruit!* \u{1F409}`,
35
+ };
36
+
37
+ /** Get a feed reaction based on the Pokemon's primary type. */
38
+ function getFeedReaction(species: Pokemon, displayName: string): string {
39
+ const primaryType = species.types[0];
40
+ return FEED_REACTIONS[primaryType](displayName);
41
+ }
42
+
43
+ /** Format remaining cooldown as "Xm Ys". */
44
+ function formatCooldownRemaining(remainingMs: number): string {
45
+ const totalSeconds = Math.ceil(remainingMs / 1000);
46
+ const minutes = Math.floor(totalSeconds / 60);
47
+ const seconds = totalSeconds % 60;
48
+ if (minutes > 0 && seconds > 0) {
49
+ return `${minutes}m ${seconds}s`;
50
+ }
51
+ if (minutes > 0) {
52
+ return `${minutes}m`;
53
+ }
54
+ return `${seconds}s`;
55
+ }
56
+
57
+ /** Registers the buddy_feed tool on the MCP server. */
58
+ export function registerFeedTool(server: McpServer): void {
59
+ server.tool(
60
+ "buddy_feed",
61
+ "Feed your active Pokemon to boost happiness. 1-hour cooldown.",
62
+ {},
63
+ async () => {
64
+ const stateManager = StateManager.getInstance();
65
+ const state = await stateManager.load();
66
+
67
+ if (!state || state.party.length === 0) {
68
+ return {
69
+ content: [
70
+ {
71
+ type: "text" as const,
72
+ text: "You don't have a Pokemon yet! Use buddy_starter to pick your first companion.",
73
+ },
74
+ ],
75
+ };
76
+ }
77
+
78
+ const active = stateManager.getActivePokemon();
79
+ if (!active) {
80
+ return {
81
+ content: [
82
+ {
83
+ type: "text" as const,
84
+ text: "No active Pokemon found. Use buddy_party to set one active.",
85
+ },
86
+ ],
87
+ };
88
+ }
89
+
90
+ const species = POKEMON_BY_ID.get(active.pokemonId);
91
+ if (!species) {
92
+ return {
93
+ content: [
94
+ {
95
+ type: "text" as const,
96
+ text: `Could not find species data for Pokemon ID ${active.pokemonId}.`,
97
+ },
98
+ ],
99
+ isError: true,
100
+ };
101
+ }
102
+
103
+ // Check cooldown
104
+ const now = Date.now();
105
+ const elapsed = now - state.lastFedAt;
106
+ if (elapsed < FEED_COOLDOWN_MS) {
107
+ const remaining = FEED_COOLDOWN_MS - elapsed;
108
+ return {
109
+ content: [
110
+ {
111
+ type: "text" as const,
112
+ text: `Already fed! Try again in ${formatCooldownRemaining(remaining)}.`,
113
+ },
114
+ ],
115
+ };
116
+ }
117
+
118
+ const displayName = active.nickname ?? species.name;
119
+
120
+ // Increase happiness (cap at MAX_HAPPINESS)
121
+ const previousHappiness = active.happiness;
122
+ active.happiness = Math.min(MAX_HAPPINESS, active.happiness + FEED_HAPPINESS_BOOST);
123
+
124
+ // Set mood to happy
125
+ state.mood = "happy";
126
+ state.moodSetAt = now;
127
+
128
+ // Record feed timestamp
129
+ state.lastFedAt = now;
130
+
131
+ // Save state and update status line
132
+ await stateManager.save();
133
+ await stateManager.writeStatus();
134
+
135
+ // Build response
136
+ const reaction = getFeedReaction(species, displayName);
137
+ const lines: string[] = [reaction, ""];
138
+ lines.push(`Happiness: ${previousHappiness} \u2192 ${active.happiness}`);
139
+
140
+ return {
141
+ content: [{ type: "text" as const, text: lines.join("\n") }],
142
+ };
143
+ },
144
+ );
145
+ }
@@ -0,0 +1,378 @@
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
+
7
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+ import { z } from "zod";
9
+ import type { PendingQuiz, PokemonType } from "../../engine/types.js";
10
+ import { POKEMON_TYPES } from "../../engine/types.js";
11
+ import { POKEDEX, POKEMON_BY_ID } from "../../engine/pokemon-data.js";
12
+ import { EVOLUTION_CHAINS } from "../../engine/evolution-data.js";
13
+ import { addXp } from "../../engine/xp.js";
14
+ import { StateManager } from "../../state/state-manager.js";
15
+
16
+ /** Cooldown: 15 minutes in milliseconds (applied after completing a quiz). */
17
+ const PLAY_COOLDOWN_MS = 900_000;
18
+
19
+ /** XP awarded for correct answer. */
20
+ const CORRECT_XP = 20;
21
+
22
+ /** XP awarded for wrong answer (participation). */
23
+ const WRONG_XP = 5;
24
+
25
+ // ── Gen 1 Type Effectiveness Chart ────────────────────────
26
+
27
+ /** Maps each type to the types that are super effective against it. */
28
+ const TYPE_WEAKNESSES: Record<PokemonType, readonly PokemonType[]> = {
29
+ Normal: ["Fighting"],
30
+ Fire: ["Water", "Ground", "Rock"],
31
+ Water: ["Electric", "Grass"],
32
+ Electric: ["Ground"],
33
+ Grass: ["Fire", "Ice", "Poison", "Flying", "Bug"],
34
+ Ice: ["Fire", "Fighting", "Rock"],
35
+ Fighting: ["Flying", "Psychic"],
36
+ Poison: ["Ground", "Psychic"],
37
+ Ground: ["Water", "Grass", "Ice"],
38
+ Flying: ["Electric", "Ice", "Rock"],
39
+ Psychic: ["Bug", "Ghost"],
40
+ Bug: ["Fire", "Flying", "Rock"],
41
+ Rock: ["Water", "Grass", "Fighting", "Ground"],
42
+ Ghost: ["Ghost"],
43
+ Dragon: ["Ice", "Dragon"],
44
+ };
45
+
46
+ // ── Utility Functions ─────────────────────────────────────
47
+
48
+ /** Pick a random element from a non-empty array. */
49
+ function randomPick<T>(arr: readonly T[]): T {
50
+ const index = Math.floor(Math.random() * arr.length);
51
+ return arr[index] as T;
52
+ }
53
+
54
+ /** Shuffle an array in place (Fisher-Yates) and return it. */
55
+ function shuffle<T>(arr: T[]): T[] {
56
+ for (let i = arr.length - 1; i > 0; i--) {
57
+ const j = Math.floor(Math.random() * (i + 1));
58
+ const temp = arr[i] as T;
59
+ arr[i] = arr[j] as T;
60
+ arr[j] = temp;
61
+ }
62
+ return arr;
63
+ }
64
+
65
+ /** Format remaining cooldown as "Xm Ys". */
66
+ function formatCooldownRemaining(remainingMs: number): string {
67
+ const totalSeconds = Math.ceil(remainingMs / 1000);
68
+ const minutes = Math.floor(totalSeconds / 60);
69
+ const seconds = totalSeconds % 60;
70
+ if (minutes > 0 && seconds > 0) {
71
+ return `${minutes}m ${seconds}s`;
72
+ }
73
+ if (minutes > 0) {
74
+ return `${minutes}m`;
75
+ }
76
+ return `${seconds}s`;
77
+ }
78
+
79
+ // ── Quiz Generators ───────────────────────────────────────
80
+
81
+ /** Generate a type matchup quiz question. */
82
+ function generateTypeMatchup(): PendingQuiz {
83
+ // Pick a type that has weaknesses
84
+ const typesWithWeaknesses = POKEMON_TYPES.filter((t) => TYPE_WEAKNESSES[t].length > 0);
85
+ const targetType = randomPick(typesWithWeaknesses);
86
+ const weaknesses = TYPE_WEAKNESSES[targetType];
87
+ const correctType = randomPick(weaknesses);
88
+
89
+ // Generate 3 wrong answers (types NOT in the weakness list)
90
+ const wrongTypes = POKEMON_TYPES.filter((t) => !weaknesses.includes(t) && t !== targetType);
91
+ const wrongAnswers = shuffle([...wrongTypes]).slice(0, 3);
92
+
93
+ // Combine and shuffle, tracking correct position
94
+ const options = shuffle([correctType, ...wrongAnswers]);
95
+ const correctAnswer = options.indexOf(correctType) + 1; // 1-indexed
96
+
97
+ return {
98
+ type: "type_matchup",
99
+ question: `What type is super effective against ${targetType}?`,
100
+ options,
101
+ correctAnswer,
102
+ };
103
+ }
104
+
105
+ /** Generate a stat comparison quiz question. */
106
+ function generateStatCompare(): PendingQuiz {
107
+ const statPairs: readonly { name: string; key: keyof (typeof POKEDEX)[number]["baseStats"] }[] = [
108
+ { name: "HP", key: "hp" },
109
+ { name: "Attack", key: "attack" },
110
+ { name: "Defense", key: "defense" },
111
+ { name: "Speed", key: "speed" },
112
+ { name: "Special", key: "special" },
113
+ ];
114
+
115
+ const pair = randomPick(statPairs);
116
+ const statName = pair.name;
117
+ const statKey = pair.key;
118
+
119
+ // Pick 2 different Pokemon
120
+ let pokemon1 = randomPick(POKEDEX);
121
+ let pokemon2 = randomPick(POKEDEX);
122
+ // Ensure they are different and have different stat values for a clear answer
123
+ let attempts = 0;
124
+ while (
125
+ (pokemon2.id === pokemon1.id || pokemon1.baseStats[statKey] === pokemon2.baseStats[statKey]) &&
126
+ attempts < 50
127
+ ) {
128
+ pokemon2 = randomPick(POKEDEX);
129
+ attempts++;
130
+ }
131
+
132
+ const stat1 = pokemon1.baseStats[statKey];
133
+ const stat2 = pokemon2.baseStats[statKey];
134
+
135
+ // If still tied after attempts, just pick a winner
136
+ const correctName = stat1 >= stat2 ? pokemon1.name : pokemon2.name;
137
+
138
+ const options: string[] = [pokemon1.name, pokemon2.name];
139
+ const correctAnswer = options.indexOf(correctName) + 1; // 1 or 2
140
+
141
+ return {
142
+ type: "stat_compare",
143
+ question: `Who has higher ${statName}: ${pokemon1.name} or ${pokemon2.name}?`,
144
+ options,
145
+ correctAnswer,
146
+ };
147
+ }
148
+
149
+ /** Generate an evolution quiz question. */
150
+ function generateEvolutionQuiz(): PendingQuiz {
151
+ // Get chains that have at least one evolution link
152
+ const chainsWithEvolutions = EVOLUTION_CHAINS.filter((c) => c.links.length > 0);
153
+ const chain = randomPick(chainsWithEvolutions);
154
+ const link = randomPick(chain.links);
155
+
156
+ const fromPokemon = POKEMON_BY_ID.get(link.from);
157
+ const toPokemon = POKEMON_BY_ID.get(link.to);
158
+
159
+ if (!fromPokemon || !toPokemon) {
160
+ // Fallback to a known evolution if data lookup fails
161
+ return generateTypeMatchup();
162
+ }
163
+
164
+ // Generate 3 wrong answers (random Pokemon that are NOT the correct evolution)
165
+ const wrongPokemon = shuffle(
166
+ POKEDEX.filter((p) => p.id !== toPokemon.id && p.id !== fromPokemon.id),
167
+ ).slice(0, 3);
168
+
169
+ const options = shuffle([toPokemon.name, ...wrongPokemon.map((p) => p.name)]);
170
+ const correctAnswer = options.indexOf(toPokemon.name) + 1;
171
+
172
+ return {
173
+ type: "evolution",
174
+ question: `What does ${fromPokemon.name} evolve into?`,
175
+ options,
176
+ correctAnswer,
177
+ };
178
+ }
179
+
180
+ /** Generate a Pokedex type trivia question. */
181
+ function generatePokedexTrivia(): PendingQuiz {
182
+ const pokemon = randomPick(POKEDEX);
183
+ const correctType = pokemon.types[0];
184
+
185
+ // Generate 3 wrong type answers
186
+ const wrongTypes = shuffle(
187
+ [...POKEMON_TYPES].filter((t) => t !== correctType && !pokemon.types.includes(t)),
188
+ ).slice(0, 3);
189
+
190
+ const options = shuffle([correctType, ...wrongTypes]);
191
+ const correctAnswer = options.indexOf(correctType) + 1;
192
+
193
+ return {
194
+ type: "pokedex_trivia",
195
+ question: `What is ${pokemon.name}'s primary type?`,
196
+ options,
197
+ correctAnswer,
198
+ };
199
+ }
200
+
201
+ /** Generate a random quiz question. */
202
+ function generateQuiz(): PendingQuiz {
203
+ const generators = [
204
+ generateTypeMatchup,
205
+ generateStatCompare,
206
+ generateEvolutionQuiz,
207
+ generatePokedexTrivia,
208
+ ];
209
+ return randomPick(generators)();
210
+ }
211
+
212
+ /** Format a quiz for display. */
213
+ function formatQuiz(quiz: PendingQuiz): string {
214
+ const lines: string[] = [];
215
+ lines.push(`**${quiz.question}**`);
216
+ lines.push("");
217
+ for (let i = 0; i < quiz.options.length; i++) {
218
+ lines.push(` ${i + 1}. ${quiz.options[i]}`);
219
+ }
220
+ lines.push("");
221
+ lines.push("Use `/buddy play answer 1`, `2`, `3`, or `4` to answer.");
222
+ return lines.join("\n");
223
+ }
224
+
225
+ /** Registers the buddy_play tool on the MCP server. */
226
+ export function registerPlayTool(server: McpServer): void {
227
+ server.tool(
228
+ "buddy_play",
229
+ "Play a Pokemon trivia quiz with your active Pokemon. Use without arguments to get a question, or with answer=N (1-4) to answer.",
230
+ {
231
+ answer: z.number().int().min(1).max(4).optional().describe("Your answer: 1, 2, 3, or 4."),
232
+ },
233
+ async ({ answer }) => {
234
+ const stateManager = StateManager.getInstance();
235
+ const state = await stateManager.load();
236
+
237
+ if (!state || state.party.length === 0) {
238
+ return {
239
+ content: [
240
+ {
241
+ type: "text" as const,
242
+ text: "You don't have a Pokemon yet! Use buddy_starter to pick your first companion.",
243
+ },
244
+ ],
245
+ };
246
+ }
247
+
248
+ const active = stateManager.getActivePokemon();
249
+ if (!active) {
250
+ return {
251
+ content: [
252
+ {
253
+ type: "text" as const,
254
+ text: "No active Pokemon found. Use buddy_party to set one active.",
255
+ },
256
+ ],
257
+ };
258
+ }
259
+
260
+ const species = POKEMON_BY_ID.get(active.pokemonId);
261
+ if (!species) {
262
+ return {
263
+ content: [
264
+ {
265
+ type: "text" as const,
266
+ text: `Could not find species data for Pokemon ID ${active.pokemonId}.`,
267
+ },
268
+ ],
269
+ isError: true,
270
+ };
271
+ }
272
+
273
+ const displayName = active.nickname ?? species.name;
274
+
275
+ // ── Answer an existing quiz ────────────────────────────
276
+ if (answer !== undefined) {
277
+ if (!state.pendingQuiz) {
278
+ return {
279
+ content: [
280
+ {
281
+ type: "text" as const,
282
+ text: "No quiz in progress! Use `/buddy play` to start a new one.",
283
+ },
284
+ ],
285
+ };
286
+ }
287
+
288
+ const quiz = state.pendingQuiz;
289
+ const isCorrect = answer === quiz.correctAnswer;
290
+ const xpAmount = isCorrect ? CORRECT_XP : WRONG_XP;
291
+
292
+ // Award XP
293
+ const levelUp = addXp(active, xpAmount, species);
294
+
295
+ // Set mood based on result
296
+ if (isCorrect) {
297
+ state.mood = "happy";
298
+ }
299
+ state.moodSetAt = Date.now();
300
+
301
+ // Clear quiz and set cooldown
302
+ state.pendingQuiz = null;
303
+ state.lastPlayedAt = Date.now();
304
+
305
+ // Save state
306
+ await stateManager.save();
307
+ await stateManager.writeStatus();
308
+
309
+ // Build response
310
+ const lines: string[] = [];
311
+ if (isCorrect) {
312
+ lines.push(`*${displayName} cheers! Correct!* \u{1F389}`);
313
+ } else {
314
+ const correctOption = quiz.options[quiz.correctAnswer - 1];
315
+ lines.push(`*${displayName} shrugs. Better luck next time!*`);
316
+ lines.push(`The correct answer was: **${quiz.correctAnswer}. ${correctOption}**`);
317
+ }
318
+ lines.push("");
319
+ lines.push(`+${xpAmount} XP`);
320
+
321
+ if (levelUp) {
322
+ lines.push("");
323
+ lines.push(`*** ${displayName} grew to Lv.${levelUp.newLevel}! ***`);
324
+ }
325
+
326
+ return {
327
+ content: [{ type: "text" as const, text: lines.join("\n") }],
328
+ };
329
+ }
330
+
331
+ // ── Start a new quiz ───────────────────────────────────
332
+
333
+ // If there's already a pending quiz, show it again
334
+ if (state.pendingQuiz) {
335
+ const lines: string[] = [];
336
+ lines.push(`*${displayName} is still waiting for your answer!*`);
337
+ lines.push("");
338
+ lines.push(formatQuiz(state.pendingQuiz));
339
+
340
+ return {
341
+ content: [{ type: "text" as const, text: lines.join("\n") }],
342
+ };
343
+ }
344
+
345
+ // Check cooldown (only applies when starting a new quiz after completing one)
346
+ const now = Date.now();
347
+ const elapsed = now - state.lastPlayedAt;
348
+ if (state.lastPlayedAt > 0 && elapsed < PLAY_COOLDOWN_MS) {
349
+ const remaining = PLAY_COOLDOWN_MS - elapsed;
350
+ return {
351
+ content: [
352
+ {
353
+ type: "text" as const,
354
+ text: `${displayName} needs a break from quizzing! Try again in ${formatCooldownRemaining(remaining)}.`,
355
+ },
356
+ ],
357
+ };
358
+ }
359
+
360
+ // Generate and store a new quiz
361
+ const quiz = generateQuiz();
362
+ state.pendingQuiz = quiz;
363
+
364
+ // Save state (stores the pending quiz)
365
+ await stateManager.save();
366
+
367
+ // Build response
368
+ const lines: string[] = [];
369
+ lines.push(`*${displayName} perks up for quiz time!* \u{1F4DA}`);
370
+ lines.push("");
371
+ lines.push(formatQuiz(quiz));
372
+
373
+ return {
374
+ content: [{ type: "text" as const, text: lines.join("\n") }],
375
+ };
376
+ },
377
+ );
378
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * buddy_settings tool — Configure Claudemon settings.
3
+ * Supports: encounter-speed, xp-share.
4
+ */
5
+
6
+ import { z } from "zod";
7
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+ import { StateManager } from "../../state/state-manager.js";
9
+ import type { EncounterSpeed } from "../../engine/constants.js";
10
+ import { ENCOUNTER_THRESHOLDS } from "../../engine/constants.js";
11
+
12
+ const VALID_ENCOUNTER_SPEEDS: readonly EncounterSpeed[] = ["fast", "normal", "slow"];
13
+
14
+ const SPEED_DESCRIPTIONS: Readonly<Record<EncounterSpeed, string>> = {
15
+ fast: "Fastest encounters — wild Pokemon appear every ~100 XP",
16
+ normal: "Default pace — wild Pokemon appear every ~250 XP",
17
+ slow: "Less interruptions — wild Pokemon appear every ~500 XP",
18
+ };
19
+
20
+ /** Registers the buddy_settings tool on the MCP server. */
21
+ export function registerSettingsTool(server: McpServer): void {
22
+ server.tool(
23
+ "buddy_settings",
24
+ "Configure Claudemon settings (encounter-speed, xp-share)",
25
+ {
26
+ setting: z.enum(["encounter-speed", "xp-share"]).describe("The setting to configure"),
27
+ value: z.string().describe("The value to set"),
28
+ },
29
+ async (params: { setting: "encounter-speed" | "xp-share"; value: string }) => {
30
+ const stateManager = StateManager.getInstance();
31
+ const state = await stateManager.load();
32
+
33
+ if (!state) {
34
+ return {
35
+ content: [
36
+ {
37
+ type: "text" as const,
38
+ text: "No save data found. Use buddy_starter to pick your first Pokemon before changing settings.",
39
+ },
40
+ ],
41
+ };
42
+ }
43
+
44
+ // ── Encounter Speed ──────────────────────────────────
45
+ if (params.setting === "encounter-speed") {
46
+ const speed = params.value.toLowerCase();
47
+
48
+ if (!VALID_ENCOUNTER_SPEEDS.includes(speed as EncounterSpeed)) {
49
+ return {
50
+ content: [
51
+ {
52
+ type: "text" as const,
53
+ text: [
54
+ `Invalid encounter speed: "${params.value}"`,
55
+ "",
56
+ "Valid options:",
57
+ ...VALID_ENCOUNTER_SPEEDS.map(
58
+ (s) =>
59
+ ` ${s} — ${SPEED_DESCRIPTIONS[s]} (${ENCOUNTER_THRESHOLDS[s]} XP threshold)`,
60
+ ),
61
+ ].join("\n"),
62
+ },
63
+ ],
64
+ isError: true,
65
+ };
66
+ }
67
+
68
+ const validSpeed = speed as EncounterSpeed;
69
+ const previousSpeed = state.config.encounterSpeed ?? "normal";
70
+ state.config.encounterSpeed = validSpeed;
71
+ await stateManager.save();
72
+
73
+ return {
74
+ content: [
75
+ {
76
+ type: "text" as const,
77
+ text: [
78
+ `Encounter speed: ${previousSpeed} → ${validSpeed}`,
79
+ "",
80
+ SPEED_DESCRIPTIONS[validSpeed],
81
+ `XP threshold: ${ENCOUNTER_THRESHOLDS[validSpeed]}`,
82
+ ].join("\n"),
83
+ },
84
+ ],
85
+ };
86
+ }
87
+
88
+ // ── XP Share ─────────────────────────────────────────
89
+ if (params.setting === "xp-share") {
90
+ const percent = parseInt(params.value, 10);
91
+
92
+ if (isNaN(percent) || percent < 0 || percent > 100) {
93
+ return {
94
+ content: [
95
+ {
96
+ type: "text" as const,
97
+ text: [
98
+ `Invalid XP share value: "${params.value}"`,
99
+ "",
100
+ "Enter a number 0-100 (percentage of XP shared to inactive party):",
101
+ " 0 — No XP sharing (only active Pokemon earns)",
102
+ " 25 — Default (inactive get 25% of earned XP)",
103
+ " 50 — Half XP shared to inactive party",
104
+ " 100 — Full XP to everyone",
105
+ ].join("\n"),
106
+ },
107
+ ],
108
+ isError: true,
109
+ };
110
+ }
111
+
112
+ const previous = state.config.xpSharePercent ?? 25;
113
+ state.config.xpSharePercent = percent;
114
+ await stateManager.save();
115
+
116
+ const desc =
117
+ percent === 0
118
+ ? "Disabled — only active Pokemon earns XP"
119
+ : `Inactive party members receive ${percent}% of earned XP`;
120
+
121
+ return {
122
+ content: [
123
+ {
124
+ type: "text" as const,
125
+ text: [`XP share: ${previous}% → ${percent}%`, "", desc].join("\n"),
126
+ },
127
+ ],
128
+ };
129
+ }
130
+
131
+ return {
132
+ content: [
133
+ {
134
+ type: "text" as const,
135
+ text: `Unknown setting: "${params.setting}". Available: encounter-speed, xp-share`,
136
+ },
137
+ ],
138
+ isError: true,
139
+ };
140
+ },
141
+ );
142
+ }
@@ -13,6 +13,7 @@ import { STAT_DISPLAY_NAMES } from "../../engine/constants.js";
13
13
  import { renderStatBar, getTrainerTitle } from "../../engine/stats.js";
14
14
  import { xpProgressPercent, xpToNextLevel } from "../../engine/xp.js";
15
15
  import { StateManager } from "../../state/state-manager.js";
16
+ import { getMoodEmoji, getMoodDescription } from "../../engine/mood.js";
16
17
  import { formatTypes, renderXpBar, pad } from "./display-helpers.js";
17
18
 
18
19
  /** Registers the buddy_show tool on the MCP server. */
@@ -122,6 +123,12 @@ export function registerShowTool(server: McpServer): void {
122
123
  ? `Streak: ${state.streak.currentStreak} days`
123
124
  : "Streak: 0 days";
124
125
  lines.push(`\u2502 ${pad(streakDisplay, W - 2)}\u2502`);
126
+
127
+ const currentMood = state.mood ?? "neutral";
128
+ const moodEmoji = getMoodEmoji(currentMood);
129
+ const moodDesc = getMoodDescription(currentMood);
130
+ lines.push(`\u2502 ${pad(`Mood: ${moodEmoji} ${moodDesc}`, W - 2)}\u2502`);
131
+
125
132
  lines.push(`\u2514${border}\u2518`);
126
133
 
127
134
  // Prepend colorscript sprite if available