@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.
Files changed (42) hide show
  1. package/README.md +13 -4
  2. package/dist/src/engine/constants.js +9 -3
  3. package/dist/src/engine/encounters.js +50 -10
  4. package/dist/src/engine/mood.js +187 -0
  5. package/dist/src/engine/types.js +2 -0
  6. package/dist/src/gamification/achievements.js +3 -3
  7. package/dist/src/gamification/legendary-quests.js +4 -4
  8. package/dist/src/hooks/award-xp.js +60 -5
  9. package/dist/src/server/index.js +8 -0
  10. package/dist/src/server/instructions.js +23 -0
  11. package/dist/src/server/tools/catch.js +3 -0
  12. package/dist/src/server/tools/evolve.js +3 -0
  13. package/dist/src/server/tools/feed.js +120 -0
  14. package/dist/src/server/tools/play.js +310 -0
  15. package/dist/src/server/tools/settings.js +80 -0
  16. package/dist/src/server/tools/show.js +5 -0
  17. package/dist/src/server/tools/starter.js +24 -2
  18. package/dist/src/server/tools/train.js +144 -0
  19. package/dist/src/state/schemas.js +17 -1
  20. package/dist/src/state/state-manager.js +22 -6
  21. package/package.json +2 -3
  22. package/skills/buddy/SKILL.md +15 -0
  23. package/src/engine/constants.ts +12 -3
  24. package/src/engine/encounters.ts +65 -9
  25. package/src/engine/mood.ts +220 -0
  26. package/src/engine/types.ts +23 -0
  27. package/src/gamification/achievements.ts +3 -3
  28. package/src/gamification/legendary-quests.ts +4 -4
  29. package/src/hooks/award-xp.ts +82 -5
  30. package/src/server/index.ts +8 -0
  31. package/src/server/instructions.ts +25 -0
  32. package/src/server/tools/catch.ts +4 -0
  33. package/src/server/tools/evolve.ts +4 -0
  34. package/src/server/tools/feed.ts +145 -0
  35. package/src/server/tools/play.ts +378 -0
  36. package/src/server/tools/settings.ts +101 -0
  37. package/src/server/tools/show.ts +7 -0
  38. package/src/server/tools/starter.ts +21 -2
  39. package/src/server/tools/train.ts +180 -0
  40. package/src/state/schemas.ts +19 -0
  41. package/src/state/state-manager.ts +25 -6
  42. package/statusline/buddy-status.sh +77 -62
@@ -3,6 +3,11 @@
3
3
  * Single source of truth for all shared interfaces and types.
4
4
  */
5
5
 
6
+ // ── Mood Types ────────────────────────────────────────────
7
+
8
+ export const MOOD_TYPES = ["happy", "worried", "sleepy", "energetic", "proud", "neutral"] as const;
9
+ export type MoodType = (typeof MOOD_TYPES)[number];
10
+
6
11
  // ── Pokemon Types ──────────────────────────────────────────
7
12
 
