@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,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* buddy_train tool — Train your active Pokemon's coding stats.
|
|
3
|
+
* Has a 30-minute cooldown. Awards +3 to a stat and +5 XP.
|
|
4
|
+
*/
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { CODING_STATS } from "../../engine/types.js";
|
|
7
|
+
import { POKEMON_BY_ID } from "../../engine/pokemon-data.js";
|
|
8
|
+
import { STAT_DISPLAY_NAMES } from "../../engine/constants.js";
|
|
9
|
+
import { addXp } from "../../engine/xp.js";
|
|
10
|
+
import { applyStatBoost, renderStatBar } from "../../engine/stats.js";
|
|
11
|
+
import { StateManager } from "../../state/state-manager.js";
|
|
12
|
+
/** Cooldown: 30 minutes in milliseconds. */
|
|
13
|
+
const TRAIN_COOLDOWN_MS = 1_800_000;
|
|
14
|
+
/** Stat boost per training session. */
|
|
15
|
+
const TRAIN_STAT_BOOST = 3;
|
|
16
|
+
/** XP awarded per training session. */
|
|
17
|
+
const TRAIN_XP_AWARD = 5;
|
|
18
|
+
/** Pick a random element from a non-empty array. */
|
|
19
|
+
function randomPick(arr) {
|
|
20
|
+
const index = Math.floor(Math.random() * arr.length);
|
|
21
|
+
return arr[index];
|
|
22
|
+
}
|
|
23
|
+
/** Format remaining cooldown as "Xm Ys". */
|
|
24
|
+
function formatCooldownRemaining(remainingMs) {
|
|
25
|
+
const totalSeconds = Math.ceil(remainingMs / 1000);
|
|
26
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
27
|
+
const seconds = totalSeconds % 60;
|
|
28
|
+
if (minutes > 0 && seconds > 0) {
|
|
29
|
+
return `${minutes}m ${seconds}s`;
|
|
30
|
+
}
|
|
31
|
+
if (minutes > 0) {
|
|
32
|
+
return `${minutes}m`;
|
|
33
|
+
}
|
|
34
|
+
return `${seconds}s`;
|
|
35
|
+
}
|
|
36
|
+
/** Registers the buddy_train tool on the MCP server. */
|
|
37
|
+
export function registerTrainTool(server) {
|
|
38
|
+
server.tool("buddy_train", "Train your active Pokemon's coding stats. Optionally specify a stat (debugging, stability, velocity, wisdom, stamina). 30-minute cooldown.", {
|
|
39
|
+
stat: z
|
|
40
|
+
.string()
|
|
41
|
+
.optional()
|
|
42
|
+
.describe("Stat to train: debugging, stability, velocity, wisdom, or stamina. Random if omitted."),
|
|
43
|
+
}, async ({ stat: statInput }) => {
|
|
44
|
+
const stateManager = StateManager.getInstance();
|
|
45
|
+
const state = await stateManager.load();
|
|
46
|
+
if (!state || state.party.length === 0) {
|
|
47
|
+
return {
|
|
48
|
+
content: [
|
|
49
|
+
{
|
|
50
|
+
type: "text",
|
|
51
|
+
text: "You don't have a Pokemon yet! Use buddy_starter to pick your first companion.",
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const active = stateManager.getActivePokemon();
|
|
57
|
+
if (!active) {
|
|
58
|
+
return {
|
|
59
|
+
content: [
|
|
60
|
+
{
|
|
61
|
+
type: "text",
|
|
62
|
+
text: "No active Pokemon found. Use buddy_party to set one active.",
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const species = POKEMON_BY_ID.get(active.pokemonId);
|
|
68
|
+
if (!species) {
|
|
69
|
+
return {
|
|
70
|
+
content: [
|
|
71
|
+
{
|
|
72
|
+
type: "text",
|
|
73
|
+
text: `Could not find species data for Pokemon ID ${active.pokemonId}.`,
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
isError: true,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
// Check cooldown
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
const elapsed = now - state.lastTrainedAt;
|
|
82
|
+
if (elapsed < TRAIN_COOLDOWN_MS) {
|
|
83
|
+
const remaining = TRAIN_COOLDOWN_MS - elapsed;
|
|
84
|
+
return {
|
|
85
|
+
content: [
|
|
86
|
+
{
|
|
87
|
+
type: "text",
|
|
88
|
+
text: `Still resting from training! Try again in ${formatCooldownRemaining(remaining)}.`,
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
// Determine which stat to train
|
|
94
|
+
let targetStat;
|
|
95
|
+
if (statInput !== undefined && statInput !== "") {
|
|
96
|
+
const normalized = statInput.toLowerCase().trim();
|
|
97
|
+
if (!CODING_STATS.includes(normalized)) {
|
|
98
|
+
const validStats = CODING_STATS.map((s) => STAT_DISPLAY_NAMES[s].toLowerCase()).join(", ");
|
|
99
|
+
return {
|
|
100
|
+
content: [
|
|
101
|
+
{
|
|
102
|
+
type: "text",
|
|
103
|
+
text: `Invalid stat "${statInput}". Valid stats: ${validStats}`,
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
targetStat = normalized;
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
targetStat = randomPick(CODING_STATS);
|
|
112
|
+
}
|
|
113
|
+
const displayName = active.nickname ?? species.name;
|
|
114
|
+
// Record stat before boost for display
|
|
115
|
+
const statBefore = active.codingStats[targetStat];
|
|
116
|
+
// Apply stat boost
|
|
117
|
+
applyStatBoost(active, targetStat, TRAIN_STAT_BOOST);
|
|
118
|
+
const statAfter = active.codingStats[targetStat];
|
|
119
|
+
// Award XP
|
|
120
|
+
const levelUp = addXp(active, TRAIN_XP_AWARD, species);
|
|
121
|
+
// Set mood to energetic
|
|
122
|
+
state.mood = "energetic";
|
|
123
|
+
state.moodSetAt = now;
|
|
124
|
+
// Record train timestamp
|
|
125
|
+
state.lastTrainedAt = now;
|
|
126
|
+
// Save state and update status line
|
|
127
|
+
await stateManager.save();
|
|
128
|
+
await stateManager.writeStatus();
|
|
129
|
+
// Build response
|
|
130
|
+
const statDisplayName = STAT_DISPLAY_NAMES[targetStat];
|
|
131
|
+
const lines = [];
|
|
132
|
+
lines.push(`*${displayName} trains its ${statDisplayName}! +${TRAIN_STAT_BOOST}*`);
|
|
133
|
+
lines.push("");
|
|
134
|
+
lines.push(`${statDisplayName}: ${statBefore} \u2192 ${statAfter} ${renderStatBar(statAfter)}`);
|
|
135
|
+
lines.push(`+${TRAIN_XP_AWARD} XP`);
|
|
136
|
+
if (levelUp) {
|
|
137
|
+
lines.push("");
|
|
138
|
+
lines.push(`*** ${displayName} grew to Lv.${levelUp.newLevel}! ***`);
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Catches disk corruption early at load boundaries.
|
|
4
4
|
*/
|
|
5
5
|
import { z } from "zod";
|
|
6
|
-
import { BADGE_TYPES, CODING_STATS, EVENT_COUNTER_KEYS, } from "../engine/types.js";
|
|
6
|
+
import { BADGE_TYPES, CODING_STATS, EVENT_COUNTER_KEYS, MOOD_TYPES, } from "../engine/types.js";
|
|
7
7
|
// ---- Shared Primitives ----
|
|
8
8
|
const BadgeTypeSchema = z.enum(BADGE_TYPES);
|
|
9
9
|
// ---- Coding Stats ----
|
|
@@ -47,6 +47,7 @@ export const BuddyConfigSchema = z.object({
|
|
|
47
47
|
reactionCooldownMs: z.number().int().min(0).default(30_000),
|
|
48
48
|
statusLineEnabled: z.boolean().default(true),
|
|
49
49
|
bellEnabled: z.boolean().default(true),
|
|
50
|
+
encounterSpeed: z.enum(["fast", "normal", "slow"]).default("normal"),
|
|
50
51
|
});
|
|
51
52
|
// ---- Pokedex ----
|
|
52
53
|
export const PokedexEntrySchema = z.object({
|
|
@@ -65,6 +66,13 @@ export const UnlockedAchievementSchema = z.object({
|
|
|
65
66
|
achievementId: z.string(),
|
|
66
67
|
unlockedAt: z.string(),
|
|
67
68
|
});
|
|
69
|
+
// ---- Pending Quiz ----
|
|
70
|
+
export const PendingQuizSchema = z.object({
|
|
71
|
+
type: z.enum(["type_matchup", "stat_compare", "evolution", "pokedex_trivia"]),
|
|
72
|
+
question: z.string(),
|
|
73
|
+
options: z.array(z.string()),
|
|
74
|
+
correctAnswer: z.number().int().min(1).max(4),
|
|
75
|
+
});
|
|
68
76
|
// ---- Catch Condition ----
|
|
69
77
|
export const CatchConditionSchema = z.object({
|
|
70
78
|
requiredStat: z.enum(CODING_STATS).nullable(),
|
|
@@ -94,4 +102,12 @@ export const PlayerStateSchema = z.object({
|
|
|
94
102
|
totalSessions: z.number().int().min(0).default(0),
|
|
95
103
|
pendingEncounter: WildEncounterSchema.nullable().default(null),
|
|
96
104
|
xpSinceLastEncounter: z.number().int().min(0).default(0),
|
|
105
|
+
recentToolTypes: z.array(z.string()).default([]),
|
|
106
|
+
lastEncounterTime: z.number().int().min(0).default(0),
|
|
107
|
+
mood: z.enum(MOOD_TYPES).default("neutral"),
|
|
108
|
+
moodSetAt: z.number().default(0),
|
|
109
|
+
lastFedAt: z.number().default(0),
|
|
110
|
+
lastTrainedAt: z.number().default(0),
|
|
111
|
+
lastPlayedAt: z.number().default(0),
|
|
112
|
+
pendingQuiz: PendingQuizSchema.nullable().default(null),
|
|
97
113
|
});
|
|
@@ -30,6 +30,7 @@ function defaultConfig() {
|
|
|
30
30
|
reactionCooldownMs: 30_000,
|
|
31
31
|
statusLineEnabled: true,
|
|
32
32
|
bellEnabled: true,
|
|
33
|
+
encounterSpeed: "normal",
|
|
33
34
|
};
|
|
34
35
|
}
|
|
35
36
|
/** Build empty pokedex */
|
|
@@ -150,6 +151,14 @@ export class StateManager {
|
|
|
150
151
|
totalSessions: 0,
|
|
151
152
|
pendingEncounter: null,
|
|
152
153
|
xpSinceLastEncounter: 0,
|
|
154
|
+
recentToolTypes: [],
|
|
155
|
+
lastEncounterTime: 0,
|
|
156
|
+
mood: "neutral",
|
|
157
|
+
moodSetAt: 0,
|
|
158
|
+
lastFedAt: 0,
|
|
159
|
+
lastTrainedAt: 0,
|
|
160
|
+
lastPlayedAt: 0,
|
|
161
|
+
pendingQuiz: null,
|
|
153
162
|
};
|
|
154
163
|
this.state = state;
|
|
155
164
|
await this.save();
|
|
@@ -168,17 +177,19 @@ export class StateManager {
|
|
|
168
177
|
state.counters[key] += amount;
|
|
169
178
|
await this.save();
|
|
170
179
|
}
|
|
171
|
-
/**
|
|
180
|
+
/**
|
|
181
|
+
* Update daily streak with weekend grace period.
|
|
182
|
+
* Allows up to 2 days off without breaking the streak (covers weekends).
|
|
183
|
+
* Streak counts "coding days" not "consecutive calendar days".
|
|
184
|
+
*/
|
|
172
185
|
async updateStreak() {
|
|
173
186
|
const state = this.getState();
|
|
174
187
|
const today = todayDateString();
|
|
175
188
|
const { streak } = state;
|
|
176
189
|
if (streak.lastActiveDate === today) {
|
|
177
|
-
// Already recorded today, nothing to do
|
|
178
190
|
return;
|
|
179
191
|
}
|
|
180
192
|
if (streak.lastActiveDate === null) {
|
|
181
|
-
// First ever activity
|
|
182
193
|
streak.currentStreak = 1;
|
|
183
194
|
streak.totalDaysActive = 1;
|
|
184
195
|
}
|
|
@@ -187,12 +198,15 @@ export class StateManager {
|
|
|
187
198
|
const now = new Date(today);
|
|
188
199
|
const diffMs = now.getTime() - last.getTime();
|
|
189
200
|
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
|
|
190
|
-
|
|
191
|
-
|
|
201
|
+
// Grace period: up to 2 days off (covers weekends)
|
|
202
|
+
// 1 day gap = next day (consecutive) ✓
|
|
203
|
+
// 2 day gap = skipped 1 day (e.g., Friday → Sunday) ✓
|
|
204
|
+
// 3 day gap = skipped 2 days (e.g., Friday → Monday) ✓
|
|
205
|
+
// 4+ day gap = streak broken
|
|
206
|
+
if (diffDays <= 3) {
|
|
192
207
|
streak.currentStreak += 1;
|
|
193
208
|
}
|
|
194
209
|
else {
|
|
195
|
-
// Streak broken
|
|
196
210
|
streak.currentStreak = 1;
|
|
197
211
|
}
|
|
198
212
|
streak.totalDaysActive += 1;
|
|
@@ -209,6 +223,7 @@ export class StateManager {
|
|
|
209
223
|
if (!active) {
|
|
210
224
|
return;
|
|
211
225
|
}
|
|
226
|
+
const state = this.getState();
|
|
212
227
|
// XP percent is currentXp as a rough percentage toward next level
|
|
213
228
|
// Exact formula depends on exp group; use a simple ratio for the status line
|
|
214
229
|
const xpPercent = active.level >= 100 ? 100 : Math.min(100, Math.floor((active.currentXp / Math.max(1, active.currentXp + 50)) * 100));
|
|
@@ -225,6 +240,7 @@ export class StateManager {
|
|
|
225
240
|
xpPercent,
|
|
226
241
|
speciesId: active.pokemonId,
|
|
227
242
|
evolutionReady,
|
|
243
|
+
mood: state.mood ?? "neutral",
|
|
228
244
|
};
|
|
229
245
|
const stateDir = getStateDir();
|
|
230
246
|
await ensureDir(stateDir);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umang-boss/claudemon",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Pokemon coding companion for Claude Code — Gotta code 'em all!",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/src/server/index.js",
|
|
@@ -33,8 +33,7 @@
|
|
|
33
33
|
"test": "bun test",
|
|
34
34
|
"typecheck": "tsc --noEmit",
|
|
35
35
|
"format": "prettier --write \"src/**/*.ts\" \"cli/**/*.ts\" \"scripts/**/*.ts\" \"tests/**/*.ts\" --ignore-unknown",
|
|
36
|
-
"format:check": "prettier --check \"src/**/*.ts\" \"cli/**/*.ts\" \"scripts/**/*.ts\" \"tests/**/*.ts\" --ignore-unknown"
|
|
37
|
-
"prepare": "husky"
|
|
36
|
+
"format:check": "prettier --check \"src/**/*.ts\" \"cli/**/*.ts\" \"scripts/**/*.ts\" \"tests/**/*.ts\" --ignore-unknown"
|
|
38
37
|
},
|
|
39
38
|
"dependencies": {
|
|
40
39
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
package/skills/buddy/SKILL.md
CHANGED
|
@@ -41,6 +41,21 @@ This ensures first-time users get the starter selection flow automatically.
|
|
|
41
41
|
| `rename` (empty) | `buddy_rename` with name="" — reset to species name |
|
|
42
42
|
| `hide` | `buddy_hide` — hide sprite from status line |
|
|
43
43
|
| `unhide` | `buddy_unhide` — show sprite in status line |
|
|
44
|
+
| `feed` | `buddy_feed` — feed your Pokemon (+10 happiness, 1h cooldown) |
|
|
45
|
+
| `train` | `buddy_train` — train a random stat (+3 stat, +5 XP, 30m cooldown) |
|
|
46
|
+
| `train debugging` | `buddy_train` with stat="debugging" |
|
|
47
|
+
| `train stability` | `buddy_train` with stat="stability" |
|
|
48
|
+
| `train velocity` | `buddy_train` with stat="velocity" |
|
|
49
|
+
| `train wisdom` | `buddy_train` with stat="wisdom" |
|
|
50
|
+
| `train stamina` | `buddy_train` with stat="stamina" |
|
|
51
|
+
| `play` | `buddy_play` — start a Pokemon trivia quiz (15m cooldown after completion) |
|
|
52
|
+
| `play answer 1` | `buddy_play` with answer=1 |
|
|
53
|
+
| `play answer 2` | `buddy_play` with answer=2 |
|
|
54
|
+
| `play answer 3` | `buddy_play` with answer=3 |
|
|
55
|
+
| `play answer 4` | `buddy_play` with answer=4 |
|
|
56
|
+
| `settings encounter-speed fast` | `buddy_settings` with setting="encounter-speed", value="fast" — fastest encounters (100 XP) |
|
|
57
|
+
| `settings encounter-speed normal` | `buddy_settings` with setting="encounter-speed", value="normal" — default (250 XP) |
|
|
58
|
+
| `settings encounter-speed slow` | `buddy_settings` with setting="encounter-speed", value="slow" — less interruptions (500 XP) |
|
|
44
59
|
| `help` | List all available /buddy commands |
|
|
45
60
|
|
|
46
61
|
Pass $ARGUMENTS to determine which subcommand to route to.
|
package/src/engine/constants.ts
CHANGED
|
@@ -46,8 +46,17 @@ export const STAT_DISPLAY_NAMES: Record<CodingStat, string> = {
|
|
|
46
46
|
|
|
47
47
|
// ── Encounter Rate ─────────────────────────────────────────
|
|
48
48
|
|
|
49
|
-
/** XP
|
|
50
|
-
export const
|
|
49
|
+
/** XP thresholds for encounter triggers by speed setting */
|
|
50
|
+
export const ENCOUNTER_THRESHOLDS = {
|
|
51
|
+
fast: 100,
|
|
52
|
+
normal: 250,
|
|
53
|
+
slow: 500,
|
|
54
|
+
} as const;
|
|
55
|
+
|
|
56
|
+
export type EncounterSpeed = keyof typeof ENCOUNTER_THRESHOLDS;
|
|
57
|
+
|
|
58
|
+
/** Default encounter speed */
|
|
59
|
+
export const DEFAULT_ENCOUNTER_SPEED: EncounterSpeed = "normal";
|
|
51
60
|
|
|
52
61
|
// ── Reaction Cooldown ──────────────────────────────────────
|
|
53
62
|
|
|
@@ -92,7 +101,7 @@ export const BADGES: readonly Badge[] = [
|
|
|
92
101
|
{
|
|
93
102
|
type: "lunar",
|
|
94
103
|
name: "Lunar Badge",
|
|
95
|
-
description: "
|
|
104
|
+
description: "Code for 30 days (weekends off OK) — unlocks Moon Stone evolutions",
|
|
96
105
|
condition: { type: "streak", minDays: 30 },
|
|
97
106
|
},
|
|
98
107
|
{
|
package/src/engine/encounters.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Wild encounter system.
|
|
3
|
-
* Pokemon appear based on coding activity type
|
|
4
|
-
*
|
|
3
|
+
* Pokemon appear based on coding activity type, streak bonuses,
|
|
4
|
+
* tool diversity, and time-of-day biases. Catch eligibility is
|
|
5
|
+
* determined by the active Pokemon's stats and level.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import type {
|
|
@@ -14,7 +15,8 @@ import type {
|
|
|
14
15
|
CodingStat,
|
|
15
16
|
RarityTier,
|
|
16
17
|
} from "./types.js";
|
|
17
|
-
import {
|
|
18
|
+
import { ENCOUNTER_THRESHOLDS } from "./constants.js";
|
|
19
|
+
import type { EncounterSpeed } from "./constants.js";
|
|
18
20
|
import { POKEMON_BY_ID } from "./pokemon-data.js";
|
|
19
21
|
import { TYPE_POOLS } from "./encounter-pool.js";
|
|
20
22
|
|
|
@@ -41,14 +43,55 @@ export function getEncounterTypes(eventType: XpEventType): readonly PokemonType[
|
|
|
41
43
|
return ENCOUNTER_TYPE_MAP[eventType];
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
// ── Encounter Context ─────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
export interface EncounterContext {
|
|
49
|
+
xpSinceLastEncounter: number;
|
|
50
|
+
encounterSpeed: EncounterSpeed;
|
|
51
|
+
currentStreak: number;
|
|
52
|
+
recentToolTypes: string[]; // tool types used recently
|
|
53
|
+
currentHour: number; // 0-23
|
|
54
|
+
}
|
|
55
|
+
|
|
44
56
|
// ── Encounter Trigger ─────────────────────────────────────────
|
|
45
57
|
|
|
46
58
|
/**
|
|
47
|
-
* Check if a wild encounter should trigger based on XP earned
|
|
48
|
-
*
|
|
59
|
+
* Check if a wild encounter should trigger based on XP earned,
|
|
60
|
+
* encounter speed setting, and streak bonus.
|
|
61
|
+
* Streak bonus: 7+ day streak halves the threshold.
|
|
49
62
|
*/
|
|
50
|
-
export function shouldTriggerEncounter(
|
|
51
|
-
|
|
63
|
+
export function shouldTriggerEncounter(ctx: EncounterContext): boolean {
|
|
64
|
+
const threshold = ENCOUNTER_THRESHOLDS[ctx.encounterSpeed];
|
|
65
|
+
|
|
66
|
+
// Streak bonus: 7+ day streak = halve the threshold
|
|
67
|
+
const streakMultiplier = ctx.currentStreak >= 7 ? 0.5 : 1;
|
|
68
|
+
const effectiveThreshold = Math.floor(threshold * streakMultiplier);
|
|
69
|
+
|
|
70
|
+
if (ctx.xpSinceLastEncounter < effectiveThreshold) return false;
|
|
71
|
+
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Check for bonus encounter (10% chance after a regular encounter). */
|
|
76
|
+
export function shouldBonusEncounter(): boolean {
|
|
77
|
+
return Math.random() < 0.1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Check for activity diversity bonus (3+ unique tool types in recent history). */
|
|
81
|
+
export function shouldDiversityBonus(recentToolTypes: string[]): boolean {
|
|
82
|
+
const uniqueTypes = new Set(recentToolTypes);
|
|
83
|
+
return uniqueTypes.size >= 3;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Time-of-Day Bias ──────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/** Get time-of-day type biases for encounter generation. */
|
|
89
|
+
export function getTimeOfDayBias(hour: number): PokemonType[] {
|
|
90
|
+
if (hour >= 22 || hour < 5) return ["Ghost", "Poison"]; // Night: Ghost types
|
|
91
|
+
if (hour >= 5 && hour < 9) return ["Grass", "Bug"]; // Morning: Grass types
|
|
92
|
+
if (hour >= 12 && hour < 14) return ["Fire", "Rock"]; // Midday: Fire types
|
|
93
|
+
if (hour >= 17 && hour < 20) return ["Water", "Flying"]; // Evening: Water types
|
|
94
|
+
return []; // No bias
|
|
52
95
|
}
|
|
53
96
|
|
|
54
97
|
// ── Rarity Weights ────────────────────────────────────────────
|
|
@@ -235,14 +278,27 @@ function determineEncounterLevel(state: PlayerState, seed: number): number {
|
|
|
235
278
|
/**
|
|
236
279
|
* Generate a wild encounter based on the activity type.
|
|
237
280
|
* Picks a Pokemon from the matching type pool, weighted by rarity.
|
|
238
|
-
*
|
|
281
|
+
* If time-of-day bias types are provided, there is a 40% chance to
|
|
282
|
+
* use those types instead of the activity-based types.
|
|
283
|
+
* Excludes Pokemon already in the player's party/box (unless common tier).
|
|
239
284
|
* Returns null if no eligible Pokemon found.
|
|
240
285
|
*/
|
|
241
286
|
export function generateEncounter(
|
|
242
287
|
eventType: XpEventType,
|
|
243
288
|
state: PlayerState,
|
|
289
|
+
timeOfDayTypes?: readonly PokemonType[],
|
|
244
290
|
): WildEncounter | null {
|
|
245
|
-
|
|
291
|
+
let types = getEncounterTypes(eventType);
|
|
292
|
+
|
|
293
|
+
// 40% chance to use time-of-day biased types if available
|
|
294
|
+
if (timeOfDayTypes && timeOfDayTypes.length > 0) {
|
|
295
|
+
const seed = Math.floor(Date.now() / 1000);
|
|
296
|
+
const biasRoll = seededRandom(seed + 42);
|
|
297
|
+
if (biasRoll < 0.4) {
|
|
298
|
+
types = timeOfDayTypes;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
246
302
|
const candidates = buildCandidatePool(types, state);
|
|
247
303
|
|
|
248
304
|
if (candidates.length === 0) return null;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mood engine for Claudemon.
|
|
3
|
+
* Pure functions that calculate mood based on recent events, time of day,
|
|
4
|
+
* and special triggers (evolution, achievements, catches).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { MoodType, EventCounters } from "./types.js";
|
|
8
|
+
|
|
9
|
+
// ── Mood Decay Durations (milliseconds) ───────────────────
|
|
10
|
+
|
|
11
|
+
/** How long each mood lasts before decaying back to neutral */
|
|
12
|
+
const MOOD_DECAY_MS: Record<MoodType, number> = {
|
|
13
|
+
happy: 600_000, // 10 minutes
|
|
14
|
+
worried: 300_000, // 5 minutes
|
|
15
|
+
sleepy: Infinity, // Resets based on time-of-day, not duration
|
|
16
|
+
energetic: 900_000, // 15 minutes
|
|
17
|
+
proud: 600_000, // 10 minutes
|
|
18
|
+
neutral: Infinity, // Never decays (it IS the default)
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// ── XP Event Types That Trigger Moods ─────────────────────
|
|
22
|
+
|
|
23
|
+
const POSITIVE_EVENTS = new Set(["test_pass", "build_success", "commit"]);
|
|
24
|
+
|
|
25
|
+
const NEGATIVE_EVENTS = new Set(["test_fail", "build_fail", "error"]);
|
|
26
|
+
|
|
27
|
+
// ── Mood Calculation ──────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Calculate the current mood based on recent events, time, and special triggers.
|
|
31
|
+
*
|
|
32
|
+
* Priority order:
|
|
33
|
+
* 1. Sleepy (midnight to 5 AM)
|
|
34
|
+
* 2. Proud (just evolved/achieved/caught)
|
|
35
|
+
* 3. Worried (recent negative event)
|
|
36
|
+
* 4. Happy (recent positive event)
|
|
37
|
+
* 5. Energetic (morning + active streak)
|
|
38
|
+
* 6. Keep current mood if it hasn't decayed
|
|
39
|
+
* 7. Neutral (default fallback)
|
|
40
|
+
*
|
|
41
|
+
* @param recentEvent - The last XP event type, or null
|
|
42
|
+
* @param counters - Current event counters (for context)
|
|
43
|
+
* @param currentHour - Hour of day (0-23)
|
|
44
|
+
* @param lastMood - The previous mood
|
|
45
|
+
* @param moodSetAt - Timestamp when last mood was set
|
|
46
|
+
* @param hadEvolution - Whether the Pokemon just evolved
|
|
47
|
+
* @param hadAchievement - Whether the player just unlocked an achievement
|
|
48
|
+
* @param hadCatch - Whether the player just caught a Pokemon
|
|
49
|
+
* @returns The calculated mood
|
|
50
|
+
*/
|
|
51
|
+
export function calculateMood(
|
|
52
|
+
recentEvent: string | null,
|
|
53
|
+
counters: EventCounters,
|
|
54
|
+
currentHour: number,
|
|
55
|
+
lastMood: MoodType,
|
|
56
|
+
moodSetAt: number,
|
|
57
|
+
hadEvolution: boolean,
|
|
58
|
+
hadAchievement: boolean,
|
|
59
|
+
hadCatch: boolean,
|
|
60
|
+
): MoodType {
|
|
61
|
+
// 1. Sleepy: midnight to 5 AM (hours 0-4)
|
|
62
|
+
if (currentHour >= 0 && currentHour < 5) {
|
|
63
|
+
return "sleepy";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 2. Proud: just evolved, achieved, or caught a Pokemon
|
|
67
|
+
if (hadEvolution || hadAchievement || hadCatch) {
|
|
68
|
+
return "proud";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 3. Worried: recent negative event
|
|
72
|
+
if (recentEvent !== null && NEGATIVE_EVENTS.has(recentEvent)) {
|
|
73
|
+
return "worried";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 4. Happy: recent positive event
|
|
77
|
+
if (recentEvent !== null && POSITIVE_EVENTS.has(recentEvent)) {
|
|
78
|
+
return "happy";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 5. Energetic: morning coding (5 AM - 10 AM) with an active streak
|
|
82
|
+
if (currentHour >= 5 && currentHour < 10) {
|
|
83
|
+
// Use a simple heuristic: if there have been any sessions, consider it an active streak
|
|
84
|
+
const hasStreak = counters.sessions > 0;
|
|
85
|
+
if (hasStreak) {
|
|
86
|
+
return "energetic";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 6. Keep current mood if it hasn't decayed
|
|
91
|
+
if (!hasMoodDecayed(lastMood, moodSetAt, Date.now())) {
|
|
92
|
+
return lastMood;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 7. Default fallback
|
|
96
|
+
return "neutral";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Check whether a mood has expired based on its decay duration.
|
|
101
|
+
*
|
|
102
|
+
* @param mood - The mood to check
|
|
103
|
+
* @param setAt - Timestamp when the mood was set
|
|
104
|
+
* @param now - Current timestamp
|
|
105
|
+
* @returns true if the mood has decayed (expired)
|
|
106
|
+
*/
|
|
107
|
+
export function hasMoodDecayed(mood: MoodType, setAt: number, now: number): boolean {
|
|
108
|
+
const duration = MOOD_DECAY_MS[mood];
|
|
109
|
+
if (duration === Infinity) {
|
|
110
|
+
// Sleepy decays when it's no longer midnight-5 AM (handled in calculateMood)
|
|
111
|
+
// Neutral never decays
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
return now - setAt >= duration;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Mood Speeches ─────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/** Mood-specific speech lines for the status line. Name placeholder {name} is replaced at call time. */
|
|
120
|
+
const MOOD_SPEECHES: Record<MoodType, readonly string[]> = {
|
|
121
|
+
happy: [
|
|
122
|
+
"*{name} is beaming with pride!*",
|
|
123
|
+
"*{name} does a little victory dance*",
|
|
124
|
+
"*{name} radiates positive energy*",
|
|
125
|
+
"*{name} bounces happily*",
|
|
126
|
+
"*{name} gives you a thumbs up*",
|
|
127
|
+
],
|
|
128
|
+
worried: [
|
|
129
|
+
"*{name} looks concerned...*",
|
|
130
|
+
"*{name} nervously watches the errors*",
|
|
131
|
+
"*{name} hides behind the terminal*",
|
|
132
|
+
"*{name} paces back and forth*",
|
|
133
|
+
"*{name} offers you a virtual hug*",
|
|
134
|
+
],
|
|
135
|
+
sleepy: [
|
|
136
|
+
"*{name} yawns widely*",
|
|
137
|
+
"*{name} dozes off... zzz*",
|
|
138
|
+
"*{name} rubs its eyes*",
|
|
139
|
+
"*{name} curls up near the keyboard*",
|
|
140
|
+
"*{name} mumbles in its sleep*",
|
|
141
|
+
],
|
|
142
|
+
energetic: [
|
|
143
|
+
"*{name} is fired up! Let's go!*",
|
|
144
|
+
"*{name} bounces off the walls*",
|
|
145
|
+
"*{name} can't sit still!*",
|
|
146
|
+
"*{name} is ready to code all day!*",
|
|
147
|
+
"*{name} stretches and flexes*",
|
|
148
|
+
],
|
|
149
|
+
proud: [
|
|
150
|
+
"*{name} puffs up with pride*",
|
|
151
|
+
"*{name} strikes a victory pose*",
|
|
152
|
+
"*{name} shows off to everyone*",
|
|
153
|
+
"*{name} earned bragging rights!*",
|
|
154
|
+
"*{name} stands tall and proud*",
|
|
155
|
+
],
|
|
156
|
+
neutral: [
|
|
157
|
+
"*{name} looks at your code curiously*",
|
|
158
|
+
"*{name} nods along as you type*",
|
|
159
|
+
"*{name} is watching closely*",
|
|
160
|
+
"*{name} hums softly*",
|
|
161
|
+
"*{name} waits patiently*",
|
|
162
|
+
"*{name} tilts head at the screen*",
|
|
163
|
+
"*{name} chirps encouragingly*",
|
|
164
|
+
"*{name} peers at a variable name*",
|
|
165
|
+
],
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Get mood-specific speech messages with the Pokemon's name filled in.
|
|
170
|
+
*
|
|
171
|
+
* @param name - The Pokemon's display name
|
|
172
|
+
* @param mood - The current mood
|
|
173
|
+
* @returns Array of speech strings with the name interpolated
|
|
174
|
+
*/
|
|
175
|
+
export function getMoodSpeeches(name: string, mood: MoodType): string[] {
|
|
176
|
+
const templates = MOOD_SPEECHES[mood];
|
|
177
|
+
return templates.map((t) => t.replace("{name}", name));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Mood Display Helpers ──────────────────────────────────
|
|
181
|
+
|
|
182
|
+
/** Emoji representation for each mood */
|
|
183
|
+
const MOOD_EMOJIS: Record<MoodType, string> = {
|
|
184
|
+
happy: "\u{1F60A}", // 😊
|
|
185
|
+
worried: "\u{1F61F}", // 😟
|
|
186
|
+
sleepy: "\u{1F634}", // 😴
|
|
187
|
+
energetic: "\u{26A1}", // ⚡
|
|
188
|
+
proud: "\u{1F451}", // 👑
|
|
189
|
+
neutral: "\u{1F610}", // 😐
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/** Human-readable mood descriptions */
|
|
193
|
+
const MOOD_DESCRIPTIONS: Record<MoodType, string> = {
|
|
194
|
+
happy: "Happy",
|
|
195
|
+
worried: "Worried",
|
|
196
|
+
sleepy: "Sleepy",
|
|
197
|
+
energetic: "Energetic",
|
|
198
|
+
proud: "Proud",
|
|
199
|
+
neutral: "Neutral",
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Get the emoji for a mood.
|
|
204
|
+
*
|
|
205
|
+
* @param mood - The mood type
|
|
206
|
+
* @returns The emoji string
|
|
207
|
+
*/
|
|
208
|
+
export function getMoodEmoji(mood: MoodType): string {
|
|
209
|
+
return MOOD_EMOJIS[mood];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Get a human-readable description for a mood.
|
|
214
|
+
*
|
|
215
|
+
* @param mood - The mood type
|
|
216
|
+
* @returns The description string (e.g. "Happy")
|
|
217
|
+
*/
|
|
218
|
+
export function getMoodDescription(mood: MoodType): string {
|
|
219
|
+
return MOOD_DESCRIPTIONS[mood];
|
|
220
|
+
}
|