@ramarivera/coding-buddy 0.4.0-alpha.1

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/server/art.ts ADDED
@@ -0,0 +1,376 @@
1
+ /**
2
+ * ASCII art for all 18 buddy species
3
+ *
4
+ * Each species has 3 animation frames (idle variations).
5
+ * Each frame is 5 lines, ~12 chars wide.
6
+ * {E} is replaced with the eye character at render time.
7
+ */
8
+
9
+ import type { Species, Eye, Hat, Rarity, StatName, BuddyBones } from "./engine.ts";
10
+
11
+ // ─── Species art: 3 frames × 5 lines each ──────────────────────────────────
12
+
13
+ export const SPECIES_ART: Record<Species, string[][]> = {
14
+ duck: [
15
+ [" ", " __ ", " <({E} )___ ", " ( ._> ", " `--' "],
16
+ [" ", " __ ", " <({E} )___ ", " ( ._> ", " `--'~ "],
17
+ [" ", " __ ", " <({E} )___ ", " ( .__> ", " `--' "],
18
+ ],
19
+ goose: [
20
+ [" ", " ({E}> ", " || ", " _(__)_ ", " ^^^^ "],
21
+ [" ", " ({E}> ", " || ", " _(__)_ ", " ^^^^ "],
22
+ [" ", " ({E}>> ", " || ", " _(__)_ ", " ^^^^ "],
23
+ ],
24
+ blob: [
25
+ [" ", " .----. ", " ( {E} {E} ) ", " ( ) ", " `----' "],
26
+ [" ", " .------. ", " ( {E} {E} ) ", " ( ) ", " `------' "],
27
+ [" ", " .--. ", " ({E} {E}) ", " ( ) ", " `--' "],
28
+ ],
29
+ cat: [
30
+ [" ", " /\\_/\\ ", " ( {E} {E}) ", " ( \u03c9 ) ", " (\")_(\") "],
31
+ [" ", " /\\_/\\ ", " ( {E} {E}) ", " ( \u03c9 ) ", " (\")_(\")~ "],
32
+ [" ", " /\\-/\\ ", " ( {E} {E}) ", " ( \u03c9 ) ", " (\")_(\") "],
33
+ ],
34
+ dragon: [
35
+ [" ", " /^\\ /^\\ ", " < {E} {E} > ", " ( ~~ ) ", " `-vvvv-' "],
36
+ [" ", " /^\\ /^\\ ", " < {E} {E} > ", " ( ) ", " `-vvvv-' "],
37
+ [" ~ ~ ", " /^\\ /^\\ ", " < {E} {E} > ", " ( ~~ ) ", " `-vvvv-' "],
38
+ ],
39
+ octopus: [
40
+ [" ", " .----. ", " ( {E} {E} ) ", " (______) ", " /\\/\\/\\/\\ "],
41
+ [" ", " .----. ", " ( {E} {E} ) ", " (______) ", " \\/\\/\\/\\/ "],
42
+ [" o ", " .----. ", " ( {E} {E} ) ", " (______) ", " /\\/\\/\\/\\ "],
43
+ ],
44
+ owl: [
45
+ [" ", " /\\ /\\ ", " (({E})({E})) ", " ( >< ) ", " `----' "],
46
+ [" ", " /\\ /\\ ", " (({E})({E})) ", " ( >< ) ", " .----. "],
47
+ [" ", " /\\ /\\ ", " (({E})(-)) ", " ( >< ) ", " `----' "],
48
+ ],
49
+ penguin: [
50
+ [" ", " .---. ", " ({E}>{E}) ", " /( )\\ ", " `---' "],
51
+ [" ", " .---. ", " ({E}>{E}) ", " |( )| ", " `---' "],
52
+ [" .---. ", " ({E}>{E}) ", " /( )\\ ", " `---' ", " ~ ~ "],
53
+ ],
54
+ turtle: [
55
+ [" ", " _,--._ ", " ( {E} {E} ) ", " /[______]\\ ", " `` `` "],
56
+ [" ", " _,--._ ", " ( {E} {E} ) ", " /[______]\\ ", " `` `` "],
57
+ [" ", " _,--._ ", " ( {E} {E} ) ", " /[======]\\ ", " `` `` "],
58
+ ],
59
+ snail: [
60
+ [" ", " {E} .--. ", " \\ ( @ ) ", " \\_`--' ", " ~~~~~~~ "],
61
+ [" ", " {E} .--. ", " | ( @ ) ", " \\_`--' ", " ~~~~~~~ "],
62
+ [" ", " {E} .--. ", " \\ ( @ ) ", " \\_`--' ", " ~~~~~~ "],
63
+ ],
64
+ ghost: [
65
+ [" ", " .----. ", " / {E} {E} \\ ", " | | ", " ~`~``~`~ "],
66
+ [" ", " .----. ", " / {E} {E} \\ ", " | | ", " `~`~~`~` "],
67
+ [" ~ ~ ", " .----. ", " / {E} {E} \\ ", " | | ", " ~~`~~`~~ "],
68
+ ],
69
+ axolotl: [
70
+ [" ", "}~(______)~{", "}~({E} .. {E})~{", " ( .--. ) ", " (_/ \\_) "],
71
+ [" ", "~}(______){~", "~}({E} .. {E}){~", " ( .--. ) ", " (_/ \\_) "],
72
+ [" ", "}~(______)~{", "}~({E} .. {E})~{", " ( -- ) ", " ~_/ \\_~ "],
73
+ ],
74
+ capybara: [
75
+ [" ", " n______n ", " ( {E} {E} ) ", " ( oo ) ", " `------' "],
76
+ [" ", " n______n ", " ( {E} {E} ) ", " ( Oo ) ", " `------' "],
77
+ [" ~ ~ ", " u______n ", " ( {E} {E} ) ", " ( oo ) ", " `------' "],
78
+ ],
79
+ cactus: [
80
+ [" ", " n ____ n ", " | |{E} {E}| | ", " |_| |_| ", " | | "],
81
+ [" ", " ____ ", " n |{E} {E}| n ", " |_| |_| ", " | | "],
82
+ [" n n ", " | ____ | ", " | |{E} {E}| | ", " |_| |_| ", " | | "],
83
+ ],
84
+ robot: [
85
+ [" ", " .[||]. ", " [ {E} {E} ] ", " [ ==== ] ", " `------' "],
86
+ [" ", " .[||]. ", " [ {E} {E} ] ", " [ -==- ] ", " `------' "],
87
+ [" * ", " .[||]. ", " [ {E} {E} ] ", " [ ==== ] ", " `------' "],
88
+ ],
89
+ rabbit: [
90
+ [" ", " (\\__/) ", " ( {E} {E} ) ", " =( .. )= ", " (\")__(\")" ],
91
+ [" ", " (|__/) ", " ( {E} {E} ) ", " =( .. )= ", " (\")__(\")" ],
92
+ [" ", " (\\__/) ", " ( {E} {E} ) ", " =( . . )= ", " (\")__(\")" ],
93
+ ],
94
+ mushroom: [
95
+ [" ", " .-o-OO-o-. ", "(__________)"," |{E} {E}| ", " |____| "],
96
+ [" ", " .-O-oo-O-. ", "(__________)"," |{E} {E}| ", " |____| "],
97
+ [" . o . ", " .-o-OO-o-. ", "(__________)"," |{E} {E}| ", " |____| "],
98
+ ],
99
+ chonk: [
100
+ [" ", " /\\ /\\ ", " ( {E} {E} ) ", " ( .. ) ", " `------' "],
101
+ [" ", " /\\ /| ", " ( {E} {E} ) ", " ( .. ) ", " `------' "],
102
+ [" ", " /\\ /\\ ", " ( {E} {E} ) ", " ( .. ) ", " `------'~ "],
103
+ ],
104
+ };
105
+
106
+ // ─── Hat art ────────────────────────────────────────────────────────────────
107
+
108
+ export const HAT_ART: Record<Hat, string> = {
109
+ none: "",
110
+ crown: " \\^^^/ ",
111
+ tophat: " [___] ",
112
+ propeller: " -+- ",
113
+ halo: " ( ) ",
114
+ wizard: " /^\\ ",
115
+ beanie: " (___) ",
116
+ tinyduck: " ,> ",
117
+ };
118
+
119
+ // ─── Rarity ANSI colors ────────────────────────────────────────────────────
120
+
121
+ const RARITY_COLOR: Record<Rarity, string> = {
122
+ common: "\x1b[38;2;153;153;153m", // inactive rgb(153,153,153)
123
+ uncommon: "\x1b[38;2;78;186;101m", // success rgb(78,186,101)
124
+ rare: "\x1b[38;2;177;185;249m", // permission rgb(177,185,249)
125
+ epic: "\x1b[38;2;175;135;255m", // autoAccept rgb(175,135,255)
126
+ legendary: "\x1b[38;2;255;193;7m", // warning rgb(255,193,7)
127
+ };
128
+
129
+ const SHINY_COLOR = "\x1b[93m"; // bright yellow
130
+ const BOLD = "\x1b[1m";
131
+ const DIM = "\x1b[2m";
132
+ const NC = "\x1b[0m";
133
+
134
+ export const RARITY_STARS: Record<Rarity, string> = {
135
+ common: "\u2605",
136
+ uncommon: "\u2605\u2605",
137
+ rare: "\u2605\u2605\u2605",
138
+ epic: "\u2605\u2605\u2605\u2605",
139
+ legendary: "\u2605\u2605\u2605\u2605\u2605",
140
+ };
141
+
142
+ // ─── Display width helpers ──────────────────────────────────────────────────
143
+
144
+ function stripAnsi(s: string): string { return s.replace(/\x1b\[[^m]*m/g, ""); }
145
+
146
+ function charWidth(cp: number): number {
147
+ if (cp >= 0xFE00 && cp <= 0xFE0F) return 0;
148
+ if (cp === 0x200D) return 0;
149
+ if (cp >= 0x1F000) return 2;
150
+ if (cp === 0x2728) return 2; // ✨
151
+ if (cp >= 0x2600 && cp <= 0x27BF) return 1;
152
+ if (cp >= 0x2500 && cp <= 0x259F) return 1;
153
+ if (cp >= 0x3000 && cp <= 0x9FFF) return 2;
154
+ if (cp >= 0xFF01 && cp <= 0xFF60) return 2;
155
+ return 1;
156
+ }
157
+
158
+ function displayWidth(s: string): number {
159
+ let w = 0;
160
+ for (const ch of stripAnsi(s)) w += charWidth(ch.codePointAt(0)!);
161
+ return w;
162
+ }
163
+
164
+ /** Pad string with spaces to reach target display width */
165
+ function dpad(s: string, targetW: number): string {
166
+ const w = displayWidth(s);
167
+ return w < targetW ? s + " ".repeat(targetW - w) : s;
168
+ }
169
+
170
+ // ─── Render functions ───────────────────────────────────────────────────────
171
+
172
+ export function getArtFrame(species: Species, eye: Eye, frame: number = 0): string[] {
173
+ const frames = SPECIES_ART[species];
174
+ const f = frames[frame % frames.length];
175
+ return f.map((line) => line.replace(/\{E\}/g, eye));
176
+ }
177
+
178
+ export function renderCompanionCard(
179
+ bones: BuddyBones,
180
+ name: string,
181
+ personality: string,
182
+ reaction?: string,
183
+ frame: number = 0,
184
+ width: number = 40,
185
+ ): string {
186
+ const color = RARITY_COLOR[bones.rarity];
187
+ const stars = RARITY_STARS[bones.rarity];
188
+ const shiny = bones.shiny ? `${SHINY_COLOR}\u2728 ${NC}` : "";
189
+ const art = getArtFrame(bones.species, bones.eye, frame);
190
+
191
+ // Hat: replace first empty art line
192
+ const hatLine = HAT_ART[bones.hat];
193
+ if (hatLine && !art[0].trim()) {
194
+ art[0] = hatLine;
195
+ }
196
+
197
+ // Build the card
198
+ const W = Math.max(24, width);
199
+ const hr = "\u2500".repeat(W - 2);
200
+ const lines: string[] = [];
201
+
202
+ // Top border
203
+ lines.push(`${color}\u256d${hr}\u256e${NC}`);
204
+
205
+ // Inner width = W - 2 (borders), content area = W - 4 (borders + padding)
206
+ const innerW = W - 4;
207
+
208
+ // Species art (centered)
209
+ for (const artLine of art) {
210
+ if (!artLine.trim()) continue;
211
+ lines.push(`${color}\u2502${NC} ${dpad(artLine, innerW)}${color}\u2502${NC}`);
212
+ }
213
+
214
+ // Separator
215
+ lines.push(`${color}\u251c${"╌".repeat(W - 2)}\u2524${NC}`);
216
+
217
+ // Name + rarity
218
+ const nameStarsRaw = `${BOLD}${name}${NC} ${color}${stars}${NC}`;
219
+ lines.push(`${color}\u2502${NC} ${nameStarsRaw}${" ".repeat(Math.max(0, innerW - displayWidth(name) - 2 - displayWidth(stars)))}${color}\u2502${NC}`);
220
+
221
+ const rarityRaw = `${shiny}${color}${BOLD}${bones.rarity.toUpperCase()}${NC} ${bones.species}`;
222
+ const rarityVis = (bones.shiny ? 3 : 0) + bones.rarity.length + 1 + bones.species.length;
223
+ lines.push(`${color}\u2502${NC} ${rarityRaw}${" ".repeat(Math.max(0, innerW - rarityVis))}${color}\u2502${NC}`);
224
+
225
+ // Eye + Hat info
226
+ const cosmeticLine = `eye: ${bones.eye} hat: ${bones.hat}`;
227
+ lines.push(`${color}\u2502${NC} ${DIM}${cosmeticLine}${NC}${" ".repeat(Math.max(0, innerW - displayWidth(cosmeticLine)))}${color}\u2502${NC}`);
228
+
229
+ // Separator
230
+ lines.push(`${color}\u251c${"╌".repeat(W - 2)}\u2524${NC}`);
231
+
232
+ // Stats
233
+ const STAT_NAMES: StatName[] = ["DEBUGGING", "PATIENCE", "CHAOS", "WISDOM", "SNARK"];
234
+ for (const stat of STAT_NAMES) {
235
+ const val = bones.stats[stat];
236
+ const filled = Math.round(val / 10);
237
+ const bar = "\u2588".repeat(filled) + "\u2591".repeat(10 - filled);
238
+ const label = stat.slice(0, 3).padEnd(3);
239
+ const marker = stat === bones.peak ? " \u25b2" : stat === bones.dump ? " \u25bc" : " ";
240
+ const valStr = String(val).padStart(3);
241
+ const statLine = `${DIM}${label}${NC} ${bar} ${valStr}${marker}`;
242
+ const statVis = 3 + 1 + 10 + 1 + 3 + 2; // label + spaces + bar + val + marker = 20
243
+ lines.push(`${color}\u2502${NC} ${statLine}${" ".repeat(Math.max(0, innerW - statVis))}${color}\u2502${NC}`);
244
+ }
245
+
246
+ // Speech bubble (if reaction)
247
+ if (reaction) {
248
+ lines.push(`${color}\u251c${"╌".repeat(W - 2)}\u2524${NC}`);
249
+ const maxMsg = innerW - 3; // "💬 " prefix (💬 = 2 cols + space)
250
+ const msg = displayWidth(reaction) > maxMsg ? reaction.slice(0, maxMsg - 1) + "\u2026" : reaction;
251
+ const msgPad = Math.max(0, innerW - displayWidth(msg) - 3);
252
+ lines.push(`${color}\u2502${NC} \ud83d\udcac ${msg}${" ".repeat(msgPad)}${color}\u2502${NC}`);
253
+ }
254
+
255
+ // Personality
256
+ if (personality) {
257
+ lines.push(`${color}\u251c${"╌".repeat(W - 2)}\u2524${NC}`);
258
+ const words = personality.split(" ");
259
+ let line = "";
260
+ for (const word of words) {
261
+ if (displayWidth(line) + displayWidth(word) + 1 > innerW) {
262
+ lines.push(`${color}\u2502${NC} ${DIM}${dpad(line, innerW)}${NC}${color}\u2502${NC}`);
263
+ line = word;
264
+ } else {
265
+ line = line ? `${line} ${word}` : word;
266
+ }
267
+ }
268
+ if (line) {
269
+ lines.push(`${color}\u2502${NC} ${DIM}${dpad(line, innerW)}${NC}${color}\u2502${NC}`);
270
+ }
271
+ }
272
+
273
+ // Bottom border
274
+ lines.push(`${color}\u2570${hr}\u256f${NC}`);
275
+
276
+ return lines.join("\n");
277
+ }
278
+
279
+ // ─── Markdown-native render (for MCP tool responses) ───────────────────────
280
+ //
281
+ // Claude Code's UI doesn't render raw ANSI escape codes properly — it strips
282
+ // the ESC byte but leaves "[38;2;...m" as literal text, making the output
283
+ // unreadable. This renderer produces pure markdown with unicode rarity dots
284
+ // instead of ANSI colors, so it renders cleanly in any MCP client UI.
285
+
286
+ const RARITY_DOT: Record<Rarity, string> = {
287
+ common: "\u26AA", // ⚪ white circle
288
+ uncommon: "\uD83D\uDFE2", // 🟢 green circle
289
+ rare: "\uD83D\uDD35", // 🔵 blue circle
290
+ epic: "\uD83D\uDFE3", // 🟣 purple circle
291
+ legendary: "\uD83D\uDFE1", // 🟡 yellow circle
292
+ };
293
+
294
+ export function renderCompanionCardMarkdown(
295
+ bones: BuddyBones,
296
+ name: string,
297
+ personality: string,
298
+ reaction?: string,
299
+ frame: number = 0,
300
+ ): string {
301
+ const dot = RARITY_DOT[bones.rarity];
302
+ const stars = RARITY_STARS[bones.rarity];
303
+ const shiny = bones.shiny ? " \u2728" : "";
304
+ const art = getArtFrame(bones.species, bones.eye, frame);
305
+
306
+ // Hat: replace first empty art line
307
+ const hatLine = HAT_ART[bones.hat];
308
+ if (hatLine && !art[0].trim()) {
309
+ art[0] = hatLine;
310
+ }
311
+
312
+ // Strip empty lines from art for cleaner rendering
313
+ const artLines = art.filter((l) => l.trim().length > 0);
314
+
315
+ const STAT_NAMES: StatName[] = ["DEBUGGING", "PATIENCE", "CHAOS", "WISDOM", "SNARK"];
316
+ const statRows = STAT_NAMES.map((stat) => {
317
+ const val = bones.stats[stat];
318
+ const filled = Math.round(val / 10);
319
+ const bar = "\u2588".repeat(filled) + "\u2591".repeat(10 - filled);
320
+ const marker = stat === bones.peak ? " \u25B2" : stat === bones.dump ? " \u25BC" : "";
321
+ const label = `**${stat.slice(0, 3)}**${stat.slice(3)}`;
322
+ return `| ${label} | ${val}${marker} | \`${bar}\` |`;
323
+ }).join("\n");
324
+
325
+ const parts: string[] = [];
326
+
327
+ // Header: rarity dot, name, species+rarity, stars, shiny
328
+ parts.push(`### ${dot} ${name} · \`${bones.rarity.toUpperCase()} ${bones.species}\` · ${stars}${shiny}`);
329
+ parts.push("");
330
+
331
+ // ASCII art in a code block (preserves monospaced formatting)
332
+ parts.push("```");
333
+ parts.push(artLines.join("\n"));
334
+ parts.push("```");
335
+ parts.push("");
336
+
337
+ // Identity line
338
+ parts.push(`**Identity:** eye \`${bones.eye}\` · hat \`${bones.hat}\``);
339
+ parts.push("");
340
+
341
+ // Stats table
342
+ parts.push("| Stat | Value | Bar |");
343
+ parts.push("|---|---|---|");
344
+ parts.push(statRows);
345
+ parts.push("");
346
+
347
+ // Reaction (if any) — reactions often already contain asterisks
348
+ // for actions like "*blinks slowly*", so render them verbatim to avoid
349
+ // accidentally turning italics into bold.
350
+ if (reaction) {
351
+ parts.push(`\ud83d\udcac ${reaction}`);
352
+ parts.push("");
353
+ }
354
+
355
+ // Personality as blockquote
356
+ if (personality) {
357
+ parts.push(`> ${personality}`);
358
+ }
359
+
360
+ return parts.join("\n");
361
+ }
362
+
363
+ // ─── Compact status line render ─────────────────────────────────────────────
364
+
365
+ export function renderStatusLine(
366
+ bones: BuddyBones,
367
+ name: string,
368
+ reaction?: string,
369
+ ): string {
370
+ const face = SPECIES_ART[bones.species][0][2]?.replace(/\{E\}/g, bones.eye).trim() || "(?)";
371
+ const color = RARITY_COLOR[bones.rarity];
372
+ const stars = RARITY_STARS[bones.rarity];
373
+ const shiny = bones.shiny ? "\u2728" : "";
374
+ const msg = reaction ? ` \u2502 "${reaction}"` : "";
375
+ return `${color}${face}${NC} ${BOLD}${name}${NC} ${shiny}${color}${stars}${NC}${msg}`;
376
+ }