@ramarivera/coding-buddy 0.4.0-alpha.9 → 0.7.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/.claude-plugin/marketplace.json +45 -0
- package/.claude-plugin/plugin.json +30 -0
- package/LICENSE +1 -1
- package/README.md +96 -45
- package/adapters/omp/commands.ts +201 -0
- package/adapters/omp/context.ts +22 -0
- package/adapters/omp/events.ts +551 -0
- package/adapters/omp/identity.ts +10 -0
- package/adapters/omp/index.ts +39 -0
- package/adapters/omp/logger.ts +52 -0
- package/adapters/omp/prompt.ts +60 -0
- package/adapters/omp/renderers.ts +73 -0
- package/adapters/omp/storage.ts +25 -0
- package/adapters/omp/ui.ts +85 -0
- package/adapters/pi/commands.ts +64 -37
- package/adapters/pi/events.ts +147 -72
- package/adapters/pi/index.ts +9 -4
- package/adapters/pi/storage.ts +14 -285
- package/adapters/pi/ui.ts +56 -5
- package/adapters/shared/file-storage.ts +526 -0
- package/{adapters/claude/install → cli}/backup.ts +130 -33
- package/cli/buddy-shell.ts +1 -1
- package/{adapters/claude/install → cli}/disable.ts +19 -32
- package/cli/doctor.ts +231 -0
- package/{adapters/claude/install → cli}/hunt.ts +7 -9
- package/cli/index.ts +30 -26
- package/{adapters/claude/install → cli}/install.ts +65 -83
- package/{adapters/claude/install → cli}/pick.ts +136 -57
- package/{adapters/claude/install → cli}/settings.ts +2 -2
- package/{adapters/claude/install → cli}/show.ts +4 -4
- package/{adapters/claude/install → cli}/test-statusline.sh +2 -2
- package/{adapters/claude/install → cli}/test-statusline.ts +21 -11
- package/cli/tui.tsx +106 -17
- package/{adapters/claude/install → cli}/uninstall.ts +40 -47
- package/cli/upgrade.ts +46 -13
- package/cli/validate-species.ts +209 -0
- package/cli/verify.ts +3 -3
- package/core/achievements.ts +12 -20
- package/core/art-data.ts +26 -0
- package/core/command-service.ts +99 -97
- package/core/engine.ts +83 -1
- package/core/ports.ts +10 -0
- package/core/reactions.ts +533 -95
- package/core/slot-slug.ts +22 -0
- package/{adapters/claude/hooks → hooks}/buddy-comment.sh +22 -9
- package/hooks/file-type-react.sh +403 -0
- package/hooks/hooks.json +52 -0
- package/hooks/mood-react.sh +210 -0
- package/{adapters/claude/hooks → hooks}/name-react.sh +3 -4
- package/hooks/react.sh +1364 -0
- package/hooks/suggest.sh +55 -0
- package/package.json +70 -31
- package/scripts/gen-emoji-widths.ts +34 -0
- package/scripts/paths.sh +33 -2
- package/scripts/postinstall.cjs +42 -0
- package/scripts/snapshot-statusline.sh +54 -0
- package/server/achievements.ts +1419 -15
- package/server/art.ts +450 -1
- package/server/award-xp.ts +48 -0
- package/server/check-suggestions.ts +102 -0
- package/server/consolidate.ts +33 -0
- package/{adapters/claude/server → server}/index.ts +556 -95
- package/server/mcp-launcher.sh +16 -6
- package/server/memory.ts +404 -0
- package/server/mood.ts +254 -0
- package/server/path.ts +70 -18
- package/server/shift-mood.ts +43 -0
- package/server/state.ts +552 -3
- package/server/suggestions.ts +335 -0
- package/server/theme.ts +53 -0
- package/server/xp.ts +435 -0
- package/{adapters/claude/skills → skills}/buddy/SKILL.md +36 -7
- package/statusline/buddy-status.sh +402 -0
- package/statusline/combined-status.sh +135 -0
- package/statusline/emoji-widths.data +4 -0
- package/adapters/claude/hooks/hooks.json +0 -35
- package/adapters/claude/hooks/react.sh +0 -207
- package/adapters/claude/install/doctor.ts +0 -172
- package/adapters/claude/plugin/marketplace.json +0 -40
- package/adapters/claude/plugin/plugin.json +0 -26
- package/adapters/claude/rendering/art.ts +0 -264
- package/adapters/claude/server/instructions.ts +0 -24
- package/adapters/claude/server/path.ts +0 -12
- package/adapters/claude/server/resources.ts +0 -38
- package/adapters/claude/statusline/buddy-status.sh +0 -408
- package/adapters/claude/storage/achievements.ts +0 -253
- package/adapters/claude/storage/identity.ts +0 -13
- package/adapters/claude/storage/paths.ts +0 -33
- package/adapters/claude/storage/settings.ts +0 -91
- package/adapters/claude/storage/state.ts +0 -365
- package/adapters/pi/README.md +0 -83
- package/adapters/pi/tools.ts +0 -6
- package/server/engine.ts +0 -1
- package/server/reactions.ts +0 -1
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Validate species art format.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* bun run cli/validate-species.ts <path>
|
|
7
|
+
* bun run cli/validate-species.ts --check-species <name>
|
|
8
|
+
*
|
|
9
|
+
* Exits 0 if valid, 1 if invalid (with error messages to stderr).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync } from "fs";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
|
|
15
|
+
interface ValidationResult {
|
|
16
|
+
valid: boolean;
|
|
17
|
+
errors: string[];
|
|
18
|
+
warnings: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const EYE_PLACEHOLDER = "{E}";
|
|
22
|
+
|
|
23
|
+
/** Check a string for ANSI escape sequences */
|
|
24
|
+
function hasAnsiCodes(s: string): boolean {
|
|
25
|
+
return s.includes("\x1b[");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Strip ANSI codes from a string */
|
|
29
|
+
function stripAnsi(s: string): string {
|
|
30
|
+
return s.replace(/\x1b\[[^m]*m/g, "");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Validate a single art frame (must be exactly 5 lines) */
|
|
34
|
+
function validateFrame(
|
|
35
|
+
species: string,
|
|
36
|
+
frameIndex: number,
|
|
37
|
+
lines: string[],
|
|
38
|
+
): ValidationResult {
|
|
39
|
+
const errors: string[] = [];
|
|
40
|
+
const warnings: string[] = [];
|
|
41
|
+
|
|
42
|
+
if (lines.length !== 5) {
|
|
43
|
+
errors.push(
|
|
44
|
+
` Species "${species}" frame ${frameIndex}: expected 5 lines, got ${lines.length}`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < lines.length; i++) {
|
|
49
|
+
const line = lines[i];
|
|
50
|
+
const rawLen = line.length;
|
|
51
|
+
const displayLen = stripAnsi(line).length;
|
|
52
|
+
|
|
53
|
+
if (hasAnsiCodes(line)) {
|
|
54
|
+
errors.push(
|
|
55
|
+
` Species "${species}" frame ${frameIndex} line ${i + 1}: contains ANSI escape sequences (not allowed)`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (displayLen > 14) {
|
|
60
|
+
errors.push(
|
|
61
|
+
` Species "${species}" frame ${frameIndex} line ${i + 1}: display width ${displayLen} exceeds max 14 chars`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Each species art should have the eye placeholder in at least one line
|
|
66
|
+
// (not strictly required but warn if missing)
|
|
67
|
+
if (!line.includes(EYE_PLACEHOLDER) && i === 1) {
|
|
68
|
+
warnings.push(
|
|
69
|
+
` Species "${species}" frame ${frameIndex} line ${i + 1}: missing ${EYE_PLACEHOLDER} eye placeholder (recommended for line 2)`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Validate art structure from parsed JSON */
|
|
78
|
+
function validateSpeciesArt(
|
|
79
|
+
species: string,
|
|
80
|
+
art: string[][],
|
|
81
|
+
): ValidationResult {
|
|
82
|
+
const errors: string[] = [];
|
|
83
|
+
const warnings: string[] = [];
|
|
84
|
+
|
|
85
|
+
if (!Array.isArray(art)) {
|
|
86
|
+
errors.push(`Species "${species}": art must be an array of frames`);
|
|
87
|
+
return { valid: false, errors, warnings };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (art.length !== 3) {
|
|
91
|
+
errors.push(
|
|
92
|
+
`Species "${species}": expected 3 animation frames, got ${art.length}`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (let i = 0; i < art.length; i++) {
|
|
97
|
+
const frame = art[i];
|
|
98
|
+
const result = validateFrame(species, i, frame);
|
|
99
|
+
errors.push(...result.errors);
|
|
100
|
+
warnings.push(...result.warnings);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Validate a face template — must contain at least one eye placeholder */
|
|
107
|
+
function validateFaceTemplate(
|
|
108
|
+
species: string,
|
|
109
|
+
face: string,
|
|
110
|
+
): ValidationResult {
|
|
111
|
+
const errors: string[] = [];
|
|
112
|
+
const warnings: string[] = [];
|
|
113
|
+
|
|
114
|
+
if (face.length > 20) {
|
|
115
|
+
errors.push(
|
|
116
|
+
`Species "${species}" face template: display width ${face.length} exceeds max 20 chars`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!face.includes(EYE_PLACEHOLDER)) {
|
|
121
|
+
errors.push(
|
|
122
|
+
`Species "${species}" face template: missing ${EYE_PLACEHOLDER} eye placeholder`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Main validation function — validates a species JSON file or inline art.
|
|
131
|
+
* Returns exit code (0 = valid, 1 = invalid).
|
|
132
|
+
*/
|
|
133
|
+
export function validateSpeciesFile(filePath: string): ValidationResult {
|
|
134
|
+
const errors: string[] = [];
|
|
135
|
+
const warnings: string[] = [];
|
|
136
|
+
|
|
137
|
+
let data: Record<string, unknown>;
|
|
138
|
+
try {
|
|
139
|
+
const raw = readFileSync(filePath, "utf8");
|
|
140
|
+
data = JSON.parse(raw);
|
|
141
|
+
} catch (e) {
|
|
142
|
+
return {
|
|
143
|
+
valid: false,
|
|
144
|
+
errors: [`Failed to read or parse JSON file: ${filePath}`],
|
|
145
|
+
warnings: [],
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const species = data.name as string | undefined;
|
|
150
|
+
if (!species || typeof species !== "string") {
|
|
151
|
+
errors.push("Species must have a 'name' field (string)");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const art = data.art as string[][] | undefined;
|
|
155
|
+
if (!art) {
|
|
156
|
+
errors.push("Species must have an 'art' field (string[][] — 3 frames × 5 lines)");
|
|
157
|
+
} else {
|
|
158
|
+
const artResult = validateSpeciesArt(species ?? "unknown", art);
|
|
159
|
+
errors.push(...artResult.errors);
|
|
160
|
+
warnings.push(...artResult.warnings);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const face = data.face as string | undefined;
|
|
164
|
+
if (face) {
|
|
165
|
+
const faceResult = validateFaceTemplate(species ?? "unknown", face);
|
|
166
|
+
errors.push(...faceResult.errors);
|
|
167
|
+
warnings.push(...faceResult.warnings);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Print results and exit with appropriate code */
|
|
174
|
+
function main(): never {
|
|
175
|
+
const args = process.argv.slice(2);
|
|
176
|
+
|
|
177
|
+
if (args.length === 0) {
|
|
178
|
+
console.error("Usage: bun run cli/validate-species.ts <path-to-species-json>");
|
|
179
|
+
console.error(" Validates art format, ANSI codes, and eye placeholder.");
|
|
180
|
+
console.error("");
|
|
181
|
+
console.error("Example:");
|
|
182
|
+
console.error(" bun run cli/validate-species.ts ./my-species.json");
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const filePath = args[0].startsWith("/") ? args[0] : join(process.cwd(), args[0]);
|
|
187
|
+
const result = validateSpeciesFile(filePath);
|
|
188
|
+
|
|
189
|
+
if (result.warnings.length > 0) {
|
|
190
|
+
console.warn("Warnings:");
|
|
191
|
+
for (const w of result.warnings) {
|
|
192
|
+
console.warn(w);
|
|
193
|
+
}
|
|
194
|
+
console.warn("");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (result.valid) {
|
|
198
|
+
console.log(`✓ ${args[0]}: valid`);
|
|
199
|
+
process.exit(0);
|
|
200
|
+
} else {
|
|
201
|
+
console.error("Errors:");
|
|
202
|
+
for (const e of result.errors) {
|
|
203
|
+
console.error(e);
|
|
204
|
+
}
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
main();
|
package/cli/verify.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* coding-buddy verify — show what buddy a user ID produces
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { generateBones, renderBuddy, STAT_NAMES } from "../core/engine.ts"
|
|
6
|
-
import { resolveUserId } from "../
|
|
5
|
+
import { generateBones, renderBuddy, STAT_NAMES } from "../core/engine.ts"
|
|
6
|
+
import { resolveUserId } from "../server/state.ts";
|
|
7
7
|
|
|
8
8
|
const userId = process.argv[3] || resolveUserId();
|
|
9
9
|
|
package/core/achievements.ts
CHANGED
|
@@ -1,24 +1,6 @@
|
|
|
1
|
-
import type { UnlockedAchievement } from "./model.ts";
|
|
1
|
+
import type { UnlockedAchievement, GlobalCounters, SlotCounters, EventCounters } from "./model.ts";
|
|
2
|
+
export type { GlobalCounters, SlotCounters, EventCounters };
|
|
2
3
|
|
|
3
|
-
export interface GlobalCounters {
|
|
4
|
-
errors_seen: number;
|
|
5
|
-
tests_failed: number;
|
|
6
|
-
large_diffs: number;
|
|
7
|
-
sessions: number;
|
|
8
|
-
commands_run: number;
|
|
9
|
-
days_active: number;
|
|
10
|
-
turns: number;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface SlotCounters {
|
|
14
|
-
pets: number;
|
|
15
|
-
reactions_given: number;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export interface EventCounters extends GlobalCounters {
|
|
19
|
-
pets: number;
|
|
20
|
-
reactions_given: number;
|
|
21
|
-
}
|
|
22
4
|
|
|
23
5
|
export const GLOBAL_KEYS: (keyof GlobalCounters)[] = [
|
|
24
6
|
"errors_seen", "tests_failed", "large_diffs",
|
|
@@ -189,12 +171,22 @@ export function getUnlockedAchievements(
|
|
|
189
171
|
): Achievement[] {
|
|
190
172
|
const newlyUnlocked: Achievement[] = [];
|
|
191
173
|
for (const ach of ACHIEVEMENTS) {
|
|
174
|
+
if (ach.id === "first_steps") continue;
|
|
192
175
|
if (unlockedIds.has(ach.id)) continue;
|
|
193
176
|
if (ach.check(events)) newlyUnlocked.push(ach);
|
|
194
177
|
}
|
|
195
178
|
return newlyUnlocked;
|
|
196
179
|
}
|
|
197
180
|
|
|
181
|
+
export function getCreationAchievements(
|
|
182
|
+
_events: EventCounters,
|
|
183
|
+
unlockedIds: Set<string>,
|
|
184
|
+
): Achievement[] {
|
|
185
|
+
if (unlockedIds.has("first_steps")) return [];
|
|
186
|
+
const first = ACHIEVEMENTS.find((ach) => ach.id === "first_steps");
|
|
187
|
+
return first ? [first] : [];
|
|
188
|
+
}
|
|
189
|
+
|
|
198
190
|
export function isAchievementUnlocked(
|
|
199
191
|
id: string,
|
|
200
192
|
unlocked: UnlockedAchievement[],
|
package/core/art-data.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Species, Hat } from "./engine.ts";
|
|
2
2
|
|
|
3
|
+
// ─── Species art: 3 frames × 5 lines each ──────────────────────────────────
|
|
4
|
+
|
|
3
5
|
export const SPECIES_ART: Record<Species, string[][]> = {
|
|
4
6
|
duck: [
|
|
5
7
|
[" ", " __ ", " <({E} )___ ", " ( ._> ", " `--' "],
|
|
@@ -91,8 +93,32 @@ export const SPECIES_ART: Record<Species, string[][]> = {
|
|
|
91
93
|
[" ", " /\\ /| ", " ( {E} {E} ) ", " ( .. ) ", " `------' "],
|
|
92
94
|
[" ", " /\\ /\\ ", " ( {E} {E} ) ", " ( .. ) ", " `------'~ "],
|
|
93
95
|
],
|
|
96
|
+
wyvern: [
|
|
97
|
+
["} {",
|
|
98
|
+
"|\\^```^/|",
|
|
99
|
+
"\\ {E}' '{E} /",
|
|
100
|
+
" ≈(° °)≈",
|
|
101
|
+
" '-'"],
|
|
102
|
+
["} {",
|
|
103
|
+
"|\\^```^/|",
|
|
104
|
+
"\\ {E}' '{E} /",
|
|
105
|
+
" ≈(° °)≈",
|
|
106
|
+
" '~'"],
|
|
107
|
+
["} {",
|
|
108
|
+
"|\\^```^/|",
|
|
109
|
+
"\\ {E}' '{E} /",
|
|
110
|
+
" ≈(° °)≈",
|
|
111
|
+
" 'v'"],
|
|
112
|
+
],
|
|
113
|
+
pikachu: [
|
|
114
|
+
[" ", " /\\_/\\ ", " ({E} {E}) ", " ( ω ) ", " (__) "],
|
|
115
|
+
[" ", " /\\_/\\ ", " (- -) ", " ( ω ) ", " (__) "],
|
|
116
|
+
[" ", " /\\_/\\ ", " ({E} {E}) ", " ( ~ ) ", " (__) "],
|
|
117
|
+
],
|
|
94
118
|
};
|
|
95
119
|
|
|
120
|
+
// ─── Hat art ────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
96
122
|
export const HAT_ART: Record<Hat, string> = {
|
|
97
123
|
none: "",
|
|
98
124
|
crown: " \\^^^/ ",
|
package/core/command-service.ts
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
|
+
export function mergeAchievements(first: Achievement[], second: Achievement[]): Achievement[] {
|
|
2
|
+
const merged = new Map<string, Achievement>();
|
|
3
|
+
for (const achievement of [...first, ...second]) {
|
|
4
|
+
merged.set(achievement.id, achievement);
|
|
5
|
+
}
|
|
6
|
+
return [...merged.values()];
|
|
7
|
+
}
|
|
8
|
+
|
|
1
9
|
import {
|
|
2
10
|
generateBones,
|
|
3
11
|
RARITY_STARS,
|
|
4
12
|
type Companion,
|
|
5
13
|
} from "./engine.ts";
|
|
6
|
-
import { getUnlockedAchievements, type Achievement } from "./achievements.ts";
|
|
14
|
+
import { ACHIEVEMENTS, getCreationAchievements, getUnlockedAchievements, type Achievement } from "./achievements.ts";
|
|
7
15
|
import { generateFallbackName, getReaction } from "./reactions.ts";
|
|
16
|
+
import { slugifySlot } from "./slot-slug.ts";
|
|
8
17
|
import type {
|
|
9
18
|
BuddyConfig,
|
|
10
19
|
ReactionState,
|
|
@@ -86,40 +95,27 @@ export class BuddyCommandService {
|
|
|
86
95
|
}
|
|
87
96
|
|
|
88
97
|
ensureCompanion(): EnsureCompanionResult {
|
|
89
|
-
const active = this.deps.buddies.loadActive();
|
|
90
|
-
if (active) {
|
|
91
|
-
return {
|
|
92
|
-
companion: active,
|
|
93
|
-
slot: this.getActiveSlot(),
|
|
94
|
-
created: false,
|
|
95
|
-
achievements: [],
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const saved = this.deps.buddies.listSlots();
|
|
100
|
-
if (saved.length > 0) {
|
|
101
|
-
const rescued = saved[0];
|
|
102
|
-
this.deps.buddies.saveActiveSlot(rescued.slot);
|
|
103
|
-
return {
|
|
104
|
-
companion: rescued.companion,
|
|
105
|
-
slot: rescued.slot,
|
|
106
|
-
created: false,
|
|
107
|
-
achievements: [],
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
|
|
111
98
|
const userId = this.getStableUserId();
|
|
112
|
-
const companion = this.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
99
|
+
const { companion, slot, created } = this.deps.buddies.ensureCompanion(
|
|
100
|
+
userId,
|
|
101
|
+
(existingSlots) => this.createCompanion(userId, existingSlots),
|
|
102
|
+
);
|
|
103
|
+
const achievements = created
|
|
104
|
+
? this.deps.events.unlockAchievements(slot, getCreationAchievements)
|
|
105
|
+
: [];
|
|
106
|
+
return { companion, slot, created, achievements };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
startSession(): EnsureCompanionResult {
|
|
110
|
+
const result = this.ensureCompanion();
|
|
116
111
|
this.deps.events.trackActiveDay();
|
|
117
112
|
this.deps.events.increment("sessions", 1);
|
|
118
|
-
const
|
|
113
|
+
const sessionAchievements = this.unlockNewAchievements(result.slot);
|
|
119
114
|
|
|
120
|
-
return {
|
|
115
|
+
return { ...result, achievements: mergeAchievements(result.achievements, sessionAchievements) };
|
|
121
116
|
}
|
|
122
117
|
|
|
118
|
+
|
|
123
119
|
petBuddy(scope?: string): ReactionResult {
|
|
124
120
|
const { companion, slot } = this.ensureCompanion();
|
|
125
121
|
this.deps.events.increment("pets", 1, slot);
|
|
@@ -129,31 +125,45 @@ export class BuddyCommandService {
|
|
|
129
125
|
renameBuddy(name: string): Companion {
|
|
130
126
|
const trimmed = name.trim();
|
|
131
127
|
if (!trimmed) throw new Error("Buddy name cannot be empty.");
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
128
|
+
this.ensureCompanion();
|
|
129
|
+
return this.deps.buddies.updateActive((companion) => ({
|
|
130
|
+
...companion,
|
|
131
|
+
name: trimmed.slice(0, 14),
|
|
132
|
+
}));
|
|
136
133
|
}
|
|
137
134
|
|
|
138
135
|
setPersonality(personality: string): Companion {
|
|
139
136
|
const trimmed = personality.trim();
|
|
140
137
|
if (!trimmed) throw new Error("Buddy personality cannot be empty.");
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
138
|
+
this.ensureCompanion();
|
|
139
|
+
return this.deps.buddies.updateActive((companion) => ({
|
|
140
|
+
...companion,
|
|
141
|
+
personality: trimmed,
|
|
142
|
+
}));
|
|
145
143
|
}
|
|
146
144
|
|
|
147
145
|
saveBuddy(slot?: string): SaveBuddyResult {
|
|
148
|
-
const { companion } = this.ensureCompanion();
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
146
|
+
const { companion, slot: activeSlot } = this.ensureCompanion();
|
|
147
|
+
if (slot?.trim()) {
|
|
148
|
+
const targetSlot = slugifySlot(slot.trim());
|
|
149
|
+
if (targetSlot !== activeSlot && this.deps.buddies.loadSlot(targetSlot)) {
|
|
150
|
+
throw new Error(`Slot "${targetSlot}" already exists.`);
|
|
151
|
+
}
|
|
152
|
+
if (targetSlot === activeSlot) {
|
|
153
|
+
this.deps.buddies.saveActive(companion);
|
|
154
|
+
} else {
|
|
155
|
+
this.deps.buddies.saveSlot(targetSlot, companion);
|
|
156
|
+
}
|
|
157
|
+
this.deps.buddies.saveActiveSlot(targetSlot);
|
|
158
|
+
return { companion, slot: targetSlot };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
this.deps.buddies.saveActive(companion);
|
|
162
|
+
return { companion, slot: activeSlot };
|
|
153
163
|
}
|
|
154
164
|
|
|
155
165
|
summonBuddy(slot?: string): SummonBuddyResult | null {
|
|
156
|
-
const targetSlot = slot ?
|
|
166
|
+
const targetSlot = slot ? slugifySlot(slot) : undefined;
|
|
157
167
|
|
|
158
168
|
if (!targetSlot) {
|
|
159
169
|
const companions = this.deps.buddies.listSlots();
|
|
@@ -171,7 +181,7 @@ export class BuddyCommandService {
|
|
|
171
181
|
}
|
|
172
182
|
|
|
173
183
|
dismissBuddy(slot: string): SaveBuddyResult {
|
|
174
|
-
const targetSlot =
|
|
184
|
+
const targetSlot = slugifySlot(slot);
|
|
175
185
|
const activeSlot = this.getActiveSlot();
|
|
176
186
|
if (targetSlot === activeSlot) {
|
|
177
187
|
throw new Error("Cannot dismiss the active buddy.");
|
|
@@ -208,6 +218,23 @@ export class BuddyCommandService {
|
|
|
208
218
|
this.deps.events.increment("large_diffs", 1);
|
|
209
219
|
return this.saveReaction("large-diff", companion, slot, scope, { lines });
|
|
210
220
|
}
|
|
221
|
+
trackToolError(scope?: string, line?: number): Achievement[] {
|
|
222
|
+
const { slot } = this.ensureCompanion();
|
|
223
|
+
this.deps.events.increment("errors_seen", 1, slot);
|
|
224
|
+
return this.unlockNewAchievements(slot);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
trackTestFailure(scope?: string, count?: number): Achievement[] {
|
|
228
|
+
const { slot } = this.ensureCompanion();
|
|
229
|
+
this.deps.events.increment("tests_failed", 1, slot);
|
|
230
|
+
return this.unlockNewAchievements(slot);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
trackLargeDiff(lines: number, scope?: string): Achievement[] {
|
|
234
|
+
const { slot } = this.ensureCompanion();
|
|
235
|
+
this.deps.events.increment("large_diffs", 1, slot);
|
|
236
|
+
return this.unlockNewAchievements(slot);
|
|
237
|
+
}
|
|
211
238
|
|
|
212
239
|
recordTurn(scope?: string): ReactionResult {
|
|
213
240
|
const { companion, slot } = this.ensureCompanion();
|
|
@@ -218,7 +245,7 @@ export class BuddyCommandService {
|
|
|
218
245
|
recordTurnOnly(): ProgressResult {
|
|
219
246
|
const { companion, slot } = this.ensureCompanion();
|
|
220
247
|
this.deps.events.increment("turns", 1);
|
|
221
|
-
const achievements = this.
|
|
248
|
+
const achievements = this.unlockNewAchievements(slot);
|
|
222
249
|
return { companion, slot, achievements };
|
|
223
250
|
}
|
|
224
251
|
|
|
@@ -232,10 +259,10 @@ export class BuddyCommandService {
|
|
|
232
259
|
const { companion, slot } = this.ensureCompanion();
|
|
233
260
|
return this.saveCustomReaction(trimmed, reason, companion, slot, scope);
|
|
234
261
|
}
|
|
235
|
-
|
|
236
262
|
incrementCommandsRun(): Achievement[] {
|
|
237
|
-
|
|
238
|
-
|
|
263
|
+
const slot = this.getActiveSlotOrUndefined();
|
|
264
|
+
this.deps.events.increment("commands_run", 1, slot);
|
|
265
|
+
return this.unlockNewAchievements(slot);
|
|
239
266
|
}
|
|
240
267
|
|
|
241
268
|
getAchievementProgress(slot?: string): {
|
|
@@ -245,15 +272,10 @@ export class BuddyCommandService {
|
|
|
245
272
|
const unlocked = this.deps.events.loadUnlocked();
|
|
246
273
|
const unlockedMap = new Map(unlocked.map((entry) => [entry.id, entry]));
|
|
247
274
|
|
|
248
|
-
const unlockedAchievements =
|
|
249
|
-
|
|
250
|
-
new Set<string>(),
|
|
251
|
-
).filter((achievement) => unlockedMap.has(achievement.id));
|
|
252
|
-
|
|
253
|
-
const remaining = getUnlockedAchievements(
|
|
254
|
-
this.deps.events.loadCounters(slot),
|
|
255
|
-
new Set(unlocked.map((entry) => entry.id)),
|
|
275
|
+
const unlockedAchievements = ACHIEVEMENTS.filter((achievement) =>
|
|
276
|
+
unlockedMap.has(achievement.id)
|
|
256
277
|
);
|
|
278
|
+
const remaining = ACHIEVEMENTS.filter((achievement) => !unlockedMap.has(achievement.id));
|
|
257
279
|
|
|
258
280
|
return {
|
|
259
281
|
unlocked: unlockedAchievements.map((achievement) => ({
|
|
@@ -279,23 +301,30 @@ export class BuddyCommandService {
|
|
|
279
301
|
return slot || undefined;
|
|
280
302
|
}
|
|
281
303
|
|
|
282
|
-
private createCompanion(
|
|
304
|
+
private createCompanion(
|
|
305
|
+
userId: string,
|
|
306
|
+
existingSlots: string[],
|
|
307
|
+
): { companion: Companion; slot: string } {
|
|
283
308
|
const bones = generateBones(userId);
|
|
284
|
-
const name = this.generateUnusedName();
|
|
309
|
+
const name = this.generateUnusedName(existingSlots);
|
|
310
|
+
const slot = slugifySlot(name);
|
|
285
311
|
return {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
312
|
+
companion: {
|
|
313
|
+
bones,
|
|
314
|
+
name,
|
|
315
|
+
personality: `A ${bones.rarity} ${bones.species} who watches code with quiet intensity.`,
|
|
316
|
+
hatchedAt: Date.now(),
|
|
317
|
+
userId,
|
|
318
|
+
},
|
|
319
|
+
slot,
|
|
291
320
|
};
|
|
292
321
|
}
|
|
293
322
|
|
|
294
|
-
private generateUnusedName(): string {
|
|
295
|
-
const taken = new Set(
|
|
323
|
+
private generateUnusedName(existingSlots: string[]): string {
|
|
324
|
+
const taken = new Set(existingSlots);
|
|
296
325
|
for (let i = 0; i < 50; i++) {
|
|
297
326
|
const candidate = generateFallbackName();
|
|
298
|
-
if (!taken.has(
|
|
327
|
+
if (!taken.has(slugifySlot(candidate))) return candidate;
|
|
299
328
|
}
|
|
300
329
|
|
|
301
330
|
let suffix = 0;
|
|
@@ -314,6 +343,7 @@ export class BuddyCommandService {
|
|
|
314
343
|
reason,
|
|
315
344
|
companion.bones.species,
|
|
316
345
|
companion.bones.rarity,
|
|
346
|
+
companion.bones.stats,
|
|
317
347
|
context,
|
|
318
348
|
);
|
|
319
349
|
return this.saveCustomReaction(reaction, reason, companion, slot, scope);
|
|
@@ -333,40 +363,12 @@ export class BuddyCommandService {
|
|
|
333
363
|
};
|
|
334
364
|
this.deps.reactions.saveLatest(state, scope);
|
|
335
365
|
this.deps.events.increment("reactions_given", 1, slot);
|
|
336
|
-
const achievements = this.
|
|
366
|
+
const achievements = this.unlockNewAchievements(slot);
|
|
337
367
|
return { companion, slot, reaction, state, achievements };
|
|
338
368
|
}
|
|
339
369
|
|
|
340
|
-
private
|
|
341
|
-
|
|
342
|
-
const unlockedIds = new Set(unlocked.map((entry) => entry.id));
|
|
343
|
-
const newlyUnlocked = getUnlockedAchievements(
|
|
344
|
-
this.deps.events.loadCounters(slot),
|
|
345
|
-
unlockedIds,
|
|
346
|
-
);
|
|
347
|
-
|
|
348
|
-
if (newlyUnlocked.length === 0) return [];
|
|
349
|
-
|
|
350
|
-
this.deps.events.saveUnlocked([
|
|
351
|
-
...unlocked,
|
|
352
|
-
...newlyUnlocked.map((achievement) => ({
|
|
353
|
-
id: achievement.id,
|
|
354
|
-
unlockedAt: Date.now(),
|
|
355
|
-
slot,
|
|
356
|
-
})),
|
|
357
|
-
]);
|
|
358
|
-
|
|
359
|
-
return newlyUnlocked;
|
|
370
|
+
private unlockNewAchievements(slot?: string): Achievement[] {
|
|
371
|
+
return this.deps.events.unlockAchievements(slot, getUnlockedAchievements);
|
|
360
372
|
}
|
|
361
|
-
}
|
|
362
373
|
|
|
363
|
-
function slugify(name: string): string {
|
|
364
|
-
return (
|
|
365
|
-
name
|
|
366
|
-
.toLowerCase()
|
|
367
|
-
.replace(/[^a-z0-9-]/g, "-")
|
|
368
|
-
.replace(/-+/g, "-")
|
|
369
|
-
.replace(/^-|-$/g, "")
|
|
370
|
-
.slice(0, 14) || "buddy"
|
|
371
|
-
);
|
|
372
374
|
}
|