8
13
  export const POKEMON_TYPES = [
@@ -159,6 +164,14 @@ export interface PlayerState {
159
164
  totalSessions: number;
160
165
  pendingEncounter: WildEncounter | null;
161
166
  xpSinceLastEncounter: number;
167
+ recentToolTypes: string[]; // Track tool diversity for bonus encounters
168
+ lastEncounterTime: number; // Timestamp of last encounter (for cooldown)
169
+ mood: MoodType; // Current mood of the active Pokemon
170
+ moodSetAt: number; // Timestamp when mood was last set
171
+ lastFedAt: number; // Timestamp of last feed action
172
+ lastTrainedAt: number; // Timestamp of last train action
173
+ lastPlayedAt: number; // Timestamp of last play (quiz completed) action
174
+ pendingQuiz: PendingQuiz | null; // Active quiz awaiting answer
162
175
  }
163
176
 
164
177
  // ── Pokedex ────────────────────────────────────────────────
@@ -240,6 +253,7 @@ export interface BuddyConfig {
240
253
  reactionCooldownMs: number; // Default 30000
241
254
  statusLineEnabled: boolean;
242
255
  bellEnabled: boolean; // Terminal bell on level-up/encounters
256
+ encounterSpeed: "fast" | "normal" | "slow"; // Configurable encounter frequency
243
257
  }
244
258
 
245
259
  // ── XP Events (what triggers XP awards) ────────────────────
@@ -269,6 +283,15 @@ export interface XpEvent {
269
283
  readonly boostAmount: number;
270
284
  }
271
285
 
286
+ // ── Pending Quiz (for /buddy play) ────────────────────────
287
+
288
+ export interface PendingQuiz {
289
+ readonly type: "type_matchup" | "stat_compare" | "evolution" | "pokedex_trivia";
290
+ readonly question: string;
291
+ readonly options: readonly string[];
292
+ readonly correctAnswer: number; // 1-4
293
+ }
294
+
272
295
  // ── Encounters ─────────────────────────────────────────────
273
296
 
274
297
  export interface WildEncounter {
@@ -103,21 +103,21 @@ export const ACHIEVEMENTS: readonly Achievement[] = [
103
103
  {
104
104
  id: "iron_coder",
105
105
  name: "Iron Coder",
106
- description: "Maintain a 7-day coding streak",
106
+ description: "Code for 7 days (weekends off OK)",
107
107
  category: "coding",
108
108
  condition: { type: "streak", minDays: 7 },
109
109
  },
110
110
  {
111
111
  id: "marathon",
112
112
  name: "Marathon",
113
- description: "Maintain a 30-day coding streak",
113
+ description: "Code for 30 days (weekends off OK)",
114
114
  category: "coding",
115
115
  condition: { type: "streak", minDays: 30 },
116
116
  },
117
117
  {
118
118
  id: "centurion",
119
119
  name: "Centurion",
120
- description: "Maintain a 100-day coding streak",
120
+ description: "Code for 100 days (weekends off OK)",
121
121
  category: "coding",
122
122
  condition: { type: "streak", minDays: 100 },
123
123
  },
@@ -16,7 +16,7 @@ export const LEGENDARY_QUESTS: readonly LegendaryQuest[] = [
16
16
  name: "The Ice Bird of Endurance",
17
17
  steps: [
18
18
  {
19
- description: "Maintain a 30-day coding streak",
19
+ description: "Code for 30 days (weekends off OK)",
20
20
  condition: { type: "streak", minDays: 30 },
21
21
  },
22
22
  {
@@ -25,7 +25,7 @@ export const LEGENDARY_QUESTS: readonly LegendaryQuest[] = [
25
25
  condition: { type: "pokedex", minCaught: 3 },
26
26
  },
27
27
  {
28
- description: "Maintain a 100-day coding streak",
28
+ description: "Code for 100 days (weekends off OK)",
29
29
  condition: { type: "streak", minDays: 100 },
30
30
  },
31
31
  {
@@ -113,7 +113,7 @@ export const LEGENDARY_QUESTS: readonly LegendaryQuest[] = [
113
113
  name: "The Myth",
114
114
  steps: [
115
115
  {
116
- description: "Maintain a 100-day coding streak",
116
+ description: "Code for 100 days (weekends off OK)",
117
117
  condition: { type: "streak", minDays: 100 },
118
118
  },
119
119
  {
@@ -129,7 +129,7 @@ export const LEGENDARY_QUESTS: readonly LegendaryQuest[] = [
129
129
  condition: { type: "pokedex", minCaught: 149 },
130
130
  },
131
131
  {
132
- description: "Maintain a 365-day coding streak",
132
+ description: "Code for 365 days (weekends off OK)",
133
133
  condition: { type: "streak", minDays: 365 },
134
134
  },
135
135
  ],
@@ -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
 
@@ -14,7 +16,17 @@ import { addXp, createXpEvent } from "../engine/xp.js";
14
16
  import { applyStatBoost } from "../engine/stats.js";
15
17
  import { POKEMON_BY_ID } from "../engine/pokemon-data.js";
16
18
  import { checkEvolution, getNewlyEarnedBadges } from "../engine/evolution.js";
17
- import { shouldTriggerEncounter, generateEncounter } from "../engine/encounters.js";
19
+ import {
20
+ shouldTriggerEncounter,
21
+ generateEncounter,
22
+ shouldBonusEncounter,
23
+ shouldDiversityBonus,
24
+ getTimeOfDayBias,
25
+ } from "../engine/encounters.js";
26
+ import type { EncounterContext } from "../engine/encounters.js";
27
+ import { calculateMood } from "../engine/mood.js";
28
+
29
+ const MAX_RECENT_TOOL_TYPES = 20;
18
30
 
19
31
  const eventType = process.argv[2] as XpEventType;
20
32
  const counterKey = process.argv[3] as EventCounterKey | undefined;
@@ -60,6 +72,14 @@ if (counterKey) {
60
72
  state.counters[counterKey] += 1;
61
73
  }
62
74
 
75
+ // Track tool type for diversity bonus (keep last MAX_RECENT_TOOL_TYPES entries)
76
+ const recentToolTypes = state.recentToolTypes ?? [];
77
+ recentToolTypes.push(eventType);
78
+ if (recentToolTypes.length > MAX_RECENT_TOOL_TYPES) {
79
+ recentToolTypes.splice(0, recentToolTypes.length - MAX_RECENT_TOOL_TYPES);
80
+ }
81
+ state.recentToolTypes = recentToolTypes;
82
+
63
83
  // Update the daily coding streak
64
84
  // Inline the streak logic to avoid the extra save from updateStreak()
65
85
  const today = new Date();
@@ -100,19 +120,76 @@ for (const badge of newBadges) {
100
120
  // Check if evolution is available (sets flag in status for status line indicator)
101
121
  const evolutionReady = checkEvolution(pokemon, state) !== null;
102
122
 
103
- // Track XP toward next encounter and trigger if threshold met
104
- state.xpSinceLastEncounter = (state.xpSinceLastEncounter ?? 0) + xpEvent.xp;
123
+ // Build encounter context for the enhanced trigger system
124
+ const encounterSpeed = state.config.encounterSpeed ?? "normal";
125
+ const currentHour = new Date().getHours();
126
+ const timeOfDayTypes = getTimeOfDayBias(currentHour);
127
+
128
+ const encounterCtx: EncounterContext = {
129
+ xpSinceLastEncounter: (state.xpSinceLastEncounter ?? 0) + xpEvent.xp,
130
+ encounterSpeed,
131
+ currentStreak: streak.currentStreak,
132
+ recentToolTypes: state.recentToolTypes,
133
+ currentHour,
134
+ };
135
+
136
+ // Track XP toward next encounter
137
+ state.xpSinceLastEncounter = encounterCtx.xpSinceLastEncounter;
105
138
  let encounterTriggered = false;
106
139
 
107
- if (shouldTriggerEncounter(state.xpSinceLastEncounter) && !state.pendingEncounter) {
108
- const encounter = generateEncounter(eventType, state);
140
+ if (shouldTriggerEncounter(encounterCtx) && !state.pendingEncounter) {
141
+ const encounter = generateEncounter(eventType, state, timeOfDayTypes);
109
142
  if (encounter) {
110
143
  state.pendingEncounter = encounter;
111
144
  state.xpSinceLastEncounter = 0;
145
+ state.lastEncounterTime = Date.now();
112
146
  encounterTriggered = true;
147
+
148
+ // 10% chance for a bonus encounter after a regular one
149
+ // (bonus replaces the pending encounter with a second roll)
150
+ if (shouldBonusEncounter()) {
151
+ const bonusEncounter = generateEncounter(eventType, state, timeOfDayTypes);
152
+ if (bonusEncounter) {
153
+ // The bonus encounter replaces the first; first is already set as pending
154
+ // In practice the player still sees one encounter per trigger,
155
+ // but the bonus gives them a fresh roll (potentially rarer Pokemon)
156
+ state.pendingEncounter = bonusEncounter;
157
+ }
158
+ }
113
159
  }
114
160
  }
115
161
 
162
+ // Tool diversity bonus: if 3+ unique tool types used recently and no pending encounter,
163
+ // grant an extra encounter opportunity
164
+ if (!encounterTriggered && !state.pendingEncounter && shouldDiversityBonus(state.recentToolTypes)) {
165
+ const diversityEncounter = generateEncounter(eventType, state, timeOfDayTypes);
166
+ if (diversityEncounter) {
167
+ state.pendingEncounter = diversityEncounter;
168
+ state.xpSinceLastEncounter = 0;
169
+ state.lastEncounterTime = Date.now();
170
+ // Clear recent tool types after diversity bonus triggers
171
+ state.recentToolTypes = [];
172
+ encounterTriggered = true;
173
+ }
174
+ }
175
+
176
+ // Calculate mood based on the event that just happened
177
+ const newMood = calculateMood(
178
+ eventType,
179
+ state.counters,
180
+ currentHour,
181
+ state.mood ?? "neutral",
182
+ state.moodSetAt ?? 0,
183
+ evolutionReady, // evolution ready counts as a proud trigger
184
+ false, // achievements are checked in catch/evolve tools
185
+ false, // catches are handled in the catch tool
186
+ );
187
+
188
+ if (newMood !== (state.mood ?? "neutral")) {
189
+ state.mood = newMood;
190
+ state.moodSetAt = Date.now();
191
+ }
192
+
116
193
  // Single atomic save for all mutations
117
194
  await stateManager.save();
118
195
 
@@ -19,6 +19,10 @@ import { registerAchievementsTool } from "./tools/achievements.js";
19
19
  import { registerLegendaryTool } from "./tools/legendary.js";
20
20
  import { registerHideTool, registerUnhideTool } from "./tools/visibility.js";
21
21
  import { registerRenameTool } from "./tools/rename.js";
22
+ import { registerSettingsTool } from "./tools/settings.js";
23
+ import { registerFeedTool } from "./tools/feed.js";
24
+ import { registerTrainTool } from "./tools/train.js";
25
+ import { registerPlayTool } from "./tools/play.js";
22
26
  import { buildInstructions } from "./instructions.js";
23
27
 
24
28
  /** Safely register a tool, logging to stderr on failure instead of crashing. */
@@ -65,6 +69,10 @@ async function main(): Promise<void> {
65
69
  safeRegister("buddy_hide", registerHideTool, server);
66
70
  safeRegister("buddy_unhide", registerUnhideTool, server);
67
71
  safeRegister("buddy_rename", registerRenameTool, server);
72
+ safeRegister("buddy_settings", registerSettingsTool, server);
73
+ safeRegister("buddy_feed", registerFeedTool, server);
74
+ safeRegister("buddy_train", registerTrainTool, server);
75
+ safeRegister("buddy_play", registerPlayTool, server);
68
76
 
69
77
  // Connect via stdio transport
70
78
  const transport = new StdioServerTransport();
@@ -9,6 +9,7 @@ import { POKEMON_BY_ID } from "../engine/pokemon-data.js";
9
9
  import { cumulativeXpForLevel } from "../engine/xp.js";
10
10
  import { getEvolutionLinks } from "../engine/evolution.js";
11
11
  import { getTypePersonality } from "../engine/reactions.js";
12
+ import { getMoodDescription } from "../engine/mood.js";
12
13
 
13
14
  // ── Public API ──────────────────────────────────────────────
14
15
 
@@ -60,11 +61,17 @@ function buildActiveInstructions(
60
61
  const evolutionNote = buildEvolutionNote(active, species);
61
62
  const encounterNote = buildEncounterNote(state);
62
63
 
64
+ const currentMood = state.mood ?? "neutral";
65
+ const moodDesc = getMoodDescription(currentMood);
66
+ const moodHint = buildMoodHint(currentMood);
67
+
63
68
  const lines: string[] = [
64
69
  "You have a Claudemon Pokemon companion.",
65
70
  "",
66
71
  `Active Pokemon: ${displayName}, Level ${active.level}, ${typeStr} type.`,
67
72
  `Personality: ${personality}`,
73
+ `Current mood: ${moodDesc.toLowerCase()}${moodHint}`,
74
+ "Adjust your buddy references to match the mood.",
68
75
  "",
69
76
  `Occasionally (not every message), naturally reference ${displayName}:`,
70
77
  `- When an error occurs: ${displayName} reacts (use ${primaryType} type personality)`,
@@ -95,6 +102,24 @@ function buildActiveInstructions(
95
102
 
96
103
  // ── Helper Functions ────────────────────────────────────────
97
104
 
105
+ /** Build a short contextual hint for the current mood. */
106
+ function buildMoodHint(mood: string): string {
107
+ switch (mood) {
108
+ case "happy":
109
+ return " (tests passing, feeling good)";
110
+ case "worried":
111
+ return " (errors detected, feeling anxious)";
112
+ case "sleepy":
113
+ return " (late night coding, very drowsy)";
114
+ case "energetic":
115
+ return " (morning energy, fired up!)";
116
+ case "proud":
117
+ return " (just accomplished something big!)";
118
+ default:
119
+ return " (calm, waiting for action)";
120
+ }
121
+ }
122
+
98
123
  /** Find the active Pokemon in the player's party. */
99
124
  function findActivePokemon(state: PlayerState): OwnedPokemon | null {
100
125
  return state.party.find((p) => p.isActive) ?? null;
@@ -248,6 +248,10 @@ export function registerCatchTool(server: McpServer): void {
248
248
  state.achievements.push(unlockAchievement(achievement.id));
249
249
  }
250
250
 
251
+ // Set mood to proud after a successful catch
252
+ state.mood = "proud";
253
+ state.moodSetAt = Date.now();
254
+
251
255
  // Save state
252
256
  await stateManager.save();
253
257
  await stateManager.writeStatus();
@@ -197,6 +197,10 @@ export function registerEvolveTool(server: McpServer): void {
197
197
  // Confirm mode: apply evolution
198
198
  const { newName, newTypes } = applyEvolution(active, eligibleLink.to);
199
199
 
200
+ // Set mood to proud after evolution
201
+ state.mood = "proud";
202
+ state.moodSetAt = Date.now();
203
+
200
204
  // Save state
201
205
  await stateManager.save();
202
206
  await stateManager.writeStatus();
@@ -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
+ }