@ramarivera/coding-buddy 0.4.0-alpha.2 → 0.4.0-alpha.4
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 +4 -5
- package/{hooks → adapters/claude/hooks}/hooks.json +3 -3
- package/{cli → adapters/claude/install}/backup.ts +10 -9
- package/{cli → adapters/claude/install}/disable.ts +27 -6
- package/{cli → adapters/claude/install}/doctor.ts +44 -31
- package/{cli → adapters/claude/install}/hunt.ts +4 -4
- package/{cli → adapters/claude/install}/install.ts +81 -42
- package/{cli → adapters/claude/install}/pick.ts +3 -3
- package/{cli → adapters/claude/install}/settings.ts +1 -1
- package/{cli → adapters/claude/install}/show.ts +2 -2
- package/{cli → adapters/claude/install}/test-statusline.ts +3 -2
- package/{cli → adapters/claude/install}/uninstall.ts +31 -10
- package/{.claude-plugin → adapters/claude/plugin}/plugin.json +1 -1
- package/adapters/claude/popup/buddy-popup.sh +92 -0
- package/adapters/claude/popup/buddy-render.sh +540 -0
- package/adapters/claude/popup/popup-manager.sh +355 -0
- package/{server → adapters/claude/rendering}/art.ts +3 -115
- package/{server → adapters/claude/server}/index.ts +49 -71
- package/adapters/claude/server/instructions.ts +24 -0
- package/adapters/claude/server/resources.ts +38 -0
- package/adapters/claude/storage/achievements.ts +253 -0
- package/adapters/claude/storage/identity.ts +13 -0
- package/adapters/claude/storage/paths.ts +24 -0
- package/adapters/claude/storage/settings.ts +41 -0
- package/{server → adapters/claude/storage}/state.ts +3 -65
- package/adapters/pi/README.md +64 -0
- package/adapters/pi/commands.ts +173 -0
- package/adapters/pi/events.ts +150 -0
- package/adapters/pi/identity.ts +10 -0
- package/adapters/pi/index.ts +25 -0
- package/adapters/pi/renderers.ts +73 -0
- package/adapters/pi/storage.ts +295 -0
- package/adapters/pi/tools.ts +6 -0
- package/adapters/pi/ui.ts +39 -0
- package/cli/index.ts +11 -11
- package/cli/verify.ts +2 -2
- package/core/achievements.ts +203 -0
- package/core/art-data.ts +105 -0
- package/core/command-service.ts +338 -0
- package/core/model.ts +59 -0
- package/core/ports.ts +40 -0
- package/core/render-model.ts +10 -0
- package/package.json +23 -19
- package/server/achievements.ts +0 -445
- /package/{hooks → adapters/claude/hooks}/buddy-comment.sh +0 -0
- /package/{hooks → adapters/claude/hooks}/name-react.sh +0 -0
- /package/{hooks → adapters/claude/hooks}/react.sh +0 -0
- /package/{cli → adapters/claude/install}/test-statusline.sh +0 -0
- /package/{.claude-plugin → adapters/claude/plugin}/marketplace.json +0 -0
- /package/{skills → adapters/claude/skills}/buddy/SKILL.md +0 -0
- /package/{statusline → adapters/claude/statusline}/buddy-status.sh +0 -0
- /package/{server → core}/engine.ts +0 -0
- /package/{server → core}/reactions.ts +0 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import {
|
|
12
|
+
EMPTY_GLOBAL,
|
|
13
|
+
EMPTY_SLOT,
|
|
14
|
+
GLOBAL_KEYS,
|
|
15
|
+
SLOT_KEYS,
|
|
16
|
+
type GlobalCounters,
|
|
17
|
+
type SlotCounters,
|
|
18
|
+
} from "../../core/achievements.ts";
|
|
19
|
+
import type {
|
|
20
|
+
BuddyConfig,
|
|
21
|
+
Companion,
|
|
22
|
+
EventCounters,
|
|
23
|
+
ReactionState,
|
|
24
|
+
UnlockedAchievement,
|
|
25
|
+
} from "../../core/model.ts";
|
|
26
|
+
import type {
|
|
27
|
+
BuddyConfigRepository,
|
|
28
|
+
BuddyEventRepository,
|
|
29
|
+
BuddyRepository,
|
|
30
|
+
ReactionRepository,
|
|
31
|
+
} from "../../core/ports.ts";
|
|
32
|
+
|
|
33
|
+
const STATE_DIR = join(homedir(), ".pi", "agent", "buddy");
|
|
34
|
+
const MENAGERIE_FILE = join(STATE_DIR, "menagerie.json");
|
|
35
|
+
const CONFIG_FILE = join(STATE_DIR, "config.json");
|
|
36
|
+
const REACTION_FILE = join(STATE_DIR, "reaction.json");
|
|
37
|
+
const EVENTS_FILE = join(STATE_DIR, "events.json");
|
|
38
|
+
const UNLOCKED_FILE = join(STATE_DIR, "unlocked.json");
|
|
39
|
+
const ACTIVE_DAYS_FILE = join(STATE_DIR, "active_days.json");
|
|
40
|
+
const IDENTITY_FILE = join(STATE_DIR, "identity.json");
|
|
41
|
+
|
|
42
|
+
interface PiBuddyConfig extends BuddyConfig {
|
|
43
|
+
muted: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface MenagerieManifest {
|
|
47
|
+
active: string;
|
|
48
|
+
companions: Record<string, Companion>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface ActiveDayState {
|
|
52
|
+
lastDate: string;
|
|
53
|
+
totalDays: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const DEFAULT_CONFIG: PiBuddyConfig = {
|
|
57
|
+
commentCooldown: 30,
|
|
58
|
+
reactionTTL: 0,
|
|
59
|
+
bubbleStyle: "classic",
|
|
60
|
+
bubblePosition: "top",
|
|
61
|
+
showRarity: true,
|
|
62
|
+
statusLineEnabled: true,
|
|
63
|
+
muted: false,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function ensureDir(): void {
|
|
67
|
+
if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function atomicWrite(path: string, value: string): void {
|
|
71
|
+
ensureDir();
|
|
72
|
+
const tmp = `${path}.tmp`;
|
|
73
|
+
writeFileSync(tmp, value, "utf8");
|
|
74
|
+
renameSync(tmp, path);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readJson<T>(path: string, fallback: T): T {
|
|
78
|
+
try {
|
|
79
|
+
return { ...fallback, ...JSON.parse(readFileSync(path, "utf8")) };
|
|
80
|
+
} catch {
|
|
81
|
+
return fallback;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function loadManifest(): MenagerieManifest {
|
|
86
|
+
const manifest = readJson<MenagerieManifest>(MENAGERIE_FILE, {
|
|
87
|
+
active: "buddy",
|
|
88
|
+
companions: {},
|
|
89
|
+
});
|
|
90
|
+
if (!manifest.companions) manifest.companions = {};
|
|
91
|
+
if (!manifest.active) manifest.active = Object.keys(manifest.companions)[0] ?? "buddy";
|
|
92
|
+
return manifest;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function saveManifest(manifest: MenagerieManifest): void {
|
|
96
|
+
atomicWrite(MENAGERIE_FILE, JSON.stringify(manifest, null, 2));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function slotEventsFile(slot: string): string {
|
|
100
|
+
return join(STATE_DIR, `events.${slot}.json`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isSlotKey(key: keyof EventCounters): key is keyof SlotCounters {
|
|
104
|
+
return (SLOT_KEYS as readonly string[]).includes(key);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isGlobalKey(key: keyof EventCounters): key is keyof GlobalCounters {
|
|
108
|
+
return (GLOBAL_KEYS as readonly string[]).includes(key);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export class PiBuddyStorage
|
|
112
|
+
implements
|
|
113
|
+
BuddyRepository,
|
|
114
|
+
ReactionRepository,
|
|
115
|
+
BuddyConfigRepository,
|
|
116
|
+
BuddyEventRepository
|
|
117
|
+
{
|
|
118
|
+
readonly stateDir = STATE_DIR;
|
|
119
|
+
|
|
120
|
+
loadActive(): Companion | null {
|
|
121
|
+
const manifest = loadManifest();
|
|
122
|
+
return manifest.companions[manifest.active] ?? null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
saveActive(companion: Companion): void {
|
|
126
|
+
const manifest = loadManifest();
|
|
127
|
+
manifest.companions[manifest.active] = companion;
|
|
128
|
+
saveManifest(manifest);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
loadSlot(slot: string): Companion | null {
|
|
132
|
+
return loadManifest().companions[slot] ?? null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
saveSlot(slot: string, companion: Companion): void {
|
|
136
|
+
const manifest = loadManifest();
|
|
137
|
+
if (manifest.companions[slot]) {
|
|
138
|
+
throw new Error(`Slot \"${slot}\" already exists.`);
|
|
139
|
+
}
|
|
140
|
+
manifest.companions[slot] = companion;
|
|
141
|
+
saveManifest(manifest);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
deleteSlot(slot: string): void {
|
|
145
|
+
const manifest = loadManifest();
|
|
146
|
+
delete manifest.companions[slot];
|
|
147
|
+
if (manifest.active === slot) {
|
|
148
|
+
manifest.active = Object.keys(manifest.companions)[0] ?? "buddy";
|
|
149
|
+
}
|
|
150
|
+
saveManifest(manifest);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
listSlots(): Array<{ slot: string; companion: Companion }> {
|
|
154
|
+
return Object.entries(loadManifest().companions).map(([slot, companion]) => ({
|
|
155
|
+
slot,
|
|
156
|
+
companion,
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
loadActiveSlot(): string | null {
|
|
161
|
+
const manifest = loadManifest();
|
|
162
|
+
if (manifest.active && manifest.companions[manifest.active]) return manifest.active;
|
|
163
|
+
return Object.keys(manifest.companions)[0] ?? null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
saveActiveSlot(slot: string): void {
|
|
167
|
+
const manifest = loadManifest();
|
|
168
|
+
manifest.active = slot;
|
|
169
|
+
saveManifest(manifest);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
loadLatest(): ReactionState | null {
|
|
173
|
+
try {
|
|
174
|
+
const reaction = JSON.parse(readFileSync(REACTION_FILE, "utf8")) as ReactionState;
|
|
175
|
+
const ttl = this.loadPiConfig().reactionTTL;
|
|
176
|
+
if (ttl > 0 && Date.now() - reaction.timestamp > ttl * 1000) return null;
|
|
177
|
+
return reaction;
|
|
178
|
+
} catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
saveLatest(reaction: ReactionState): void {
|
|
184
|
+
atomicWrite(REACTION_FILE, JSON.stringify(reaction, null, 2));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
clearLatestReaction(): void {
|
|
188
|
+
atomicWrite(
|
|
189
|
+
REACTION_FILE,
|
|
190
|
+
JSON.stringify({ reaction: "", reason: "clear", timestamp: Date.now() }, null, 2),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
loadConfig(): BuddyConfig {
|
|
195
|
+
return this.loadPiConfig();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
saveConfig(config: Partial<BuddyConfig>): BuddyConfig {
|
|
199
|
+
return this.savePiConfig(config);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
loadPiConfig(): PiBuddyConfig {
|
|
203
|
+
return readJson<PiBuddyConfig>(CONFIG_FILE, DEFAULT_CONFIG);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
savePiConfig(config: Partial<PiBuddyConfig>): PiBuddyConfig {
|
|
207
|
+
const merged = { ...this.loadPiConfig(), ...config };
|
|
208
|
+
atomicWrite(CONFIG_FILE, JSON.stringify(merged, null, 2));
|
|
209
|
+
return merged;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
isMuted(): boolean {
|
|
213
|
+
return this.loadPiConfig().muted;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
setMuted(muted: boolean): PiBuddyConfig {
|
|
217
|
+
return this.savePiConfig({ muted });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
loadCounters(slot?: string): EventCounters {
|
|
221
|
+
const global = readJson<GlobalCounters>(EVENTS_FILE, EMPTY_GLOBAL);
|
|
222
|
+
if (!slot) {
|
|
223
|
+
return { ...global, ...EMPTY_SLOT };
|
|
224
|
+
}
|
|
225
|
+
const slotCounters = readJson<SlotCounters>(slotEventsFile(slot), EMPTY_SLOT);
|
|
226
|
+
return { ...global, ...slotCounters };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
increment(key: keyof EventCounters, amount: number = 1, slot?: string): EventCounters {
|
|
230
|
+
if (isSlotKey(key) && slot) {
|
|
231
|
+
const slotCounters = readJson<SlotCounters>(slotEventsFile(slot), EMPTY_SLOT);
|
|
232
|
+
slotCounters[key] += amount;
|
|
233
|
+
atomicWrite(slotEventsFile(slot), JSON.stringify(slotCounters, null, 2));
|
|
234
|
+
} else if (isGlobalKey(key)) {
|
|
235
|
+
const global = readJson<GlobalCounters>(EVENTS_FILE, EMPTY_GLOBAL);
|
|
236
|
+
global[key] += amount;
|
|
237
|
+
atomicWrite(EVENTS_FILE, JSON.stringify(global, null, 2));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return this.loadCounters(slot);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
loadUnlocked(): UnlockedAchievement[] {
|
|
244
|
+
try {
|
|
245
|
+
return JSON.parse(readFileSync(UNLOCKED_FILE, "utf8")) as UnlockedAchievement[];
|
|
246
|
+
} catch {
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
saveUnlocked(unlocked: UnlockedAchievement[]): void {
|
|
252
|
+
atomicWrite(UNLOCKED_FILE, JSON.stringify(unlocked, null, 2));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
trackActiveDay(): void {
|
|
256
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
257
|
+
const current = readJson<ActiveDayState>(ACTIVE_DAYS_FILE, {
|
|
258
|
+
lastDate: "",
|
|
259
|
+
totalDays: 0,
|
|
260
|
+
});
|
|
261
|
+
if (current.lastDate === today) return;
|
|
262
|
+
|
|
263
|
+
const next = { lastDate: today, totalDays: current.totalDays + 1 };
|
|
264
|
+
atomicWrite(ACTIVE_DAYS_FILE, JSON.stringify(next, null, 2));
|
|
265
|
+
|
|
266
|
+
const global = readJson<GlobalCounters>(EVENTS_FILE, EMPTY_GLOBAL);
|
|
267
|
+
global.days_active = next.totalDays;
|
|
268
|
+
atomicWrite(EVENTS_FILE, JSON.stringify(global, null, 2));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
ensureStableIdentity(): string {
|
|
272
|
+
ensureDir();
|
|
273
|
+
try {
|
|
274
|
+
const parsed = JSON.parse(readFileSync(IDENTITY_FILE, "utf8")) as { userId?: string };
|
|
275
|
+
if (parsed.userId) return parsed.userId;
|
|
276
|
+
} catch {
|
|
277
|
+
// ignore and create below
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const userId = randomUUID();
|
|
281
|
+
atomicWrite(IDENTITY_FILE, JSON.stringify({ userId }, null, 2));
|
|
282
|
+
return userId;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function slugifySlot(name: string): string {
|
|
287
|
+
return (
|
|
288
|
+
name
|
|
289
|
+
.toLowerCase()
|
|
290
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
291
|
+
.replace(/-+/g, "-")
|
|
292
|
+
.replace(/^-|-$/g, "")
|
|
293
|
+
.slice(0, 14) || "buddy"
|
|
294
|
+
);
|
|
295
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import type { Achievement } from "../../core/achievements.ts";
|
|
3
|
+
import type { Companion, ReactionState } from "../../core/model.ts";
|
|
4
|
+
import { renderAchievementsSummary, renderBuddyStatus, renderBuddyWidget } from "./renderers.ts";
|
|
5
|
+
import { PiBuddyStorage } from "./storage.ts";
|
|
6
|
+
|
|
7
|
+
export class PiBuddyUI {
|
|
8
|
+
constructor(private readonly storage: PiBuddyStorage) {}
|
|
9
|
+
|
|
10
|
+
refresh(
|
|
11
|
+
ctx: ExtensionContext,
|
|
12
|
+
companion: Companion,
|
|
13
|
+
reaction?: ReactionState | null,
|
|
14
|
+
achievements: Achievement[] = [],
|
|
15
|
+
): void {
|
|
16
|
+
const muted = this.storage.isMuted();
|
|
17
|
+
const status = renderBuddyStatus(companion, muted ? null : reaction);
|
|
18
|
+
ctx.ui.setStatus("buddy", muted ? `${status} [muted]` : status);
|
|
19
|
+
ctx.ui.setWidget(
|
|
20
|
+
"buddy",
|
|
21
|
+
renderBuddyWidget(companion, muted ? null : reaction, achievements),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
notifyAchievements(ctx: ExtensionContext, achievements: Achievement[]): void {
|
|
26
|
+
for (const achievement of achievements) {
|
|
27
|
+
ctx.ui.notify(`Unlocked: ${achievement.icon} ${achievement.name}`, "info");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
showAchievements(
|
|
32
|
+
ctx: ExtensionContext,
|
|
33
|
+
unlocked: Array<{ achievement: Achievement; unlockedAt: number; slot?: string }>,
|
|
34
|
+
remaining: Achievement[],
|
|
35
|
+
): void {
|
|
36
|
+
ctx.ui.setWidget("buddy", renderAchievementsSummary(unlocked, remaining));
|
|
37
|
+
ctx.ui.notify(`Achievements: ${unlocked.length} unlocked`, "info");
|
|
38
|
+
}
|
|
39
|
+
}
|
package/cli/index.ts
CHANGED
|
@@ -17,40 +17,40 @@ const command = args[0] || "install";
|
|
|
17
17
|
|
|
18
18
|
switch (command) {
|
|
19
19
|
case "install":
|
|
20
|
-
await import("
|
|
20
|
+
await import("../adapters/claude/install/install.ts");
|
|
21
21
|
break;
|
|
22
22
|
case "show":
|
|
23
|
-
await import("
|
|
23
|
+
await import("../adapters/claude/install/show.ts");
|
|
24
24
|
break;
|
|
25
25
|
case "pick":
|
|
26
|
-
await import("
|
|
26
|
+
await import("../adapters/claude/install/pick.ts");
|
|
27
27
|
break;
|
|
28
28
|
case "hunt":
|
|
29
|
-
await import("
|
|
29
|
+
await import("../adapters/claude/install/hunt.ts");
|
|
30
30
|
break;
|
|
31
31
|
case "uninstall":
|
|
32
|
-
await import("
|
|
32
|
+
await import("../adapters/claude/install/uninstall.ts");
|
|
33
33
|
break;
|
|
34
34
|
case "verify":
|
|
35
35
|
await import("./verify.ts");
|
|
36
36
|
break;
|
|
37
37
|
case "doctor":
|
|
38
|
-
await import("
|
|
38
|
+
await import("../adapters/claude/install/doctor.ts");
|
|
39
39
|
break;
|
|
40
40
|
case "test-statusline":
|
|
41
|
-
await import("
|
|
41
|
+
await import("../adapters/claude/install/test-statusline.ts");
|
|
42
42
|
break;
|
|
43
43
|
case "backup":
|
|
44
|
-
await import("
|
|
44
|
+
await import("../adapters/claude/install/backup.ts");
|
|
45
45
|
break;
|
|
46
46
|
case "settings":
|
|
47
|
-
await import("
|
|
47
|
+
await import("../adapters/claude/install/settings.ts");
|
|
48
48
|
break;
|
|
49
49
|
case "disable":
|
|
50
|
-
await import("
|
|
50
|
+
await import("../adapters/claude/install/disable.ts");
|
|
51
51
|
break;
|
|
52
52
|
case "enable":
|
|
53
|
-
await import("
|
|
53
|
+
await import("../adapters/claude/install/install.ts");
|
|
54
54
|
break;
|
|
55
55
|
case "help":
|
|
56
56
|
case "--help":
|
package/cli/verify.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* claude-buddy verify — show what buddy a user ID produces
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { generateBones, renderBuddy, STAT_NAMES } from "../
|
|
6
|
-
import { resolveUserId } from "../
|
|
5
|
+
import { generateBones, renderBuddy, STAT_NAMES } from "../core/engine.ts";
|
|
6
|
+
import { resolveUserId } from "../adapters/claude/storage/identity.ts";
|
|
7
7
|
|
|
8
8
|
const userId = process.argv[3] || resolveUserId();
|
|
9
9
|
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { UnlockedAchievement } from "./model.ts";
|
|
2
|
+
|
|
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
|
+
|
|
23
|
+
export const GLOBAL_KEYS: (keyof GlobalCounters)[] = [
|
|
24
|
+
"errors_seen", "tests_failed", "large_diffs",
|
|
25
|
+
"sessions", "commands_run", "days_active", "turns",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export const SLOT_KEYS: (keyof SlotCounters)[] = [
|
|
29
|
+
"pets", "reactions_given",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export const COUNTER_KEYS: (keyof EventCounters)[] = [
|
|
33
|
+
"errors_seen", "tests_failed", "large_diffs", "turns", "pets",
|
|
34
|
+
"sessions", "reactions_given", "commands_run", "days_active",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export const EMPTY_GLOBAL: GlobalCounters = {
|
|
38
|
+
errors_seen: 0, tests_failed: 0, large_diffs: 0,
|
|
39
|
+
sessions: 0, commands_run: 0, days_active: 0, turns: 0,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const EMPTY_SLOT: SlotCounters = {
|
|
43
|
+
pets: 0, reactions_given: 0,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export interface Achievement {
|
|
47
|
+
id: string;
|
|
48
|
+
name: string;
|
|
49
|
+
description: string;
|
|
50
|
+
icon: string;
|
|
51
|
+
check: (events: EventCounters) => boolean;
|
|
52
|
+
secret: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const ACHIEVEMENTS: Achievement[] = [
|
|
56
|
+
{
|
|
57
|
+
id: "first_steps",
|
|
58
|
+
name: "First Steps",
|
|
59
|
+
description: "Hatch your buddy for the first time",
|
|
60
|
+
icon: "🌟",
|
|
61
|
+
check: () => true,
|
|
62
|
+
secret: false,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: "good_boy",
|
|
66
|
+
name: "Good Buddy",
|
|
67
|
+
description: "Pet your companion 10 times",
|
|
68
|
+
icon: "🧹",
|
|
69
|
+
check: (e) => e.pets >= 10,
|
|
70
|
+
secret: false,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "best_friend",
|
|
74
|
+
name: "Best Friend",
|
|
75
|
+
description: "Pet your companion 50 times",
|
|
76
|
+
icon: "❤️",
|
|
77
|
+
check: (e) => e.pets >= 50,
|
|
78
|
+
secret: false,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: "bug_spotter",
|
|
82
|
+
name: "Bug Spotter",
|
|
83
|
+
description: "Witness your first error together",
|
|
84
|
+
icon: "🐛",
|
|
85
|
+
check: (e) => e.errors_seen >= 1,
|
|
86
|
+
secret: false,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: "error_whisperer",
|
|
90
|
+
name: "Error Whisperer",
|
|
91
|
+
description: "Survive 25 errors as a team",
|
|
92
|
+
icon: "🔧",
|
|
93
|
+
check: (e) => e.errors_seen >= 25,
|
|
94
|
+
secret: false,
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: "battle_scarred",
|
|
98
|
+
name: "Battle-Scarred",
|
|
99
|
+
description: "Survive 100 errors together",
|
|
100
|
+
icon: "💀",
|
|
101
|
+
check: (e) => e.errors_seen >= 100,
|
|
102
|
+
secret: true,
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: "test_witness",
|
|
106
|
+
name: "Test Witness",
|
|
107
|
+
description: "See your first test failure",
|
|
108
|
+
icon: "❌",
|
|
109
|
+
check: (e) => e.tests_failed >= 1,
|
|
110
|
+
secret: false,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: "test_veteran",
|
|
114
|
+
name: "Test Veteran",
|
|
115
|
+
description: "Witness 50 test failures",
|
|
116
|
+
icon: "📊",
|
|
117
|
+
check: (e) => e.tests_failed >= 50,
|
|
118
|
+
secret: false,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: "big_mover",
|
|
122
|
+
name: "Big Mover",
|
|
123
|
+
description: "Make a diff with 80+ lines",
|
|
124
|
+
icon: "📦",
|
|
125
|
+
check: (e) => e.large_diffs >= 1,
|
|
126
|
+
secret: false,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
id: "refactor_machine",
|
|
130
|
+
name: "Refactor Machine",
|
|
131
|
+
description: "Make 10 large diffs",
|
|
132
|
+
icon: "🔨",
|
|
133
|
+
check: (e) => e.large_diffs >= 10,
|
|
134
|
+
secret: false,
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
id: "chatterbox",
|
|
138
|
+
name: "Chatterbox",
|
|
139
|
+
description: "Your buddy reacts 100 times",
|
|
140
|
+
icon: "💬",
|
|
141
|
+
check: (e) => e.reactions_given >= 100,
|
|
142
|
+
secret: false,
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: "week_streak",
|
|
146
|
+
name: "Week Streak",
|
|
147
|
+
description: "Code with your buddy for 7 days",
|
|
148
|
+
icon: "🔥",
|
|
149
|
+
check: (e) => e.days_active >= 7,
|
|
150
|
+
secret: false,
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
id: "month_streak",
|
|
154
|
+
name: "Month Streak",
|
|
155
|
+
description: "Code with your buddy for 30 days",
|
|
156
|
+
icon: "👑",
|
|
157
|
+
check: (e) => e.days_active >= 30,
|
|
158
|
+
secret: true,
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
id: "power_user",
|
|
162
|
+
name: "Power User",
|
|
163
|
+
description: "Run 50 buddy commands",
|
|
164
|
+
icon: "⚡",
|
|
165
|
+
check: (e) => e.commands_run >= 50,
|
|
166
|
+
secret: false,
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
id: "dedicated",
|
|
170
|
+
name: "Dedicated Companion",
|
|
171
|
+
description: "Complete 200 turns together",
|
|
172
|
+
icon: "🏅",
|
|
173
|
+
check: (e) => e.turns >= 200,
|
|
174
|
+
secret: false,
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
id: "thousand_turns",
|
|
178
|
+
name: "Thousand Turns",
|
|
179
|
+
description: "Reach 1000 turns together",
|
|
180
|
+
icon: "🎖",
|
|
181
|
+
check: (e) => e.turns >= 1000,
|
|
182
|
+
secret: true,
|
|
183
|
+
},
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
export function getUnlockedAchievements(
|
|
187
|
+
events: EventCounters,
|
|
188
|
+
unlockedIds: Set<string>,
|
|
189
|
+
): Achievement[] {
|
|
190
|
+
const newlyUnlocked: Achievement[] = [];
|
|
191
|
+
for (const ach of ACHIEVEMENTS) {
|
|
192
|
+
if (unlockedIds.has(ach.id)) continue;
|
|
193
|
+
if (ach.check(events)) newlyUnlocked.push(ach);
|
|
194
|
+
}
|
|
195
|
+
return newlyUnlocked;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function isAchievementUnlocked(
|
|
199
|
+
id: string,
|
|
200
|
+
unlocked: UnlockedAchievement[],
|
|
201
|
+
): boolean {
|
|
202
|
+
return unlocked.some((a) => a.id === id);
|
|
203
|
+
}
|