@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.
@@ -0,0 +1,448 @@
1
+ /**
2
+ * claude-buddy engine — deterministic companion generation
3
+ * Matches Claude Code's exact algorithm: wyhash → mulberry32 → species/stats
4
+ */
5
+
6
+ export const SALT = "friend-2026-401";
7
+
8
+ export const SPECIES = [
9
+ "duck",
10
+ "goose",
11
+ "blob",
12
+ "cat",
13
+ "dragon",
14
+ "octopus",
15
+ "owl",
16
+ "penguin",
17
+ "turtle",
18
+ "snail",
19
+ "ghost",
20
+ "axolotl",
21
+ "capybara",
22
+ "cactus",
23
+ "robot",
24
+ "rabbit",
25
+ "mushroom",
26
+ "chonk",
27
+ ] as const;
28
+
29
+ export type Species = (typeof SPECIES)[number];
30
+
31
+ export const RARITIES = [
32
+ "common",
33
+ "uncommon",
34
+ "rare",
35
+ "epic",
36
+ "legendary",
37
+ ] as const;
38
+ export type Rarity = (typeof RARITIES)[number];
39
+
40
+ export const RARITY_WEIGHTS: Record<Rarity, number> = {
41
+ common: 60,
42
+ uncommon: 25,
43
+ rare: 10,
44
+ epic: 4,
45
+ legendary: 1,
46
+ };
47
+
48
+ export const STAT_NAMES = [
49
+ "DEBUGGING",
50
+ "PATIENCE",
51
+ "CHAOS",
52
+ "WISDOM",
53
+ "SNARK",
54
+ ] as const;
55
+ export type StatName = (typeof STAT_NAMES)[number];
56
+
57
+ export const RARITY_FLOOR: Record<Rarity, number> = {
58
+ common: 5,
59
+ uncommon: 15,
60
+ rare: 25,
61
+ epic: 35,
62
+ legendary: 50,
63
+ };
64
+
65
+ export const RARITY_STARS: Record<Rarity, string> = {
66
+ common: "\u2605",
67
+ uncommon: "\u2605\u2605",
68
+ rare: "\u2605\u2605\u2605",
69
+ epic: "\u2605\u2605\u2605\u2605",
70
+ legendary: "\u2605\u2605\u2605\u2605\u2605",
71
+ };
72
+
73
+ export const EYES = [
74
+ "\u00b7",
75
+ "\u2726",
76
+ "\u00d7",
77
+ "\u25c9",
78
+ "@",
79
+ "\u00b0",
80
+ ] as const;
81
+ export type Eye = (typeof EYES)[number];
82
+
83
+ export const HATS = [
84
+ "none",
85
+ "crown",
86
+ "tophat",
87
+ "propeller",
88
+ "halo",
89
+ "wizard",
90
+ "beanie",
91
+ "tinyduck",
92
+ ] as const;
93
+ export type Hat = (typeof HATS)[number];
94
+
95
+ export const HAT_ART: Record<Hat, string> = {
96
+ none: "",
97
+ crown: " \\^^^/ ",
98
+ tophat: " [___] ",
99
+ propeller: " -+- ",
100
+ halo: " ( ) ",
101
+ wizard: " /^\\ ",
102
+ beanie: " (___) ",
103
+ tinyduck: " ,> ",
104
+ };
105
+
106
+ export interface BuddyStats {
107
+ DEBUGGING: number;
108
+ PATIENCE: number;
109
+ CHAOS: number;
110
+ WISDOM: number;
111
+ SNARK: number;
112
+ }
113
+
114
+ export interface BuddyBones {
115
+ rarity: Rarity;
116
+ species: Species;
117
+ eye: Eye;
118
+ hat: Hat;
119
+ shiny: boolean;
120
+ stats: BuddyStats;
121
+ peak: StatName;
122
+ dump: StatName;
123
+ }
124
+
125
+ export interface Companion {
126
+ bones: BuddyBones;
127
+ name: string;
128
+ personality: string;
129
+ hatchedAt: number;
130
+ userId: string;
131
+ }
132
+
133
+ // ─── Hash: wyhash via Bun.hash, pure JS fallback ───────────────────────────
134
+ // Matches Zig stdlib wyhash v4.2 (used by Bun.hash). The pure JS implementation
135
+ // uses BigInt for 128-bit multiplication — slower than native, but produces
136
+ // identical hashes so every user gets the same buddy regardless of runtime.
137
+
138
+ const MASK64 = (1n << 64n) - 1n;
139
+ const WY_SECRET: readonly bigint[] = [
140
+ 0xa0761d6478bd642fn,
141
+ 0xe7037ed1a0b428dbn,
142
+ 0x8ebc6af09c88c6e3n,
143
+ 0x589965cc75374cc3n,
144
+ ];
145
+
146
+ function wyMum(a: bigint, b: bigint): [bigint, bigint] {
147
+ const x = (a & MASK64) * (b & MASK64);
148
+ return [x & MASK64, (x >> 64n) & MASK64];
149
+ }
150
+
151
+ function wyMix(a: bigint, b: bigint): bigint {
152
+ const [lo, hi] = wyMum(a, b);
153
+ return (lo ^ hi) & MASK64;
154
+ }
155
+
156
+ function wyR8(buf: Uint8Array, off: number): bigint {
157
+ let v = 0n;
158
+ for (let i = 0; i < 8; i++) v |= BigInt(buf[off + i]) << BigInt(i * 8);
159
+ return v;
160
+ }
161
+
162
+ function wyR4(buf: Uint8Array, off: number): bigint {
163
+ let v = 0n;
164
+ for (let i = 0; i < 4; i++) v |= BigInt(buf[off + i]) << BigInt(i * 8);
165
+ return v;
166
+ }
167
+
168
+ function wyhash(input: string, seed = 0n): bigint {
169
+ const buf = new TextEncoder().encode(input);
170
+ const len = buf.length;
171
+
172
+ let s0 =
173
+ (seed ^ wyMix((seed ^ WY_SECRET[0]) & MASK64, WY_SECRET[1])) & MASK64;
174
+ let s1 = s0,
175
+ s2 = s0;
176
+ let a: bigint, b: bigint;
177
+
178
+ if (len <= 16) {
179
+ if (len >= 4) {
180
+ const q = (len >> 3) << 2;
181
+ a = ((wyR4(buf, 0) << 32n) | wyR4(buf, q)) & MASK64;
182
+ b = ((wyR4(buf, len - 4) << 32n) | wyR4(buf, len - 4 - q)) & MASK64;
183
+ } else if (len > 0) {
184
+ a =
185
+ (BigInt(buf[0]) << 16n) |
186
+ (BigInt(buf[len >> 1]) << 8n) |
187
+ BigInt(buf[len - 1]);
188
+ b = 0n;
189
+ } else {
190
+ a = 0n;
191
+ b = 0n;
192
+ }
193
+ } else {
194
+ let i = 0;
195
+ if (len >= 48) {
196
+ while (i + 48 < len) {
197
+ for (let j = 0; j < 3; j++) {
198
+ const ra = wyR8(buf, i + 16 * j);
199
+ const rb = wyR8(buf, i + 16 * j + 8);
200
+ const states = [s0, s1, s2];
201
+ states[j] = wyMix(
202
+ (ra ^ WY_SECRET[j + 1]) & MASK64,
203
+ (rb ^ states[j]) & MASK64,
204
+ );
205
+ s0 = states[0];
206
+ s1 = states[1];
207
+ s2 = states[2];
208
+ }
209
+ i += 48;
210
+ }
211
+ s0 = (s0 ^ s1 ^ s2) & MASK64;
212
+ }
213
+ const rem = buf.subarray(i);
214
+ let ri = 0;
215
+ while (ri + 16 < rem.length) {
216
+ s0 = wyMix(
217
+ (wyR8(rem, ri) ^ WY_SECRET[1]) & MASK64,
218
+ (wyR8(rem, ri + 8) ^ s0) & MASK64,
219
+ );
220
+ ri += 16;
221
+ }
222
+ a = wyR8(buf, len - 16);
223
+ b = wyR8(buf, len - 8);
224
+ }
225
+
226
+ a = (a ^ WY_SECRET[1]) & MASK64;
227
+ b = (b ^ s0) & MASK64;
228
+ [a, b] = wyMum(a, b);
229
+ return wyMix(
230
+ (a ^ WY_SECRET[0] ^ BigInt(len)) & MASK64,
231
+ (b ^ WY_SECRET[1]) & MASK64,
232
+ );
233
+ }
234
+
235
+ export function hashString(s: string): number {
236
+ if (typeof Bun !== "undefined") {
237
+ return Number(BigInt(Bun.hash(s)) & 0xffffffffn);
238
+ }
239
+ return Number(wyhash(s) & 0xffffffffn);
240
+ }
241
+
242
+ // ─── PRNG: Mulberry32 ───────────────────────────────────────────────────────
243
+
244
+ export function mulberry32(seed: number): () => number {
245
+ let a = seed >>> 0;
246
+ return () => {
247
+ a |= 0;
248
+ a = (a + 0x6d2b79f5) | 0;
249
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
250
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
251
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
252
+ };
253
+ }
254
+
255
+ // ─── Generation ─────────────────────────────────────────────────────────────
256
+
257
+ function pick<T>(rng: () => number, arr: readonly T[]): T {
258
+ return arr[Math.floor(rng() * arr.length)];
259
+ }
260
+
261
+ function rollRarity(rng: () => number): Rarity {
262
+ const total = Object.values(RARITY_WEIGHTS).reduce((a, b) => a + b, 0);
263
+ let roll = rng() * total;
264
+ for (const r of RARITIES) {
265
+ roll -= RARITY_WEIGHTS[r];
266
+ if (roll < 0) return r;
267
+ }
268
+ return "common";
269
+ }
270
+
271
+ export function generateBones(userId: string, salt: string = SALT): BuddyBones {
272
+ const rng = mulberry32(hashString(userId + salt));
273
+
274
+ const rarity = rollRarity(rng);
275
+ const species = pick(rng, SPECIES);
276
+ const eye = pick(rng, EYES);
277
+ const hat = rarity === "common" ? "none" : pick(rng, HATS);
278
+ const shiny = rng() < 0.01;
279
+
280
+ const peak = pick(rng, STAT_NAMES);
281
+ let dump = pick(rng, STAT_NAMES);
282
+ while (dump === peak) dump = pick(rng, STAT_NAMES);
283
+
284
+ const floor = RARITY_FLOOR[rarity];
285
+ const stats = {} as BuddyStats;
286
+ for (const name of STAT_NAMES) {
287
+ if (name === peak) {
288
+ stats[name] = Math.min(100, floor + 50 + Math.floor(rng() * 30));
289
+ } else if (name === dump) {
290
+ stats[name] = Math.max(1, floor - 10 + Math.floor(rng() * 15));
291
+ } else {
292
+ stats[name] = floor + Math.floor(rng() * 40);
293
+ }
294
+ }
295
+
296
+ return { rarity, species, eye, hat, shiny, stats, peak, dump };
297
+ }
298
+
299
+ // ─── ASCII Art ──────────────────────────────────────────────────────────────
300
+
301
+ const FACE_TEMPLATES: Record<Species, string> = {
302
+ duck: "({E}>",
303
+ goose: "({E}>",
304
+ blob: "({E}{E})",
305
+ cat: "={E}\u03c9{E}=",
306
+ dragon: "<{E}~{E}>",
307
+ octopus: "~({E}{E})~",
308
+ owl: "({E})({E})",
309
+ penguin: "({E}>)",
310
+ turtle: "[{E}_{E}]",
311
+ snail: "{E}(@)",
312
+ ghost: "/{E}{E}\\",
313
+ axolotl: "}{E}.{E}{",
314
+ capybara: "({E}oo{E})",
315
+ cactus: "|{E} {E}|",
316
+ robot: "[{E}{E}]",
317
+ rabbit: "({E}..{E})",
318
+ mushroom: "|{E} {E}|",
319
+ chonk: "({E}.{E})",
320
+ };
321
+
322
+ export function renderFace(species: Species, eye: Eye): string {
323
+ return FACE_TEMPLATES[species].replace(/\{E\}/g, eye);
324
+ }
325
+
326
+ export function renderBuddy(bones: BuddyBones): string {
327
+ const face = renderFace(bones.species, bones.eye);
328
+ const hat = HAT_ART[bones.hat];
329
+ const shiny = bones.shiny ? "\u2728 " : "";
330
+ const stars = RARITY_STARS[bones.rarity];
331
+
332
+ const lines: string[] = [];
333
+ if (hat) lines.push(hat);
334
+ lines.push(` ${face}`);
335
+ lines.push("");
336
+ lines.push(`${shiny}${bones.rarity} ${bones.species} ${stars}`);
337
+ lines.push("");
338
+
339
+ for (const stat of STAT_NAMES) {
340
+ const val = bones.stats[stat];
341
+ const bar =
342
+ "\u2588".repeat(Math.floor(val / 5)) +
343
+ "\u2591".repeat(20 - Math.floor(val / 5));
344
+ const label = stat.padEnd(9);
345
+ const marker =
346
+ stat === bones.peak ? " \u25b2" : stat === bones.dump ? " \u25bc" : "";
347
+ lines.push(` ${label} ${bar} ${String(val).padStart(3)}${marker}`);
348
+ }
349
+
350
+ return lines.join("\n");
351
+ }
352
+
353
+ // ─── Compact render for status line ─────────────────────────────────────────
354
+
355
+ export function renderCompact(
356
+ bones: BuddyBones,
357
+ name: string,
358
+ reaction?: string,
359
+ ): string {
360
+ const face = renderFace(bones.species, bones.eye);
361
+ const shiny = bones.shiny ? "\u2728" : "";
362
+ const stars = RARITY_STARS[bones.rarity];
363
+ const msg = reaction ? ` \u2502 "${reaction}"` : "";
364
+ return `${face} ${name} ${shiny}${stars}${msg}`;
365
+ }
366
+
367
+ // ─── Search (brute-force) ───────────────────────────────────────────────────
368
+
369
+ export interface SearchCriteria {
370
+ species: Species;
371
+ rarity: Rarity;
372
+ wantShiny: boolean;
373
+ wantPeak?: StatName;
374
+ wantDump?: StatName;
375
+ statOrder?: StatName[];
376
+ }
377
+
378
+ export interface SearchResult {
379
+ userId: string;
380
+ bones: BuddyBones;
381
+ }
382
+
383
+ export function searchBuddy(
384
+ criteria: SearchCriteria,
385
+ maxAttempts: number,
386
+ onProgress?: (checked: number, found: number) => void,
387
+ ): SearchResult[] {
388
+ const { randomBytes } = require("crypto") as typeof import("crypto");
389
+ const results: SearchResult[] = [];
390
+ const reportInterval = 2_000_000;
391
+
392
+ for (let i = 0; i < maxAttempts; i++) {
393
+ if (i > 0 && i % reportInterval === 0) {
394
+ onProgress?.(i, results.length);
395
+ }
396
+
397
+ const id = randomBytes(32).toString("hex");
398
+ const rng = mulberry32(hashString(id + SALT));
399
+
400
+ const rarity = rollRarity(rng);
401
+ if (rarity !== criteria.rarity) continue;
402
+
403
+ const species = pick(rng, SPECIES);
404
+ if (species !== criteria.species) continue;
405
+
406
+ const eye = pick(rng, EYES);
407
+ const hat = rarity === "common" ? "none" : pick(rng, HATS);
408
+ const shiny = rng() < 0.01;
409
+ if (criteria.wantShiny && !shiny) continue;
410
+
411
+ const peak = pick(rng, STAT_NAMES);
412
+ if (criteria.wantPeak && criteria.wantPeak !== peak) continue;
413
+
414
+ let dump = pick(rng, STAT_NAMES);
415
+ while (dump === peak) dump = pick(rng, STAT_NAMES);
416
+ if (criteria.wantDump && criteria.wantDump !== dump) continue;
417
+
418
+ const floor = RARITY_FLOOR[rarity];
419
+ const stats = {} as BuddyStats;
420
+ for (const name of STAT_NAMES) {
421
+ if (name === peak)
422
+ stats[name] = Math.min(100, floor + 50 + Math.floor(rng() * 30));
423
+ else if (name === dump)
424
+ stats[name] = Math.max(1, floor - 10 + Math.floor(rng() * 15));
425
+ else stats[name] = floor + Math.floor(rng() * 40);
426
+ }
427
+
428
+ if (criteria.statOrder && criteria.statOrder.length > 1) {
429
+ let valid = true;
430
+ for (let j = 0; j < criteria.statOrder.length - 1; j++) {
431
+ if (stats[criteria.statOrder[j]] <= stats[criteria.statOrder[j + 1]]) {
432
+ valid = false;
433
+ break;
434
+ }
435
+ }
436
+ if (!valid) continue;
437
+ }
438
+
439
+ results.push({
440
+ userId: id,
441
+ bones: { rarity, species, eye, hat, shiny, stats, peak, dump },
442
+ });
443
+
444
+ if (results.length >= 20) break;
445
+ }
446
+
447
+ return results;
448
+ }