@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.
Files changed (94) hide show
  1. package/.claude-plugin/marketplace.json +45 -0
  2. package/.claude-plugin/plugin.json +30 -0
  3. package/LICENSE +1 -1
  4. package/README.md +96 -45
  5. package/adapters/omp/commands.ts +201 -0
  6. package/adapters/omp/context.ts +22 -0
  7. package/adapters/omp/events.ts +551 -0
  8. package/adapters/omp/identity.ts +10 -0
  9. package/adapters/omp/index.ts +39 -0
  10. package/adapters/omp/logger.ts +52 -0
  11. package/adapters/omp/prompt.ts +60 -0
  12. package/adapters/omp/renderers.ts +73 -0
  13. package/adapters/omp/storage.ts +25 -0
  14. package/adapters/omp/ui.ts +85 -0
  15. package/adapters/pi/commands.ts +64 -37
  16. package/adapters/pi/events.ts +147 -72
  17. package/adapters/pi/index.ts +9 -4
  18. package/adapters/pi/storage.ts +14 -285
  19. package/adapters/pi/ui.ts +56 -5
  20. package/adapters/shared/file-storage.ts +526 -0
  21. package/{adapters/claude/install → cli}/backup.ts +130 -33
  22. package/cli/buddy-shell.ts +1 -1
  23. package/{adapters/claude/install → cli}/disable.ts +19 -32
  24. package/cli/doctor.ts +231 -0
  25. package/{adapters/claude/install → cli}/hunt.ts +7 -9
  26. package/cli/index.ts +30 -26
  27. package/{adapters/claude/install → cli}/install.ts +65 -83
  28. package/{adapters/claude/install → cli}/pick.ts +136 -57
  29. package/{adapters/claude/install → cli}/settings.ts +2 -2
  30. package/{adapters/claude/install → cli}/show.ts +4 -4
  31. package/{adapters/claude/install → cli}/test-statusline.sh +2 -2
  32. package/{adapters/claude/install → cli}/test-statusline.ts +21 -11
  33. package/cli/tui.tsx +106 -17
  34. package/{adapters/claude/install → cli}/uninstall.ts +40 -47
  35. package/cli/upgrade.ts +46 -13
  36. package/cli/validate-species.ts +209 -0
  37. package/cli/verify.ts +3 -3
  38. package/core/achievements.ts +12 -20
  39. package/core/art-data.ts +26 -0
  40. package/core/command-service.ts +99 -97
  41. package/core/engine.ts +83 -1
  42. package/core/ports.ts +10 -0
  43. package/core/reactions.ts +533 -95
  44. package/core/slot-slug.ts +22 -0
  45. package/{adapters/claude/hooks → hooks}/buddy-comment.sh +22 -9
  46. package/hooks/file-type-react.sh +403 -0
  47. package/hooks/hooks.json +52 -0
  48. package/hooks/mood-react.sh +210 -0
  49. package/{adapters/claude/hooks → hooks}/name-react.sh +3 -4
  50. package/hooks/react.sh +1364 -0
  51. package/hooks/suggest.sh +55 -0
  52. package/package.json +70 -31
  53. package/scripts/gen-emoji-widths.ts +34 -0
  54. package/scripts/paths.sh +33 -2
  55. package/scripts/postinstall.cjs +42 -0
  56. package/scripts/snapshot-statusline.sh +54 -0
  57. package/server/achievements.ts +1419 -15
  58. package/server/art.ts +450 -1
  59. package/server/award-xp.ts +48 -0
  60. package/server/check-suggestions.ts +102 -0
  61. package/server/consolidate.ts +33 -0
  62. package/{adapters/claude/server → server}/index.ts +556 -95
  63. package/server/mcp-launcher.sh +16 -6
  64. package/server/memory.ts +404 -0
  65. package/server/mood.ts +254 -0
  66. package/server/path.ts +70 -18
  67. package/server/shift-mood.ts +43 -0
  68. package/server/state.ts +552 -3
  69. package/server/suggestions.ts +335 -0
  70. package/server/theme.ts +53 -0
  71. package/server/xp.ts +435 -0
  72. package/{adapters/claude/skills → skills}/buddy/SKILL.md +36 -7
  73. package/statusline/buddy-status.sh +402 -0
  74. package/statusline/combined-status.sh +135 -0
  75. package/statusline/emoji-widths.data +4 -0
  76. package/adapters/claude/hooks/hooks.json +0 -35
  77. package/adapters/claude/hooks/react.sh +0 -207
  78. package/adapters/claude/install/doctor.ts +0 -172
  79. package/adapters/claude/plugin/marketplace.json +0 -40
  80. package/adapters/claude/plugin/plugin.json +0 -26
  81. package/adapters/claude/rendering/art.ts +0 -264
  82. package/adapters/claude/server/instructions.ts +0 -24
  83. package/adapters/claude/server/path.ts +0 -12
  84. package/adapters/claude/server/resources.ts +0 -38
  85. package/adapters/claude/statusline/buddy-status.sh +0 -408
  86. package/adapters/claude/storage/achievements.ts +0 -253
  87. package/adapters/claude/storage/identity.ts +0 -13
  88. package/adapters/claude/storage/paths.ts +0 -33
  89. package/adapters/claude/storage/settings.ts +0 -91
  90. package/adapters/claude/storage/state.ts +0 -365
  91. package/adapters/pi/README.md +0 -83
  92. package/adapters/pi/tools.ts +0 -6
  93. package/server/engine.ts +0 -1
  94. 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
