@umang-boss/claudemon 1.2.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -4
- package/dist/src/engine/constants.js +9 -3
- package/dist/src/engine/encounters.js +50 -10
- package/dist/src/engine/mood.js +187 -0
- package/dist/src/engine/types.js +2 -0
- package/dist/src/gamification/achievements.js +3 -3
- package/dist/src/gamification/legendary-quests.js +4 -4
- package/dist/src/hooks/award-xp.js +60 -5
- package/dist/src/server/index.js +8 -0
- package/dist/src/server/instructions.js +23 -0
- package/dist/src/server/tools/catch.js +3 -0
- package/dist/src/server/tools/evolve.js +3 -0
- package/dist/src/server/tools/feed.js +120 -0
- package/dist/src/server/tools/play.js +310 -0
- package/dist/src/server/tools/settings.js +80 -0
- package/dist/src/server/tools/show.js +5 -0
- package/dist/src/server/tools/starter.js +24 -2
- package/dist/src/server/tools/train.js +144 -0
- package/dist/src/state/schemas.js +17 -1
- package/dist/src/state/state-manager.js +22 -6
- package/package.json +2 -3
- package/skills/buddy/SKILL.md +15 -0
- package/src/engine/constants.ts +12 -3
- package/src/engine/encounters.ts +65 -9
- package/src/engine/mood.ts +220 -0
- package/src/engine/types.ts +23 -0
- package/src/gamification/achievements.ts +3 -3
- package/src/gamification/legendary-quests.ts +4 -4
- package/src/hooks/award-xp.ts +82 -5
- package/src/server/index.ts +8 -0
- package/src/server/instructions.ts +25 -0
- package/src/server/tools/catch.ts +4 -0
- package/src/server/tools/evolve.ts +4 -0
- package/src/server/tools/feed.ts +145 -0
- package/src/server/tools/play.ts +378 -0
- package/src/server/tools/settings.ts +101 -0
- package/src/server/tools/show.ts +7 -0
- package/src/server/tools/starter.ts +21 -2
- package/src/server/tools/train.ts +180 -0
- package/src/state/schemas.ts +19 -0
- package/src/state/state-manager.ts +25 -6
- package/statusline/buddy-status.sh +77 -62
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* buddy_settings tool — Configure Claudemon settings.
|
|
3
|
+
* Currently supports: encounter-speed (fast | normal | slow).
|
|
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
|
+
/** Speed descriptions for user-facing messages. */
|
|
10
|
+
const SPEED_DESCRIPTIONS = {
|
|
11
|
+
fast: "Fastest encounters — wild Pokemon appear every ~100 XP",
|
|
12
|
+
normal: "Default pace — wild Pokemon appear every ~250 XP",
|
|
13
|
+
slow: "Less interruptions — wild Pokemon appear every ~500 XP",
|
|
14
|
+
};
|
|
15
|
+
/** Registers the buddy_settings tool on the MCP server. */
|
|
16
|
+
export function registerSettingsTool(server) {
|
|
17
|
+
server.tool("buddy_settings", "Configure Claudemon settings (encounter speed, etc.)", {
|
|
18
|
+
setting: z.enum(["encounter-speed"]).describe("The setting to configure"),
|
|
19
|
+
value: z.string().describe("The value to set (for encounter-speed: fast, normal, or slow)"),
|
|
20
|
+
}, async (params) => {
|
|
21
|
+
const stateManager = StateManager.getInstance();
|
|
22
|
+
const state = await stateManager.load();
|
|
23
|
+
if (!state) {
|
|
24
|
+
return {
|
|
25
|
+
content: [
|
|
26
|
+
{
|
|
27
|
+
type: "text",
|
|
28
|
+
text: "No save data found. Use buddy_starter to pick your first Pokemon before changing settings.",
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
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 updated: ${previousSpeed} -> ${validSpeed}`,
|
|
61
|
+
"",
|
|
62
|
+
SPEED_DESCRIPTIONS[validSpeed],
|
|
63
|
+
`XP threshold: ${ENCOUNTER_THRESHOLDS[validSpeed]}`,
|
|
64
|
+
].join("\n"),
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
// Unreachable with the current enum, but guards against future additions
|
|
70
|
+
return {
|
|
71
|
+
content: [
|
|
72
|
+
{
|
|
73
|
+
type: "text",
|
|
74
|
+
text: `Unknown setting: "${params.setting}". Available settings: encounter-speed`,
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
isError: true,
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -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
|
|
@@ -56,10 +56,32 @@ async function getStarters() {
|
|
|
56
56
|
catch {
|
|
57
57
|
// No saved options or expired — generate new ones
|
|
58
58
|
}
|
|
59
|
-
// Generate fresh random 3
|
|
59
|
+
// Generate fresh random 3 with different primary types
|
|
60
60
|
const seed = hashString(`claudemon-${Date.now()}-${Math.random()}`);
|
|
61
61
|
const shuffled = seededShuffle(STARTER_POOL, seed);
|
|
62
|
-
const starters = [
|
|
62
|
+
const starters = [];
|
|
63
|
+
const usedTypes = new Set();
|
|
64
|
+
for (const id of shuffled) {
|
|
65
|
+
if (starters.length >= 3)
|
|
66
|
+
break;
|
|
67
|
+
const pokemon = POKEMON_BY_ID.get(id);
|
|
68
|
+
if (!pokemon)
|
|
69
|
+
continue;
|
|
70
|
+
const primaryType = pokemon.types[0];
|
|
71
|
+
if (usedTypes.has(primaryType))
|
|
72
|
+
continue;
|
|
73
|
+
usedTypes.add(primaryType);
|
|
74
|
+
starters.push(id);
|
|
75
|
+
}
|
|
76
|
+
// Fallback if not enough unique types (shouldn't happen with 39 starters)
|
|
77
|
+
if (starters.length < 3) {
|
|
78
|
+
for (const id of shuffled) {
|
|
79
|
+
if (starters.length >= 3)
|
|
80
|
+
break;
|
|
81
|
+
if (!starters.includes(id))
|
|
82
|
+
starters.push(id);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
63
85
|
// Save for consistency until they pick
|
|
64
86
|
try {
|
|
65
87
|
const { writeFile, mkdir } = await import("node:fs/promises");
|