@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,173 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { BuddyCommandService } from "../../core/command-service.ts";
|
|
3
|
+
import { renderBuddyStats } from "./renderers.ts";
|
|
4
|
+
import { PiBuddyStorage, slugifySlot } from "./storage.ts";
|
|
5
|
+
import { PiBuddyUI } from "./ui.ts";
|
|
6
|
+
|
|
7
|
+
interface RegisterBuddyCommandsDeps {
|
|
8
|
+
service: BuddyCommandService;
|
|
9
|
+
storage: PiBuddyStorage;
|
|
10
|
+
ui: PiBuddyUI;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function registerBuddyCommands(pi: ExtensionAPI, deps: RegisterBuddyCommandsDeps): void {
|
|
14
|
+
pi.registerCommand("buddy", {
|
|
15
|
+
description: "Manage your coding buddy",
|
|
16
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
17
|
+
const input = args.trim();
|
|
18
|
+
const [command, ...rest] = input ? input.split(/\s+/) : [];
|
|
19
|
+
const remainder = rest.join(" ").trim();
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
switch (command) {
|
|
23
|
+
case undefined:
|
|
24
|
+
case "show": {
|
|
25
|
+
const result = deps.service.ensureCompanion();
|
|
26
|
+
deps.service.incrementCommandsRun();
|
|
27
|
+
const reaction = deps.storage.loadLatest();
|
|
28
|
+
deps.ui.refresh(ctx, result.companion, reaction, result.achievements);
|
|
29
|
+
if (result.created) {
|
|
30
|
+
ctx.ui.notify(`Meet ${result.companion.name}!`, "info");
|
|
31
|
+
} else {
|
|
32
|
+
ctx.ui.notify(deps.service.formatCompanionSummary(result.companion), "info");
|
|
33
|
+
}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
case "pet": {
|
|
38
|
+
deps.service.incrementCommandsRun();
|
|
39
|
+
const result = deps.service.petBuddy();
|
|
40
|
+
deps.ui.refresh(ctx, result.companion, result.state, result.achievements);
|
|
41
|
+
ctx.ui.notify(`${result.companion.name} seems pleased.`, "info");
|
|
42
|
+
deps.ui.notifyAchievements(ctx, result.achievements);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
case "stats": {
|
|
47
|
+
deps.service.incrementCommandsRun();
|
|
48
|
+
const result = deps.service.ensureCompanion();
|
|
49
|
+
ctx.ui.setWidget("buddy", renderBuddyStats(result.companion));
|
|
50
|
+
ctx.ui.notify(`${result.companion.name}'s stats`, "info");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
case "rename": {
|
|
55
|
+
if (!remainder) throw new Error("Usage: /buddy rename <name>");
|
|
56
|
+
deps.service.incrementCommandsRun();
|
|
57
|
+
const companion = deps.service.renameBuddy(remainder);
|
|
58
|
+
deps.ui.refresh(ctx, companion, deps.storage.loadLatest());
|
|
59
|
+
ctx.ui.notify(`Buddy renamed to ${companion.name}.`, "info");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
case "personality": {
|
|
64
|
+
if (!remainder) throw new Error("Usage: /buddy personality <text>");
|
|
65
|
+
deps.service.incrementCommandsRun();
|
|
66
|
+
const companion = deps.service.setPersonality(remainder);
|
|
67
|
+
deps.ui.refresh(ctx, companion, deps.storage.loadLatest());
|
|
68
|
+
ctx.ui.notify(`${companion.name}'s personality was updated.`, "info");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
case "off": {
|
|
73
|
+
deps.service.incrementCommandsRun();
|
|
74
|
+
deps.storage.setMuted(true);
|
|
75
|
+
const result = deps.service.ensureCompanion();
|
|
76
|
+
deps.ui.refresh(ctx, result.companion, null);
|
|
77
|
+
ctx.ui.notify(`${result.companion.name} goes quiet.`, "info");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
case "on": {
|
|
82
|
+
deps.service.incrementCommandsRun();
|
|
83
|
+
deps.storage.setMuted(false);
|
|
84
|
+
const result = deps.service.recordTurn();
|
|
85
|
+
deps.ui.refresh(ctx, result.companion, result.state, result.achievements);
|
|
86
|
+
ctx.ui.notify(`${result.companion.name} is back.`, "info");
|
|
87
|
+
deps.ui.notifyAchievements(ctx, result.achievements);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
case "save": {
|
|
92
|
+
deps.service.incrementCommandsRun();
|
|
93
|
+
const result = deps.service.saveBuddy(remainder || undefined);
|
|
94
|
+
deps.ui.refresh(ctx, result.companion, deps.storage.loadLatest());
|
|
95
|
+
ctx.ui.notify(`Saved ${result.companion.name} to [${result.slot}].`, "info");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
case "summon": {
|
|
100
|
+
deps.service.incrementCommandsRun();
|
|
101
|
+
const result = deps.service.summonBuddy(remainder || undefined);
|
|
102
|
+
if (!result) throw new Error("No saved buddy found for that slot.");
|
|
103
|
+
deps.ui.refresh(ctx, result.companion, deps.storage.loadLatest());
|
|
104
|
+
ctx.ui.notify(`${result.companion.name} arrives from [${result.slot}].`, "info");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
case "list": {
|
|
109
|
+
deps.service.incrementCommandsRun();
|
|
110
|
+
const active = deps.storage.loadActiveSlot();
|
|
111
|
+
const companions = deps.service.listCompanions();
|
|
112
|
+
if (companions.length === 0) {
|
|
113
|
+
ctx.ui.notify("No saved buddies yet.", "info");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
ctx.ui.setWidget(
|
|
117
|
+
"buddy",
|
|
118
|
+
companions.map(({ slot, companion }) => {
|
|
119
|
+
const marker = slot === active ? " ← active" : "";
|
|
120
|
+
return `${companion.name} [${slot}] — ${companion.bones.rarity} ${companion.bones.species}${marker}`;
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
ctx.ui.notify(`Saved buddies: ${companions.length}`, "info");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
case "dismiss": {
|
|
128
|
+
if (!remainder) throw new Error("Usage: /buddy dismiss <slot>");
|
|
129
|
+
deps.service.incrementCommandsRun();
|
|
130
|
+
const result = deps.service.dismissBuddy(remainder);
|
|
131
|
+
const active = deps.service.ensureCompanion();
|
|
132
|
+
deps.ui.refresh(ctx, active.companion, deps.storage.loadLatest());
|
|
133
|
+
ctx.ui.notify(`Dismissed ${result.companion.name} [${result.slot}].`, "info");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
case "achievements": {
|
|
138
|
+
deps.service.incrementCommandsRun();
|
|
139
|
+
const activeSlot = deps.storage.loadActiveSlot() ?? undefined;
|
|
140
|
+
const progress = deps.service.getAchievementProgress(activeSlot);
|
|
141
|
+
deps.ui.showAchievements(ctx, progress.unlocked, progress.remaining);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
case "help":
|
|
146
|
+
default: {
|
|
147
|
+
ctx.ui.setWidget("buddy", [
|
|
148
|
+
"/buddy",
|
|
149
|
+
"/buddy pet",
|
|
150
|
+
"/buddy stats",
|
|
151
|
+
"/buddy rename <name>",
|
|
152
|
+
"/buddy personality <text>",
|
|
153
|
+
"/buddy off | on",
|
|
154
|
+
"/buddy save [slot]",
|
|
155
|
+
"/buddy summon [slot]",
|
|
156
|
+
"/buddy list",
|
|
157
|
+
"/buddy dismiss <slot>",
|
|
158
|
+
"/buddy achievements",
|
|
159
|
+
]);
|
|
160
|
+
ctx.ui.notify("Buddy commands ready.", "info");
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch (error) {
|
|
165
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
166
|
+
ctx.ui.notify(message, "error");
|
|
167
|
+
if (command === "save" && remainder) {
|
|
168
|
+
ctx.ui.notify(`Try a different slot, for example: ${slugifySlot(remainder)}-2`, "info");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
InputEvent,
|
|
5
|
+
InputEventResult,
|
|
6
|
+
SessionStartEvent,
|
|
7
|
+
ToolResultEvent,
|
|
8
|
+
TurnEndEvent,
|
|
9
|
+
} from "@mariozechner/pi-coding-agent";
|
|
10
|
+
import { isBashToolResult } from "@mariozechner/pi-coding-agent";
|
|
11
|
+
import { BuddyCommandService } from "../../core/command-service.ts";
|
|
12
|
+
import { PiBuddyStorage } from "./storage.ts";
|
|
13
|
+
import { PiBuddyUI } from "./ui.ts";
|
|
14
|
+
|
|
15
|
+
interface RegisterBuddyEventsDeps {
|
|
16
|
+
service: BuddyCommandService;
|
|
17
|
+
storage: PiBuddyStorage;
|
|
18
|
+
ui: PiBuddyUI;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function registerBuddyEvents(pi: ExtensionAPI, deps: RegisterBuddyEventsDeps): void {
|
|
22
|
+
pi.on("session_start", async (_event: SessionStartEvent, ctx: ExtensionContext) => {
|
|
23
|
+
const result = deps.service.ensureCompanion();
|
|
24
|
+
deps.ui.refresh(ctx, result.companion, deps.storage.loadLatest(), result.achievements);
|
|
25
|
+
if (result.created) {
|
|
26
|
+
ctx.ui.notify(`A new buddy hatched: ${result.companion.name}`, "info");
|
|
27
|
+
deps.ui.notifyAchievements(ctx, result.achievements);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
pi.on("input", async (event: InputEvent, ctx: ExtensionContext): Promise<InputEventResult> => {
|
|
32
|
+
if (event.source === "extension" || deps.storage.isMuted()) {
|
|
33
|
+
return { action: "continue" };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const companion = deps.service.ensureCompanion().companion;
|
|
37
|
+
const pattern = new RegExp(`\\b${escapeRegExp(companion.name)}\\b`, "i");
|
|
38
|
+
if (!pattern.test(event.text)) {
|
|
39
|
+
return { action: "continue" };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!shouldEmitPassiveReaction(deps.storage)) {
|
|
43
|
+
return { action: "continue" };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const result = deps.service.recordNameMention();
|
|
47
|
+
deps.ui.refresh(ctx, result.companion, result.state, result.achievements);
|
|
48
|
+
deps.ui.notifyAchievements(ctx, result.achievements);
|
|
49
|
+
return { action: "continue" };
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
pi.on("tool_result", async (event: ToolResultEvent, ctx: ExtensionContext) => {
|
|
53
|
+
if (deps.storage.isMuted() || !shouldEmitPassiveReaction(deps.storage)) return;
|
|
54
|
+
|
|
55
|
+
const text = extractToolText(event);
|
|
56
|
+
let result:
|
|
57
|
+
| ReturnType<BuddyCommandService["recordToolError"]>
|
|
58
|
+
| ReturnType<BuddyCommandService["recordTestFailure"]>
|
|
59
|
+
| ReturnType<BuddyCommandService["recordLargeDiff"]>
|
|
60
|
+
| undefined;
|
|
61
|
+
|
|
62
|
+
if (event.isError) {
|
|
63
|
+
result = deps.service.recordToolError(undefined, firstLineNumber(text));
|
|
64
|
+
} else if (looksLikeTestFailure(text)) {
|
|
65
|
+
result = deps.service.recordTestFailure(undefined, extractFailureCount(text));
|
|
66
|
+
} else {
|
|
67
|
+
const diffLines = extractLargeDiffLines(text);
|
|
68
|
+
if (diffLines >= 80) {
|
|
69
|
+
result = deps.service.recordLargeDiff(diffLines);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!result) return;
|
|
74
|
+
deps.ui.refresh(ctx, result.companion, result.state, result.achievements);
|
|
75
|
+
deps.ui.notifyAchievements(ctx, result.achievements);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
pi.on("turn_end", async (_event: TurnEndEvent, ctx: ExtensionContext) => {
|
|
79
|
+
const result = deps.service.recordTurn();
|
|
80
|
+
if (deps.storage.isMuted()) {
|
|
81
|
+
deps.ui.refresh(ctx, result.companion, null, result.achievements);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!shouldEmitPassiveReaction(deps.storage)) {
|
|
86
|
+
deps.ui.refresh(ctx, result.companion, deps.storage.loadLatest(), result.achievements);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (Math.random() < 0.35) {
|
|
91
|
+
deps.ui.refresh(ctx, result.companion, result.state, result.achievements);
|
|
92
|
+
deps.ui.notifyAchievements(ctx, result.achievements);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
deps.ui.refresh(ctx, result.companion, deps.storage.loadLatest(), result.achievements);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function shouldEmitPassiveReaction(storage: PiBuddyStorage): boolean {
|
|
101
|
+
const cooldownSeconds = storage.loadPiConfig().commentCooldown;
|
|
102
|
+
if (cooldownSeconds <= 0) return true;
|
|
103
|
+
const latest = storage.loadLatest();
|
|
104
|
+
if (!latest?.timestamp) return true;
|
|
105
|
+
return Date.now() - latest.timestamp >= cooldownSeconds * 1000;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function extractToolText(event: ToolResultEvent): string {
|
|
109
|
+
const fromContent = event.content
|
|
110
|
+
.filter((item): item is Extract<(typeof event.content)[number], { type: "text" }> => item.type === "text")
|
|
111
|
+
.map((item) => item.text)
|
|
112
|
+
.join("\n");
|
|
113
|
+
|
|
114
|
+
if (fromContent.trim()) return fromContent;
|
|
115
|
+
if (isBashToolResult(event)) {
|
|
116
|
+
return "";
|
|
117
|
+
}
|
|
118
|
+
return "";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function looksLikeTestFailure(text: string): boolean {
|
|
122
|
+
return /(FAIL|failed|failing|test(s)? failed|not ok)/i.test(text);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function extractFailureCount(text: string): number | undefined {
|
|
126
|
+
const match = text.match(/(\d+)\s+(tests? )?(failed|failing)/i);
|
|
127
|
+
return match ? Number(match[1]) : undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function extractLargeDiffLines(text: string): number {
|
|
131
|
+
const statMatch = text.match(/(\d+)\s+insertions?\(\+\).*?(\d+)\s+deletions?\(-\)/is);
|
|
132
|
+
if (statMatch) {
|
|
133
|
+
return Number(statMatch[1]) + Number(statMatch[2]);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const patchLines = text
|
|
137
|
+
.split(/\r?\n/)
|
|
138
|
+
.filter((line) => line.startsWith("+") || line.startsWith("-"))
|
|
139
|
+
.length;
|
|
140
|
+
return patchLines;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function firstLineNumber(text: string): number | undefined {
|
|
144
|
+
const match = text.match(/line\s+(\d+)/i);
|
|
145
|
+
return match ? Number(match[1]) : undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function escapeRegExp(value: string): string {
|
|
149
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
150
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { IdentityProvider } from "../../core/ports.ts";
|
|
2
|
+
import { PiBuddyStorage } from "./storage.ts";
|
|
3
|
+
|
|
4
|
+
export class PiIdentityProvider implements IdentityProvider {
|
|
5
|
+
constructor(private readonly storage: PiBuddyStorage) {}
|
|
6
|
+
|
|
7
|
+
getStableUserId(): string {
|
|
8
|
+
return this.storage.ensureStableIdentity();
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { BuddyCommandService } from "../../core/command-service.ts";
|
|
3
|
+
import { PiIdentityProvider } from "./identity.ts";
|
|
4
|
+
import { registerBuddyCommands } from "./commands.ts";
|
|
5
|
+
import { registerBuddyEvents } from "./events.ts";
|
|
6
|
+
import { PiBuddyStorage } from "./storage.ts";
|
|
7
|
+
import { registerBuddyTools } from "./tools.ts";
|
|
8
|
+
import { PiBuddyUI } from "./ui.ts";
|
|
9
|
+
|
|
10
|
+
export default function registerPiBuddyExtension(pi: ExtensionAPI): void {
|
|
11
|
+
const storage = new PiBuddyStorage();
|
|
12
|
+
const identity = new PiIdentityProvider(storage);
|
|
13
|
+
const service = new BuddyCommandService({
|
|
14
|
+
identity,
|
|
15
|
+
buddies: storage,
|
|
16
|
+
reactions: storage,
|
|
17
|
+
config: storage,
|
|
18
|
+
events: storage,
|
|
19
|
+
});
|
|
20
|
+
const ui = new PiBuddyUI(storage);
|
|
21
|
+
|
|
22
|
+
registerBuddyCommands(pi, { service, storage, ui });
|
|
23
|
+
registerBuddyEvents(pi, { service, storage, ui });
|
|
24
|
+
registerBuddyTools(pi);
|
|
25
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { RARITY_STARS } from "../../core/engine.ts";
|
|
2
|
+
import { getArtFrame, HAT_ART } from "../../core/render-model.ts";
|
|
3
|
+
import type { Companion, ReactionState } from "../../core/model.ts";
|
|
4
|
+
import type { Achievement } from "../../core/achievements.ts";
|
|
5
|
+
|
|
6
|
+
function trimArt(line: string): string {
|
|
7
|
+
return line.replace(/\s+$/g, "");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function renderBuddyStatus(companion: Companion, reaction?: ReactionState | null): string {
|
|
11
|
+
const shiny = companion.bones.shiny ? " ✨" : "";
|
|
12
|
+
const stars = RARITY_STARS[companion.bones.rarity];
|
|
13
|
+
const suffix = reaction?.reaction ? ` — ${reaction.reaction}` : "";
|
|
14
|
+
return `${trimArt(getArtFrame(companion.bones.species, companion.bones.eye, Date.now())[0])} ${companion.name} ${stars}${shiny}${suffix}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function renderBuddyWidget(
|
|
18
|
+
companion: Companion,
|
|
19
|
+
reaction?: ReactionState | null,
|
|
20
|
+
achievements: Achievement[] = [],
|
|
21
|
+
): string[] {
|
|
22
|
+
const frame = Math.floor(Date.now() / 700);
|
|
23
|
+
const art = getArtFrame(companion.bones.species, companion.bones.eye, frame).map(trimArt);
|
|
24
|
+
const hat = companion.bones.hat === "none" ? [] : [trimArt(HAT_ART[companion.bones.hat])];
|
|
25
|
+
const stars = RARITY_STARS[companion.bones.rarity];
|
|
26
|
+
const shiny = companion.bones.shiny ? " ✨" : "";
|
|
27
|
+
const lines = [
|
|
28
|
+
`buddy · ${companion.name}`,
|
|
29
|
+
`${companion.bones.rarity} ${companion.bones.species} ${stars}${shiny}`,
|
|
30
|
+
"",
|
|
31
|
+
...hat,
|
|
32
|
+
...art,
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
if (reaction?.reaction) {
|
|
36
|
+
lines.push("", `“${reaction.reaction}”`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (achievements.length > 0) {
|
|
40
|
+
lines.push("", ...achievements.map((achievement) => `🏆 ${achievement.name}`));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return lines;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function renderBuddyStats(companion: Companion): string[] {
|
|
47
|
+
return [
|
|
48
|
+
`name: ${companion.name}`,
|
|
49
|
+
`species: ${companion.bones.species}`,
|
|
50
|
+
`rarity: ${companion.bones.rarity}`,
|
|
51
|
+
`peak: ${companion.bones.peak}`,
|
|
52
|
+
`dump: ${companion.bones.dump}`,
|
|
53
|
+
"",
|
|
54
|
+
...Object.entries(companion.bones.stats).map(([stat, value]) => `${stat.padEnd(9)} ${String(value).padStart(3)}`),
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function renderAchievementsSummary(
|
|
59
|
+
unlocked: Array<{ achievement: Achievement; unlockedAt: number; slot?: string }>,
|
|
60
|
+
remaining: Achievement[],
|
|
61
|
+
): string[] {
|
|
62
|
+
const lines = [`achievements unlocked: ${unlocked.length}`];
|
|
63
|
+
|
|
64
|
+
if (unlocked.length > 0) {
|
|
65
|
+
lines.push("", ...unlocked.map(({ achievement }) => `${achievement.icon} ${achievement.name}`));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (remaining.length > 0) {
|
|
69
|
+
lines.push("", `next up: ${remaining[0].icon} ${remaining[0].name}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return lines;
|
|
73
|
+
}
|