- * claude-buddy verify — show what buddy a user ID produces
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 "../adapters/claude/storage/identity.ts";
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
 
@@ -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: " \\^^^/ ",
@@ -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.createCompanion(userId);
113
- const slot = slugify(companion.name);
114
- this.deps.buddies.saveSlot(slot, companion);
115
- this.deps.buddies.saveActiveSlot(slot);
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 achievements = this.unlockAchievements(slot);
113
+ const sessionAchievements = this.unlockNewAchievements(result.slot);
119
114
 
120
- return { companion, slot, created: true, achievements };
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
- const { companion } = this.ensureCompanion();
133
- const updated: Companion = { ...companion, name: trimmed.slice(0, 14) };
134
- this.deps.buddies.saveActive(updated);
135
- return updated;
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
- const { companion } = this.ensureCompanion();
142
- const updated: Companion = { ...companion, personality: trimmed };
143
- this.deps.buddies.saveActive(updated);
144
- return updated;
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
- const targetSlot = slugify(slot?.trim() || companion.name);
150
- this.deps.buddies.saveSlot(targetSlot, companion);
151
- this.deps.buddies.saveActiveSlot(targetSlot);
152
- return { companion, slot: targetSlot };
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 ? slugify(slot) : undefined;
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 = slugify(slot);
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.unlockAchievements(slot);
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
- this.deps.events.increment("commands_run", 1, this.getActiveSlotOrUndefined());
238
- return this.unlockAchievements(this.getActiveSlotOrUndefined());
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 = getUnlockedAchievements(
249
- this.deps.events.loadCounters(slot),
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(userId: string): Companion {
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
- bones,
287
- name,
288
- personality: `A ${bones.rarity} ${bones.species} who watches code with quiet intensity.`,
289
- hatchedAt: Date.now(),
290
- userId,
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(this.deps.buddies.listSlots().map(({ slot }) => slot));
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(slugify(candidate))) return candidate;
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.unlockAchievements(slot);
366
+ const achievements = this.unlockNewAchievements(slot);
337
367
  return { companion, slot, reaction, state, achievements };
338
368
  }
339
369
 
340
- private unlockAchievements(slot?: string): Achievement[] {
341
- const unlocked = this.deps.events.loadUnlocked();
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
  }