Nibbl: a tiny pixel pet above your prompt that nibbles your bugs

A tiny pixel pet that lives above your Claude Code prompt. It hatches from an egg after 10 answered turns, reacts to what really happens in your session (a bug appears on an error, it eats the bugs when your tests pass, it carries a box when you commit, it goes on an expedition while Claude works and sleeps when you are away) and grows over months. It never costs a model token and never interrupts you.
/plugin marketplace add https://getnibbl.pages.dev/marketplace.json /plugin install nibbl@nibbl
To get updates automatically: /plugin, Marketplaces, nibbl, Enable auto-update (Claude Code leaves it off for community marketplaces).
| Command | What it does |
|---|---|
/nibbl | Name, serial, tier, level, XP, hearts, rarest trait, card and leaderboard links, your rank with the nibbl just above you, the last note and wave, and an update hint when a newer version is out |
/nibbl pet | Pets it from the prompt |
/nibbl name <text> | Rename your pet (once a week, up to 16 characters) |
/nibbl label <text> | Set a label (up to 24 characters); /nibbl label alone clears it |
/nibbl wave <serial> | Sends a wave (a heart, no text, no XP) to another nibbl: 16, #16 and 000016 all work. 10 a day, one per nibbl per day |
/nibbl share | Prints your card link, a ready post ("Meet Pixel, my nibbl #000019, a rare teen...") and a link that opens it in X. Nothing is sent anywhere |
/nibbl achievements | Your unlocked achievements with their dates (UTC) and the next three with progress, for example Bug Hunter 63/100. Asks the server, so it needs a connection |
/nibbl invite [serial] | On a hatched pet: how to invite a friend. On an egg: /nibbl invite 19 saves who invited you, and your pet becomes friends with #000019 when it hatches |
/nibbl notes | Lists the notes from the maker you have received |
/nibbl odds | Hatch odds and the odds of each of your pet's traits |
/nibbl export | Shows the secret code that moves your pet to another machine |
/nibbl import <code> | Moves a pet to this machine |
/nibbl hide | Hides or shows the band; the pet keeps living |
Every couple of days a short note from the maker shows as a toast (20 notes in total, bundled in the mod, so they work offline). Waves you receive show as one toast per sync, and /nibbl shows rank 4 of 26 · next: #000019 ... so you know whom to wave at. There is no free-text chat. A toast also appears when you first reach the top 10, when you reach #1, and once per new version.
/nibbl shows stage: teen (since level 10)./nibbl achievements lists them. They are public on your pet's card./nibbl invite <serial> before your egg hatches and both pets get the Friends achievement.Type /nibbl pet, or focus the band with ctrl+x tab and press p (a click on [♥] works in the fullscreen terminal). Five pets an hour give XP; after that it still loves you, it just gives no XP.
Every egg is rolled on the server, once per machine, with the same odds for everyone: common 40%, uncommon 30%, rare 18%, epic 9%, legendary 3%. Shiny is 4% on top of any tier (a legendary shiny is about 1 in 830). Every pet also rolls a mark with odds of 1 in 24 (4.17%), so each one has a trait rarer than 5%. /nibbl odds shows yours.
machineHash: the SHA-256 of your hardware id plus "nibbl". The id itself never leaves./nibbl name and /nibbl label. They show on your pet's public card and the leaderboard./nibbl wave <serial>, so the server can deliver the wave. No text goes with it./nibbl invite <serial>: sent once, with the hatch, and then forgotten.turn, check_pass, commit, error, pet, hide) and the time of each. Never your prompts, commands, file paths or outputs.All of it goes to https://getnibbl.pages.dev and nowhere else. The export code is a secret: anyone who has it owns your pet, so it is drawn on your screen only and never sent to the model.
Full privacy policy, retention and how to delete your pet: https://getnibbl.pages.dev/privacy/
/nibbl always works.[♥] or pressing p does nothing. The band takes keys only when it has focus: press ctrl+x tab first, or use /nibbl pet./nibbl shows how many are left./plugin marketplace update nibbl, then /plugin update nibbl@nibbl, and restart. Or turn on auto-update in /plugin, Marketplaces, nibbl.Run the API with pnpm -C apps/api dev and point the mod at it with NIBBL_API=http://localhost:8787 (or the Nibbl server option in /config).
hooks/register.js 2561 lines1// src/register.tsx
2import { read as read6 } from "claude-code";
3
4// src/account.ts
5import { update as update3 } from "claude-code";
6
7// src/config.ts
8const DEFAULT_API = "https://getnibbl.pages.dev";
9const HATCH_AFTER_TURNS = 10;
10const HATCH_DELAY_MS = 1500;
11const SYNC_EVERY_MS = 150 * 6e4;
12const SYNC_SOON_PENDING = 500;
13const QUEUE_CAP = 1e3;
14const MAX_BATCH = 1e3;
15const TRIM_AFTER_413 = 500;
16const LEASE_MS = 6e4;
17const ORPHAN_MS = 15 * 6e4;
18const BEAT_MS = 5 * 6e4;
19const BACKOFF_BASE_MS = 6e4;
20const BACKOFF_MAX_MS = 60 * 6e4;
21const CONFLICT_RETRY_MS = 3e4;
22const SESSION_END_WAIT_MS = 2500;
23const POST_TIMEOUT_MS = 2e4;
24const TICK_MS = 500;
25const MOOD_MS = 6e3;
26const HEART_MS = 2e3;
27const LOOT_MS = 4e3;
28const BOX_MS = 6e3;
29const SLEEP_AFTER_MS = 3 * 6e4;
30const MAX_BUGS = 3;
31const HOME_X = 8;
32const AWAY_X = 16;
33const NIGHT_FROM_HOUR = 2;
34const NIGHT_TO_HOUR = 5;
35const NOTE_GAP_MS = 48 * 60 * 6e4;
36const NOTE_MIN_TURNS = 3;
37const WAVES_KEPT = 10;
38const TOAST_QUEUE_MAX = 12;
39const TOP_RANK = 10;
40const RANK_EVERY_MS = 24 * 60 * 6e4;
41const NAME_MAX = 16;
42const LABEL_MAX = 24;
43const MIN_FULL_ROWS = 8;
44const MIN_FULL_COLUMNS = 60;
45const CHECK_COMMAND = /(?:^|[\s;&|(/])(?:test|vitest|jest|pytest|tsc|lint|build)(?=$|[\s;&|):])/;
46const COMMIT_COMMAND = /\bgit(?:\s+-[A-Za-z]\s+\S+|\s+--?[\w-]+(?:=\S+)?)*\s+commit(?![\w-])/;
47const K = {
48 pet: "v1.pet",
49 egg: "v1.egg",
50 hidden: "v1.hidden",
51 lease: "v1.lease",
52 syncWait: "v1.syncWait",
53 hatchWait: "v1.hatchWait",
54 installId: "v1.installId",
55 machineHash: "v1.machineHash",
56 installSerial: "v1.installSerial",
57 noteIdx: "v1.noteIdx",
58 noteAt: "v1.noteAt",
59 waves: "v1.waves",
60 rank: "v1.rank",
61 rankAt: "v1.rankAt",
62 bestRank: "v1.bestRank",
63 latest: "v1.latest",
64 updateSeen: "v1.updateSeen",
65 toastQueue: "v1.toastQueue",
66 greetDay: "v1.greetDay",
67 inviteCode: "v1.inviteCode",
68 spikePet: "pet",
69 spikeEggTurns: "eggTurns"
70};
71const QUEUE_PREFIX = "v1.q.";
72const SPIKE_KEYS = ["pet", "eggTurns", "windows", "lastScoredAt", "mode"];
73
74// src/runtime.ts
75const rt = {
76 options: {},
77 ticker: null,
78 isRegistered: false,
79 tzRead: false,
80 anim: { frame: 0, petX: HOME_X, blink: false },
81 band: null,
82 lastBeat: 0,
83 isHatching: false,
84 isSyncing: false,
85 answered: 0,
86 queueChain: Promise.resolve()
87};
88const later = ($, work) => {
89 $.clock.after(0, () => {
90 void work().catch((err) => $.ui.log(`nibbl: ${err instanceof Error ? err.message : String(err)}`, { to: "debug" }));
91 });
92};
93const withTimeout = ($, ms, work) => new Promise((resolve) => {
94 const timer = $.clock.after(ms, () => resolve("timeout"));
95 work.then(
96 (value) => {
97 timer.cancel();
98 resolve(value);
99 },
100 () => {
101 timer.cancel();
102 resolve("timeout");
103 }
104 );
105});
106
107// src/api.ts
108const outcomeOf = (status, text) => {
109 let parsed;
110 try {
111 parsed = JSON.parse(text);
112 } catch {
113 return { kind: "offline", reason: `non-JSON answer (${status})` };
114 }
115 if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return { kind: "offline", reason: `non-object answer (${status})` };
116 const body = parsed;
117 if (status >= 200 && status < 300) return { kind: "ok", status, body };
118 const code = typeof body.error === "string" ? body.error : `http_${status}`;
119 const retryAt = typeof body.retryAt === "number" && Number.isFinite(body.retryAt) ? body.retryAt : null;
120 return { kind: "error", status, code, retryAt };
121};
122const post = async ($, base, path, payload) => {
123 try {
124 const res = await $.http.fetch(`${base}${path}`, {
125 method: "POST",
126 headers: { "content-type": "application/json" },
127 body: JSON.stringify(payload)
128 });
129 return outcomeOf(res.status, res.text);
130 } catch (err) {
131 return { kind: "offline", reason: err instanceof Error ? err.message : String(err) };
132 }
133};
134const get = async ($, base, path) => {
135 try {
136 const res = await $.http.fetch(`${base}${path}`, { method: "GET" });
137 return outcomeOf(res.status, res.text);
138 } catch (err) {
139 return { kind: "offline", reason: err instanceof Error ? err.message : String(err) };
140 }
141};
142const getWithin = async ($, base, path, ms = POST_TIMEOUT_MS) => {
143 const out = await withTimeout($, ms, get($, base, path));
144 return out === "timeout" ? { kind: "offline", reason: `no answer in ${ms} ms` } : out;
145};
146const postWithin = async ($, base, path, payload, ms = POST_TIMEOUT_MS) => {
147 const out = await withTimeout($, ms, post($, base, path, payload));
148 return out === "timeout" ? { kind: "offline", reason: `no answer in ${ms} ms` } : out;
149};
150const isWait = (v) => typeof v === "object" && v !== null && Number.isFinite(v.until) && Number.isInteger(v.failures);
151const backoff = (prev, now) => {
152 const failures = (prev?.failures ?? 0) + 1;
153 return { failures, until: now + Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** (failures - 1)) };
154};
155const holdUntil = (prev, until) => ({ failures: prev?.failures ?? 0, until });
156const LOCAL = /^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d{1,5})?$/;
157const REMOTE = /^https:\/\/[A-Za-z0-9.-]+(?::\d{1,5})?$/;
158const pickBase = (env, option) => {
159 for (const candidate of [env, option]) {
160 if (typeof candidate !== "string") continue;
161 const base = candidate.trim().replace(/\/+$/, "");
162 if (LOCAL.test(base) || REMOTE.test(base)) return base;
163 }
164 return DEFAULT_API;
165};
166const apiBase = async ($) => pickBase(await $.env.get("NIBBL_API"), rt.options.apiBase);
167
168// src/hatch.ts
169import { read as read2, update as update2 } from "claude-code";
170
171// ../../packages/core/src/achievements.ts
172const ZERO_TOTALS = {
173 turns: 0,
174 checks: 0,
175 commits: 0,
176 errors: 0,
177 pets: 0,
178 wavesSent: 0,
179 activeDays: 0
180};
181const ACHIEVEMENTS = [
182 { id: "first-bite", name: "First Bite", desc: "Pass 1 check", goal: 1, metric: (f) => f.checks },
183 { id: "bug-hunter", name: "Bug Hunter", desc: "Pass 100 checks", goal: 100, metric: (f) => f.checks },
184 { id: "exterminator", name: "Exterminator", desc: "Pass 1000 checks", goal: 1e3, metric: (f) => f.checks },
185 { id: "committed", name: "Committed", desc: "Make 100 commits", goal: 100, metric: (f) => f.commits },
186 { id: "shipper", name: "Shipper", desc: "Make 1000 commits", goal: 1e3, metric: (f) => f.commits },
187 { id: "pair", name: "Pair Programmer", desc: "Get 1000 answered turns", goal: 1e3, metric: (f) => f.turns },
188 { id: "marathon", name: "Marathon", desc: "Get 10000 answered turns", goal: 1e4, metric: (f) => f.turns },
189 { id: "stubborn", name: "Never Gives Up", desc: "Survive 100 failed tool runs", goal: 100, metric: (f) => f.errors },
190 { id: "regular", name: "Regular", desc: "Be active on 7 days", goal: 7, metric: (f) => f.activeDays },
191 { id: "devoted", name: "Devoted", desc: "Be active on 30 days", goal: 30, metric: (f) => f.activeDays },
192 { id: "teen", name: "Growing Up", desc: "Reach teen stage (level 10)", goal: 10, metric: (f) => f.level },
193 { id: "adult", name: "All Grown Up", desc: "Reach adult stage (level 25)", goal: 25, metric: (f) => f.level },
194 { id: "friends", name: "Friends", desc: "Invite someone or be invited", goal: 1, metric: (f) => f.friends ? 1 : 0 },
195 { id: "social", name: "Social Butterfly", desc: "Send 25 waves", goal: 25, metric: (f) => f.wavesSent },
196 { id: "week", name: "Nibbl of the Week", desc: "Win a week", goal: 1, metric: (f) => f.weekWins }
197];
198const achievementById = (id) => ACHIEVEMENTS.find((a) => a.id === id);
199const nextUp = (f, have, n = 3) => ACHIEVEMENTS.filter((a) => !have.has(a.id)).map((a, i) => ({ id: a.id, name: a.name, value: Math.min(a.metric(f), a.goal), goal: a.goal, i })).sort((x, y) => y.value / y.goal - x.value / x.goal || x.i - y.i).slice(0, n).map(({ i: _i, ...rest }) => rest);
200
201// ../../packages/core/src/cells.ts
202const toCellPairs = (grid) => {
203 const rows = [];
204 for (let y = 0; y + 1 < grid.length; y += 2) {
205 rows.push(
206 grid[y].map((top, x) => {
207 const bottom = grid[y + 1][x];
208 if (top !== null) return { glyph: "\u2580", fg: top, bg: bottom };
209 if (bottom !== null) return { glyph: "\u2584", fg: bottom, bg: null };
210 return { glyph: " ", fg: null, bg: null };
211 })
212 );
213 }
214 return rows;
215};
216
217// ../../packages/core/src/grid.ts
218const blankGrid = (width, height) => Array.from({ length: height }, () => Array(width).fill(null));
219const inBounds = (grid, x, y) => y >= 0 && y < grid.length && x >= 0 && x < grid[0].length;
220const setCell = (grid, x, y, color) => {
221 if (inBounds(grid, x, y)) grid[y][x] = color;
222};
223
224// ../../packages/core/src/palette.ts
225const SWEETIE = [
226 "#1a1c2c",
227 "#5d275d",
228 "#b13e53",
229 "#ef7d57",
230 "#ffcd75",
231 "#a7f070",
232 "#38b764",
233 "#257179",
234 "#29366f",
235 "#3b5dc9",
236 "#41a6f6",
237 "#73eff7",
238 "#f4f4f4",
239 "#94b0c2",
240 "#566c86",
241 "#333c57"
242];
243const SWEETIE_RGB = SWEETIE.map(
244 (hex) => [1, 3, 5].map((o) => Number.parseInt(hex.slice(o, o + 2), 16))
245);
246const C = {
247 ink: 0,
248 plum: 1,
249 red: 2,
250 orange: 3,
251 yellow: 4,
252 lime: 5,
253 green: 6,
254 teal: 7,
255 navy: 8,
256 blue: 9,
257 sky: 10,
258 cyan: 11,
259 white: 12,
260 silver: 13,
261 slate: 14,
262 dusk: 15
263};
264const RAMPS = {
265 ember: [C.red, C.orange, C.yellow],
266 moss: [C.teal, C.green, C.lime],
267 ocean: [C.navy, C.blue, C.sky],
268 frost: [C.blue, C.sky, C.cyan],
269 ghost: [C.slate, C.silver, C.white],
270 jam: [C.plum, C.red, C.orange],
271 gold: [C.orange, C.yellow, C.white],
272 aurora: [C.navy, C.teal, C.cyan]
273};
274const SHINY_RAMPS = {
275 ember: [C.plum, C.orange, C.yellow],
276 moss: [C.teal, C.cyan, C.white],
277 ocean: [C.dusk, C.blue, C.cyan],
278 frost: [C.slate, C.cyan, C.white],
279 ghost: [C.plum, C.silver, C.white],
280 jam: [C.plum, C.red, C.yellow],
281 gold: [C.red, C.yellow, C.white],
282 aurora: [C.navy, C.green, C.lime]
283};
284
285// ../../packages/core/src/prng.ts
286const mulberry32 = (seed) => {
287 let a = seed >>> 0;
288 return () => {
289 a = a + 1831565813 >>> 0;
290 let t = a;
291 t = Math.imul(t ^ t >>> 15, t | 1);
292 t ^= t + Math.imul(t ^ t >>> 7, t | 61);
293 return (t ^ t >>> 14) >>> 0;
294 };
295};
296const pickIndex = (rng, n) => rng() % n;
297
298// ../../packages/core/src/draw.ts
299const PET_SIZE = 16;
300const SIZING = {
301 baby: { dw: 2, dh: 2, min: 5 },
302 teen: { dw: 1, dh: 1, min: 5 },
303 adult: { dw: 0, dh: 0, min: 3 }
304};
305const EAR_SHAPES = {
306 round: [[-3, -1], [-4, -1], [-3, -2], [-4, -2]],
307 pointy: [[-3, -1], [-4, -1], [-4, -2], [-4, -3]],
308 bunny: [[-3, -1], [-3, -2], [-3, -3], [-3, -4], [-4, -2], [-4, -3]],
309 horns: [[-3, -1], [-4, -2], [-4, -3]]
310};
311const LEAF = [[7, -1], [8, -1], [8, -2], [9, -3], [10, -3], [6, -3]];
312const MARK_SHAPES = {
313 dot: [[0, 0]],
314 star: [[0, 0], [1, 1]],
315 heart: [[0, 0], [1, 0], [0, 1]],
316 scar: [[0, 0], [1, 0], [2, 0]],
317 sparkle: [[0, 0], [0, 1]],
318 swirl: [[0, 0], [1, 0], [1, 1]]
319};
320const MARK_COLORS = {
321 dot: [C.plum, C.navy, C.dusk, C.teal],
322 star: [C.yellow, C.white, C.cyan, C.orange],
323 heart: [C.red, C.plum, C.orange, C.yellow],
324 scar: [C.white, C.silver, C.cyan, C.yellow],
325 sparkle: [C.cyan, C.white, C.sky, C.yellow],
326 swirl: [C.teal, C.lime, C.green, C.sky]
327};
328const markAnchor = (spot, ey, inset, canHold) => {
329 if (spot === "left-cheek") return { x: 4 + inset, y: ey + 2, sx: -1, sy: 1 };
330 if (spot === "right-cheek") return { x: 11 - inset, y: ey + 2, sx: 1, sy: 1 };
331 if (spot === "forehead") return { x: 7, y: [ey - 1, ey].find((row) => canHold(7, row)) ?? ey - 1, sx: 1, sy: -1 };
332 const y = [ey + 5, ey + 4, ey + 3].find((row) => canHold(7, row)) ?? ey + 5;
333 return { x: 7, y, sx: 1, sy: 1 };
334};
335const PATTERN_SALT = 2654435769;
336const PLACE_ATTEMPTS = 8;
337const SHINY_SPARKLE = [[14, 1, C.white], [15, 0, C.cyan]];
338const halves = (g, stage) => ({
339 w: Math.max(stage === "adult" ? 3 : 4, g.halfW - SIZING[stage].dw),
340 h: Math.max(SIZING[stage].min, g.halfH - SIZING[stage].dh)
341});
342const insideBody = (g, w, h2, x, y) => {
343 const dx2 = 2 * x - 15;
344 const dy2 = 2 * y - 18;
345 const ellipse = dx2 * dx2 * h2 * h2 + dy2 * dy2 * w * w <= 4 * w * w * h2 * h2;
346 if (g.family === "mochi") return ellipse || y >= 9 && y <= 9 + h2 - 1 && Math.abs(dx2) * 25 <= 46 * w;
347 if (g.family === "critter") return ellipse || y === 9 + h2 && (Math.abs(dx2) === 5 || Math.abs(dx2) === 7);
348 const m5 = 5 * Math.max(0, 9 - y);
349 const k = 8 * h2 + m5;
350 return dx2 * dx2 * k * k + 64 * w * w * dy2 * dy2 <= 256 * w * w * h2 * h2;
351};
352const buildMask = (g, stage) => {
353 const { w, h: h2 } = halves(g, stage);
354 const mask = Array.from({ length: PET_SIZE }, () => Array(PET_SIZE).fill("none"));
355 for (let y = 1; y < PET_SIZE; y++) {
356 for (let x = 0; x < PET_SIZE; x++) if (insideBody(g, w, h2, x, y)) mask[y][x] = "body";
357 }
358 const top = bodyTop(mask);
359 const put = (x, y, cell) => {
360 if (y >= 0 && y < PET_SIZE && x >= 0 && x < PET_SIZE && mask[y][x] === "none") mask[y][x] = cell;
361 };
362 if (g.head === "leaf") {
363 if (g.hat === "none") for (const [x, dy] of LEAF) put(x, top + dy, "leaf");
364 } else if (g.head !== "none") {
365 for (const [ox, oy] of EAR_SHAPES[g.head]) {
366 for (const x of [8 + ox, 7 - ox]) put(x, top + 1 + oy, g.head === "horns" ? "horn" : "body");
367 }
368 }
369 return mask;
370};
371const bodyTop = (mask) => {
372 const y = mask.findIndex((row) => row.includes("body"));
373 return y < 0 ? 0 : y;
374};
375const bodyRows = (mask) => {
376 let top = PET_SIZE;
377 let bot = 0;
378 for (let y = 0; y < PET_SIZE; y++) {
379 if (!mask[y].includes("body")) continue;
380 if (y < top) top = y;
381 bot = y;
382 }
383 return { top: Math.min(top, bot), bot };
384};
385const drawPet = (g, stage, expression) => {
386 const [shade, base, hi] = (g.shiny ? SHINY_RAMPS : RAMPS)[g.ramp];
387 const mask = buildMask(g, stage);
388 const isBody = (x, y) => y >= 0 && y < PET_SIZE && x >= 0 && x < PET_SIZE && mask[y][x] === "body";
389 const px = blankGrid(PET_SIZE, PET_SIZE);
390 for (let y = 0; y < PET_SIZE; y++) {
391 for (let x = 0; x < PET_SIZE; x++) {
392 const cell = mask[y][x];
393 if (cell === "leaf") px[y][x] = C.green;
394 else if (cell === "horn") px[y][x] = C.yellow;
395 else if (cell === "body") {
396 const topLeft = !isBody(x - 1, y) || !isBody(x, y - 1);
397 const bottomRight = !isBody(x + 1, y) || !isBody(x, y + 1);
398 px[y][x] = topLeft && !bottomRight ? hi : bottomRight && !topLeft ? shade : base;
399 }
400 }
401 }
402 const { top, bot } = bodyRows(mask);
403 const ey = Math.max(1, (top + bot >> 1) - 1);
404 const isFace = (x, y) => y >= ey - 1 && y <= ey + 3 && x >= 3 && x <= 12;
405 const paintBody = (x, y, color) => {
406 if (isBody(x, y) && !isFace(x, y)) px[y][x] = color;
407 };
408 const rng = mulberry32(PATTERN_SALT + g.patternVariant >>> 0);
409 if (g.belly) {
410 for (let y = ey + 4; y < bot; y++) {
411 for (let x = 5; x <= 10; x++) {
412 const corner = y === bot - 1 && (x === 5 || x === 10);
413 if (!corner) paintBody(x, y, hi);
414 }
415 }
416 }
417 const inset = stage === "baby" ? 1 : 0;
418 const eyeColumns = [5 + inset, 10 - inset];
419 const inEyeZone = (x, y) => eyeColumns.some((sx) => y === ey && (x === sx || x === sx + (sx < 8 ? -1 : 1)) || y === ey + 1 && Math.abs(x - sx) <= 1);
420 const anchor = markAnchor(g.mark.spot, ey, inset, (x, y) => isBody(x, y) && !inEyeZone(x, y));
421 const markPixels = MARK_SHAPES[g.mark.motif].map(([dx, dy]) => [anchor.x + dx * anchor.sx, anchor.y + dy * anchor.sy]).filter(([x, y]) => isBody(x, y) && !inEyeZone(x, y));
422 const underMark = (x, y) => markPixels.some(([mx, my]) => mx === x && my === y);
423 const span = Math.max(1, bot - top - 3);
424 const free = (x, y) => isBody(x, y) && !isFace(x, y) && !underMark(x, y) && px[y][x] !== shade;
425 const place = (fits) => {
426 for (let attempt = 0; attempt < PLACE_ATTEMPTS; attempt++) {
427 const x = 3 + rng() % 10;
428 const y = top + 2 + rng() % span;
429 if (fits(x, y)) return [x, y];
430 }
431 return null;
432 };
433 if (g.pattern === "spots") {
434 const fits = (x, y) => free(x, y) && free(x + 1, y);
435 let landed = 0;
436 for (let i = 0; i < 4; i++) {
437 const at = place(fits);
438 if (at === null) continue;
439 px[at[1]][at[0]] = shade;
440 px[at[1]][at[0] + 1] = shade;
441 landed++;
442 }
443 for (let y = bot; landed === 0 && y >= top; y--) {
444 for (let x = 3; x <= 11 && landed === 0; x++) {
445 if (!fits(x, y)) continue;
446 px[y][x] = shade;
447 px[y][x + 1] = shade;
448 landed++;
449 }
450 }
451 } else if (g.pattern === "stripes") {
452 const v = g.patternVariant;
453 for (let y = top + 1 + (v & 1); y < ey - 1; y += 2) {
454 for (let x = 6 - (v >> 1 & 1); x <= 9 + (v >> 2 & 1); x++) paintBody(x, y, shade);
455 }
456 } else if (g.pattern === "stars" || g.pattern === "constellation") {
457 const count2 = g.pattern === "stars" ? 3 : 5;
458 const star = g.ramp === "ember" || g.ramp === "gold" ? C.white : C.yellow;
459 for (let i = 0; i < count2; i++) {
460 const color = g.pattern === "constellation" && i % 2 === 1 ? C.white : star;
461 const at = place(free);
462 if (at !== null) px[at[1]][at[0]] = color;
463 }
464 }
465 const face = (x, y, color) => setCell(px, x, y, color);
466 for (const sx of eyeColumns) {
467 const out = sx < 8 ? -1 : 1;
468 if (expression === "blink" || expression === "sleep") {
469 face(sx, ey + 1, C.ink);
470 face(sx + out, ey + 1, C.ink);
471 } else if (expression === "happy") {
472 face(sx, ey, C.ink);
473 face(sx - 1, ey + 1, C.ink);
474 face(sx + 1, ey + 1, C.ink);
475 } else if (expression === "sad") {
476 face(sx, ey + 1, C.ink);
477 face(sx + out, ey, C.ink);
478 } else if (expression === "surprised") {
479 face(sx, ey, C.ink);
480 face(sx, ey + 1, C.ink);
481 } else if (g.eyes === "dot") {
482 face(sx, ey, C.ink);
483 } else if (g.eyes === "big" || g.eyes === "glint") {
484 face(sx, ey, g.eyes === "glint" ? C.yellow : C.white);
485 face(sx, ey + 1, C.ink);
486 face(sx + out, ey, C.ink);
487 face(sx + out, ey + 1, C.ink);
488 } else if (g.eyes === "wide") {
489 face(sx, ey, C.white);
490 face(sx, ey + 1, C.ink);
491 } else if (g.eyes === "sleepy") {
492 face(sx, ey + 1, C.ink);
493 face(sx + out, ey + 1, C.ink);
494 } else {
495 face(sx, ey, C.red);
496 face(sx + out, ey, C.red);
497 face(sx, ey + 1, C.red);
498 }
499 }
500 if (g.pattern === "freckles") {
501 face(4 + inset, ey + 1, shade);
502 face(11 - inset, ey + 1, shade);
503 }
504 if (g.blush) {
505 const blush = g.ramp === "jam" ? C.plum : C.red;
506 face(4 + inset, ey + 2, blush);
507 face(11 - inset, ey + 2, blush);
508 }
509 const markColor = MARK_COLORS[g.mark.motif].find((c) => c !== shade && c !== base && c !== hi);
510 for (const [x, y] of markPixels) px[y][x] = markColor;
511 if (expression === "happy") {
512 face(6, ey + 2, C.ink);
513 face(9, ey + 2, C.ink);
514 face(7, ey + 3, C.ink);
515 face(8, ey + 3, C.ink);
516 } else if (expression === "sad") {
517 face(7, ey + 2, C.ink);
518 face(8, ey + 2, C.ink);
519 face(6, ey + 3, C.ink);
520 face(9, ey + 3, C.ink);
521 } else if (expression === "surprised") {
522 face(7, ey + 2, C.ink);
523 face(8, ey + 2, C.ink);
524 face(7, ey + 3, C.ink);
525 face(8, ey + 3, C.ink);
526 } else {
527 face(7, ey + 2, C.ink);
528 face(8, ey + 2, C.ink);
529 }
530 if (g.hat === "bow") {
531 for (const [x, y] of [[9, top - 1], [10, top - 1], [11, top - 1], [9, top - 2], [11, top - 2]]) {
532 setCell(px, x, y, C.red);
533 }
534 } else if (g.hat === "crown") {
535 for (let x = 5; x <= 10; x++) setCell(px, x, top - 1, C.yellow);
536 for (const x of [5, 7, 8, 10]) setCell(px, x, top - 2, C.yellow);
537 }
538 const outlined = px.map((row) => [...row]);
539 for (let y = 0; y < PET_SIZE; y++) {
540 for (let x = 0; x < PET_SIZE; x++) {
541 if (px[y][x] !== null) continue;
542 const touches = [[-1, 0], [1, 0], [0, -1], [0, 1]].some(
543 ([dx, dy]) => inBounds(px, x + dx, y + dy) && px[y + dy][x + dx] !== null
544 );
545 if (touches) outlined[y][x] = C.ink;
546 }
547 }
548 if (g.shiny) for (const [x, y, color] of SHINY_SPARKLE) outlined[y][x] = color;
549 return outlined;
550};
551
552// ../../packages/core/src/scene.ts
553const SCENE_W = 32;
554const SCENE_H = 16;
555const GROUND = SCENE_H - 1;
556const clamp = (v, min, max) => Number.isFinite(v) ? Math.min(max, Math.max(min, Math.trunc(v))) : min;
557const BUG = [
558 [0, 0, C.lime],
559 [4, 0, C.lime],
560 [2, 0, C.ink],
561 [1, 1, C.lime],
562 [2, 1, C.lime],
563 [3, 1, C.lime]
564];
565const HEART = [
566 [1, 0],
567 [3, 0],
568 [0, 1],
569 [1, 1],
570 [2, 1],
571 [3, 1],
572 [4, 1],
573 [1, 2],
574 [2, 2],
575 [3, 2],
576 [2, 3]
577];
578const drawGround = (scene) => {
579 for (let x = 0; x < SCENE_W; x++) scene[GROUND][x] = x % 2 === 0 ? C.slate : C.dusk;
580};
581const LOOT = [[1, 0, C.yellow], [0, 1, C.yellow], [1, 1, C.white], [2, 1, C.yellow], [1, 2, C.yellow]];
582const BOX_W = 6;
583const BOX_H = 5;
584const BOX_Y = 8;
585const BOX = [];
586for (let y = 0; y < BOX_H; y++) {
587 for (let x = 0; x < BOX_W; x++) {
588 const edge = x === 0 || y === 0 || x === BOX_W - 1 || y === BOX_H - 1;
589 const inner = y === 1 ? x === 2 || x === 3 ? C.yellow : C.orange : y === 2 ? C.orange : C.red;
590 BOX.push([x, y, edge ? C.ink : inner]);
591 }
592}
593const ZED = [[0, 0], [1, 0], [2, 0], [1, 1], [0, 2], [1, 2], [2, 2]];
594const LOOT_X = 13;
595const LOOT_Y = 2;
596const crownOf = (pet) => {
597 for (let y = 0; y < pet.length; y++) {
598 const xs = [];
599 for (let x = 0; x < pet[y].length; x++) if (pet[y][x] !== null && !(y <= 3 && x >= 13)) xs.push(x);
600 if (xs.length > 0) return { top: y, mid: Math.floor((xs[0] + xs[xs.length - 1]) / 2) };
601 }
602 return null;
603};
604const drawNightcap = (scene, pet, petX, lift) => {
605 const crown = crownOf(pet);
606 if (!crown) return;
607 const y = crown.top - lift;
608 const x = petX + crown.mid;
609 for (let dx = -1; dx <= 1; dx++) setCell(scene, x + dx, y, C.blue);
610 for (let dx = -2; dx <= 2; dx++) setCell(scene, x + dx, y + 1, C.blue);
611 setCell(scene, x + 2, y, C.white);
612};
613const drawBox = (scene, pet, petX, lift) => {
614 let left = PET_SIZE;
615 let right = -1;
616 for (let y = BOX_Y + lift; y < BOX_Y + BOX_H + lift && y < PET_SIZE; y++) {
617 for (let x = 0; x < PET_SIZE; x++) {
618 if (pet[y][x] === null) continue;
619 left = Math.min(left, x);
620 right = Math.max(right, x);
621 }
622 }
623 if (right < 0) return;
624 const onRight = petX + right + 1 + BOX_W <= SCENE_W;
625 const bx = onRight ? petX + right + 1 : petX + left - BOX_W;
626 for (const [dx, dy, color] of BOX) setCell(scene, bx + dx, BOX_Y + dy, color);
627};
628const drawScene = (g, opts = {}) => {
629 const scene = blankGrid(SCENE_W, SCENE_H);
630 drawGround(scene);
631 const petX = clamp(opts.petX ?? 6, 0, SCENE_W - PET_SIZE);
632 const lift = clamp(opts.lift ?? 0, 0, 3);
633 const frame = clamp(opts.frame ?? 0, 0, 1e6);
634 const isHome = opts.away !== true;
635 if (isHome) {
636 for (let x = petX + 4; x <= petX + 11; x++) setCell(scene, x, GROUND - 1, C.dusk);
637 const pet = drawPet(g, opts.stage ?? "adult", opts.expression ?? "idle");
638 for (let y = 0; y < PET_SIZE; y++) {
639 const sy = y - lift;
640 if (sy < 0 || sy >= GROUND) continue;
641 for (let x = 0; x < PET_SIZE; x++) {
642 const cell = pet[y][x];
643 if (cell !== null) scene[sy][petX + x] = cell;
644 }
645 }
646 if (opts.nightcap) drawNightcap(scene, pet, petX, lift);
647 if (opts.box) drawBox(scene, pet, petX, lift);
648 }
649 const bugs = clamp(opts.bugs ?? 0, 0, 3);
650 for (let i = 0; i < bugs; i++) {
651 const bx = SCENE_W - 6 - i * 6;
652 for (const [dx, dy, color] of BUG) setCell(scene, bx + dx, GROUND - 2 + dy, color);
653 }
654 if (opts.heart) for (const [dx, dy] of HEART) setCell(scene, SCENE_W - 8 + dx, 2 + dy, C.red);
655 if (isHome) {
656 if (g.tier === "legendary") {
657 const on = frame % 2 === 0;
658 setCell(scene, petX + 1, on ? 2 : 5, C.yellow);
659 setCell(scene, petX + 14, on ? 5 : 2, C.white);
660 }
661 if (g.shiny) {
662 const [x, y] = frame % 2 === 0 ? [petX + 15, 2] : [petX + 13, 0];
663 if (scene[y][x] === null) scene[y][x] = C.white;
664 }
665 if (opts.loot) for (const [dx, dy, color] of LOOT) setCell(scene, petX + LOOT_X + dx, LOOT_Y + dy, color);
666 if (opts.zzz) {
667 const [zx, zy] = frame % 2 === 0 ? [14, 2] : [15, 3];
668 for (const [dx, dy] of ZED) setCell(scene, petX + zx + dx, zy + dy, C.white);
669 }
670 }
671 return scene;
672};
673
674// ../../packages/core/src/egg.ts
675const CRACKS = [
676 [],
677 [[7, 4], [8, 5], [7, 6]],
678 [[7, 4], [8, 5], [7, 6], [9, 7], [10, 8], [9, 9]],
679 [[7, 4], [8, 5], [7, 6], [9, 7], [10, 8], [9, 9], [5, 8], [6, 9], [5, 10]]
680];
681const insideEgg = (x, y) => {
682 const dx2 = 2 * x - 15;
683 const dy2 = 2 * y - 17;
684 return dx2 * dx2 * 36 + dy2 * dy2 * 25 <= 3600;
685};
686const drawEgg = (cracks) => {
687 const egg = blankGrid(PET_SIZE, PET_SIZE);
688 for (let y = 0; y < PET_SIZE; y++) {
689 for (let x = 0; x < PET_SIZE; x++) {
690 if (!insideEgg(x, y)) continue;
691 const bottomRight = !insideEgg(x + 1, y) || !insideEgg(x, y + 1);
692 egg[y][x] = bottomRight ? C.silver : C.white;
693 }
694 }
695 for (const [x, y] of [[6, 6], [9, 10], [5, 11]]) setCell(egg, x, y, C.sky);
696 for (const [x, y] of CRACKS[cracks]) setCell(egg, x, y, C.ink);
697 const outlined = egg.map((row) => [...row]);
698 for (let y = 0; y < PET_SIZE; y++) {
699 for (let x = 0; x < PET_SIZE; x++) {
700 if (egg[y][x] !== null) continue;
701 const touches = [[-1, 0], [1, 0], [0, -1], [0, 1]].some(
702 ([dx, dy]) => inBounds(egg, x + dx, y + dy) && egg[y + dy][x + dx] !== null
703 );
704 if (touches) outlined[y][x] = C.ink;
705 }
706 }
707 return outlined;
708};
709const drawEggScene = (cracks, frame = 0) => {
710 const scene = blankGrid(SCENE_W, SCENE_H);
711 drawGround(scene);
712 const wobble = cracks > 0 && frame % 2 === 1 ? 1 : 0;
713 const egg = drawEgg(cracks);
714 for (let y = 0; y < PET_SIZE - 1; y++) {
715 for (let x = 0; x < PET_SIZE; x++) {
716 const cell = egg[y][x];
717 if (cell !== null) setCell(scene, 8 + x + wobble, y, cell);
718 }
719 }
720 return scene;
721};
722
723// ../../packages/core/src/genes.ts
724const opt = (value, weight, minTier = "common") => ({ value, weight, minTier });
725const POOLS = {
726 family: [opt("mochi", 40), opt("critter", 40), opt("sprout", 20)],
727 halfW: [opt(5, 1), opt(6, 2), opt(7, 1)],
728 halfH: [opt(5, 1), opt(6, 2), opt(7, 1)],
729 ramp: [
730 opt("ember", 30),
731 opt("moss", 30),
732 opt("ocean", 30),
733 opt("frost", 20, "uncommon"),
734 opt("ghost", 20, "uncommon"),
735 opt("jam", 20, "rare"),
736 opt("gold", 10, "epic"),
737 opt("aurora", 10, "legendary")
738 ],
739 pattern: [
740 opt("none", 60),
741 opt("spots", 40),
742 opt("freckles", 4),
743 opt("stripes", 30, "uncommon"),
744 opt("stars", 20, "rare"),
745 opt("constellation", 10, "epic")
746 ],
747 belly: [opt(true, 60), opt(false, 40)],
748 eyes: [
749 opt("dot", 40),
750 opt("big", 40),
751 opt("wide", 30),
752 opt("heart", 4),
753 opt("sleepy", 20, "uncommon"),
754 opt("glint", 10, "rare")
755 ],
756 blush: [opt(true, 50), opt(false, 50)],
757 head: [
758 opt("none", 30),
759 opt("round", 30),
760 opt("pointy", 30),
761 opt("bunny", 20, "uncommon"),
762 opt("horns", 10, "rare")
763 ]
764};
765const HAT_BY_TIER = {
766 common: "none",
767 uncommon: "none",
768 rare: "none",
769 epic: "bow",
770 legendary: "crown"
771};
772const MARK_MOTIFS = ["dot", "star", "heart", "scar", "sparkle", "swirl"];
773const MARK_SPOTS = ["left-cheek", "right-cheek", "forehead", "belly"];
774
775// ../../packages/core/src/odds.ts
776const TIERS = ["common", "uncommon", "rare", "epic", "legendary"];
777const BP = 1e4;
778const TIER_BP = { common: 4e3, uncommon: 3e3, rare: 1800, epic: 900, legendary: 300 };
779const SHINY_BP = 400;
780const isTier = (x) => typeof x === "string" && TIERS.includes(x);
781const tierRank = (t) => TIERS.indexOf(t);
782const tierProbability = (t) => TIER_BP[t] / BP;
783
784// ../../packages/core/src/genome.ts
785const PATTERN_VARIANTS = 16;
786const SHINY_PROBABILITY = SHINY_BP / BP;
787const MARK_PROBABILITY = 1 / (MARK_MOTIFS.length * MARK_SPOTS.length);
788const eligible = (pool, tier) => pool.filter((o) => tierRank(o.minTier) <= tierRank(tier));
789const pick = (rng, pool, tier) => {
790 const options = eligible(pool, tier);
791 const total = options.reduce((s, o) => s + o.weight, 0);
792 let r = pickIndex(rng, total);
793 for (const o of options) {
794 if (r < o.weight) return o.value;
795 r -= o.weight;
796 }
797 return options[options.length - 1].value;
798};
799const conditional = (pool, tier, value) => {
800 const options = eligible(pool, tier);
801 const match = options.find((o) => o.value === value);
802 return match ? match.weight / options.reduce((s, o) => s + o.weight, 0) : 0;
803};
804const table = (values, given) => new Map(values.map((v) => [v, TIERS.reduce((sum, t) => sum + tierProbability(t) * given(t, v), 0)]));
805const poolTable = (pool) => table(pool.map((o) => String(o.value)), (t, v) => conditional(pool, t, pool.find((o) => String(o.value) === v).value));
806const sproutGiven = (t) => conditional(POOLS.family, t, "sprout");
807const ODDS = {
808 family: poolTable(POOLS.family),
809 ramp: poolTable(POOLS.ramp),
810 pattern: poolTable(POOLS.pattern),
811 eyes: poolTable(POOLS.eyes),
812 head: table([...POOLS.head.map((o) => o.value), "leaf"], (t, v) => {
813 if (v === "leaf") return sproutGiven(t);
814 const head = HAT_BY_TIER[t] === "none" ? conditional(POOLS.head, t, v) : v === "none" ? 1 : 0;
815 return (1 - sproutGiven(t)) * head;
816 }),
817 hat: table([...new Set(Object.values(HAT_BY_TIER))], (t, v) => HAT_BY_TIER[t] === v ? 1 : 0)
818};
819const markLabel = (m) => `${m.motif} on ${m.spot.replace("-", " ")}`;
820const traitOdds = (g) => {
821 const of = (gene, value) => ({ gene, value, probability: ODDS[gene].get(value) ?? 0 });
822 const odds = [
823 of("family", g.family),
824 of("ramp", g.ramp),
825 of("pattern", g.pattern),
826 of("eyes", g.eyes),
827 of("head", g.head),
828 of("hat", g.hat),
829 { gene: "mark", value: markLabel(g.mark), probability: MARK_PROBABILITY }
830 ];
831 if (g.shiny) odds.push({ gene: "shiny", value: "shiny", probability: SHINY_PROBABILITY });
832 return odds;
833};
834const rarestTrait = (g) => traitOdds(g).reduce((min, t) => t.probability < min.probability ? t : min);
835const genome = (seed, tier, shiny) => {
836 if (!isTier(tier)) throw new RangeError(`unknown tier: ${String(tier)}`);
837 const normalized = seed >>> 0;
838 const rng = mulberry32(normalized);
839 const family = pick(rng, POOLS.family, tier);
840 const g = {
841 seed: normalized,
842 tier,
843 shiny,
844 family,
845 halfW: pick(rng, POOLS.halfW, tier),
846 halfH: pick(rng, POOLS.halfH, tier),
847 ramp: pick(rng, POOLS.ramp, tier),
848 pattern: pick(rng, POOLS.pattern, tier),
849 belly: pick(rng, POOLS.belly, tier),
850 eyes: pick(rng, POOLS.eyes, tier),
851 blush: pick(rng, POOLS.blush, tier),
852 head: pick(rng, POOLS.head, tier),
853 hat: HAT_BY_TIER[tier],
854 mark: { motif: MARK_MOTIFS[pickIndex(rng, MARK_MOTIFS.length)], spot: MARK_SPOTS[pickIndex(rng, MARK_SPOTS.length)] },
855 patternVariant: pickIndex(rng, PATTERN_VARIANTS)
856 };
857 if (family === "sprout") g.head = "leaf";
858 if (g.hat !== "none" && family !== "sprout") g.head = "none";
859 return g;
860};
861
862// ../../packages/core/src/progression.ts
863const HOUR_MS = 36e5;
864const SYNC_GRACE_MS = 3 * HOUR_MS;
865const FIRST_SYNC_LOOKBACK_MS = 24 * HOUR_MS;
866const MAX_LEVEL = 99;
867const XP_PER_EVENT = { pet: 2, turn: 3, check_pass: 2, commit: 2, error: 0 };
868const CAPS_PER_HOUR = { pet: 5, turn: 20, check_pass: 20, commit: 10 };
869const hourOf = (at) => Math.floor(at / HOUR_MS);
870const isEventType = (t) => typeof t === "string" && Object.prototype.hasOwnProperty.call(XP_PER_EVENT, t);
871const scoreEvents = (events, windows, now, lastSyncAt) => {
872 if (!Array.isArray(events) || !Number.isFinite(now) || lastSyncAt !== null && !Number.isFinite(lastSyncAt)) {
873 return { xpGained: 0, windows: Object.fromEntries(Object.entries(windows).map(([h2, w]) => [h2, { ...w }])), accepted: 0 };
874 }
875 const oldest = lastSyncAt === null ? now - FIRST_SYNC_LOOKBACK_MS : lastSyncAt - SYNC_GRACE_MS;
876 const next = Object.fromEntries(Object.entries(windows).map(([h2, w]) => [h2, { ...w }]));
877 let xpGained = 0;
878 let accepted = 0;
879 for (const e of events) {
880 if (!e || typeof e !== "object") continue;
881 if (!isEventType(e.type) || !Number.isFinite(e.at) || e.at > now || e.at < oldest) continue;
882 if (e.type === "error") continue;
883 const hour = hourOf(e.at);
884 const window = next[hour] ??= {};
885 const used = window[e.type] ?? 0;
886 if (used >= CAPS_PER_HOUR[e.type]) continue;
887 window[e.type] = used + 1;
888 xpGained += XP_PER_EVENT[e.type];
889 accepted++;
890 }
891 return { xpGained, windows: next, accepted };
892};
893const xpToNext = (level) => Math.round(10 * level ** 1.4);
894const levelFromXp = (xp) => {
895 const safe = Number.isFinite(xp) ? Math.max(0, Math.floor(xp)) : 0;
896 let level = 1;
897 let rest = safe;
898 while (rest >= xpToNext(level) && level < MAX_LEVEL) {
899 rest -= xpToNext(level);
900 level++;
901 }
902 return { level, intoLevel: rest, toNext: xpToNext(level) };
903};
904const stageForLevel = (level) => level >= 25 ? "adult" : level >= 10 ? "teen" : "baby";
905
906// src/pet.ts
907const DEFAULT_NAME = "Nibbl";
908const TOKEN = /^[A-Za-z0-9_-]{43}$/;
909const isInt = (v, min, max = Number.MAX_SAFE_INTEGER) => typeof v === "number" && Number.isInteger(v) && v >= min && v <= max;
910const isText = (v) => v === null || typeof v === "string";
911const isServerPet = (v) => {
912 if (typeof v !== "object" || v === null) return false;
913 const p = v;
914 return isInt(p.serial, 1, 999999) && typeof p.token === "string" && TOKEN.test(p.token) && isInt(p.seed, -(2 ** 31), 4294967295) && isTier(p.tier) && typeof p.shiny === "boolean" && typeof p.genesis === "boolean" && isInt(p.hatchedAt, 0) && isText(p.name) && isText(p.label) && isInt(p.xp, 0) && isInt(p.level, 1) && isInt(p.heartsHour, 0) && isInt(p.heartsUsed, 0, 5) && (p.lastSyncAt === null || isInt(p.lastSyncAt, 0));
915};
916const spikePetOf = (v) => {
917 if (typeof v !== "object" || v === null) return null;
918 const p = v;
919 if ("serial" in p) return null;
920 if (!isInt(p.seed, -(2 ** 31), 4294967295) || !isTier(p.tier) || typeof p.shiny !== "boolean") return null;
921 return {
922 seed: p.seed >>> 0,
923 tier: p.tier,
924 shiny: p.shiny,
925 hatchedAt: isInt(p.hatchedAt, 0) ? p.hatchedAt : 0,
926 xp: isInt(p.xp, 0) ? p.xp : 0,
927 name: typeof p.name === "string" && p.name.length > 0 ? p.name : DEFAULT_NAME
928 };
929};
930const fromOwnerView = (body, token, now) => {
931 const pet = {
932 serial: body.serial,
933 token,
934 seed: body.seed,
935 tier: body.tier,
936 shiny: body.shiny,
937 genesis: body.genesis,
938 hatchedAt: body.hatchedAt,
939 name: body.name ?? null,
940 label: body.label ?? null,
941 xp: body.xp,
942 level: body.level,
943 heartsHour: Math.floor(now / HOUR_MS),
944 heartsUsed: 0,
945 lastSyncAt: null
946 };
947 return isServerPet(pet) ? { ...pet, seed: pet.seed >>> 0 } : null;
948};
949const displayName = (name) => name && name.length > 0 ? name : DEFAULT_NAME;
950const padSerial = (serial) => String(serial).padStart(6, "0");
951const exportCode = (p) => `nibbl1:${p.serial}:${p.token}`;
952const CODE = /^nibbl1:(\d{1,6}):([A-Za-z0-9_-]{43})$/;
953const parseCode = (raw) => {
954 const m = CODE.exec(raw.trim());
955 if (!m) return null;
956 const serial = Number(m[1]);
957 return serial >= 1 ? { serial, token: m[2] } : null;
958};
959const viewOf = (pet, xp, heartsHour, heartsUsed) => "serial" in pet ? {
960 serial: pet.serial,
961 seed: pet.seed,
962 tier: pet.tier,
963 shiny: pet.shiny,
964 genesis: pet.genesis,
965 name: displayName(pet.name),
966 label: pet.label,
967 xp,
968 heartsHour,
969 heartsUsed
970} : { serial: null, seed: pet.seed, tier: pet.tier, shiny: pet.shiny, genesis: false, name: pet.name, label: null, xp, heartsHour, heartsUsed };
971
972// src/reactions.ts
973const CALM = { bugs: 0, mood: "idle", moodUntil: 0, heartUntil: 0, lootUntil: 0, boxUntil: 0, awaySince: 0, lastActiveAt: 0 };
974const onTurnStart = (r, now) => ({ ...r, awaySince: r.awaySince || now, lastActiveAt: now });
975const onTurnEnd = (r, now, isAnswer) => ({
976 ...r,
977 awaySince: 0,
978 lootUntil: isAnswer ? now + LOOT_MS : r.lootUntil,
979 lastActiveAt: now
980});
981const onError = (r, now) => ({
982 ...r,
983 bugs: Math.min(MAX_BUGS, r.bugs + 1),
984 mood: "sad",
985 moodUntil: now + MOOD_MS,
986 lastActiveAt: now
987});
988const onCheckPass = (r, now) => ({ ...r, bugs: 0, mood: "happy", moodUntil: now + MOOD_MS, lastActiveAt: now });
989const onCommit = (r, now) => ({ ...r, boxUntil: now + BOX_MS, lastActiveAt: now });
990const onPet = (r, now) => ({ ...r, heartUntil: now + HEART_MS, lastActiveAt: now });
991const onActive = (r, now) => ({ ...r, lastActiveAt: now });
992const parseUtcOffset = (stdout) => {
993 const m = /^([+-])(\d{2})(\d{2})$/.exec(stdout.trim());
994 if (!m) return null;
995 const minutes = Number(m[2]) * 60 + Number(m[3]);
996 return m[1] === "-" ? -minutes : minutes;
997};
998const localHour = (now, offsetMin) => {
999 const minutes = Math.floor(now / 6e4) + offsetMin;
1000 const ofDay = (minutes % 1440 + 1440) % 1440;
1001 return Math.floor(ofDay / 60);
1002};
1003const isNight = (now, offsetMin) => {
1004 const hour = localHour(now, offsetMin);
1005 return hour >= NIGHT_FROM_HOUR && hour < NIGHT_TO_HOUR;
1006};
1007const lookOf = (r, now, offsetMin) => {
1008 if (r.awaySince > 0) return "away";
1009 if (r.mood === "sad" && now < r.moodUntil) return "oops";
1010 if (now < r.heartUntil) return "loved";
1011 if (r.mood === "happy" && now < r.moodUntil) return "yum";
1012 if (now < r.boxUntil) return "shipped";
1013 if (now < r.lootUntil) return "loot";
1014 if (r.lastActiveAt > 0 && now - r.lastActiveAt > SLEEP_AFTER_MS) return "zzz";
1015 if (isNight(now, offsetMin)) return "night";
1016 return "chilling";
1017};
1018const STATUS = {
1019 oops: "oops, a bug",
1020 loved: "loved that",
1021 yum: "yum, bugs eaten",
1022 shipped: "shipped a commit",
1023 loot: "back with loot",
1024 zzz: "zzz",
1025 night: "nightcap on",
1026 chilling: "chilling"
1027};
1028const clockText = (ms) => {
1029 const total = Math.max(0, Math.floor(ms / 1e3));
1030 const seconds = String(total % 60).padStart(2, "0");
1031 const minutes = Math.floor(total / 60);
1032 return minutes >= 60 ? `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`;
1033};
1034const idleTextAt = (hour) => hour >= 5 && hour <= 11 ? "sipping coffee" : hour >= 12 && hour <= 17 ? "sunbathing" : hour >= 18 && hour <= 22 ? "reading the logs" : STATUS.chilling;
1035const statusText = (look, now, awaySince, offsetMin) => {
1036 if (look === "away") return `\u26CF on expedition ${clockText(now - awaySince)}`;
1037 if (look === "chilling" && offsetMin !== void 0) return idleTextAt(localHour(now, offsetMin));
1038 return STATUS[look];
1039};
1040const sceneKeyOf = (r, stage, anim, now, offsetMin) => {
1041 const look = lookOf(r, now, offsetMin);
1042 const isWalking = anim.petX !== (r.awaySince > 0 ? AWAY_X : HOME_X);
1043 const isSleeping = look === "zzz";
1044 const night = isNight(now, offsetMin);
1045 const hasLoot = now < r.lootUntil;
1046 let expression = anim.blink ? "blink" : "idle";
1047 if (look === "oops") expression = "sad";
1048 else if (look === "loved" || look === "yum") expression = "happy";
1049 else if (isSleeping) expression = "sleep";
1050 else if (night && anim.frame % 16 < 2) expression = "surprised";
1051 return {
1052 stage,
1053 expression,
1054 petX: anim.petX,
1055 // loot and zzz write at fixed scene rows, so the pet never hops while either shows
1056 lift: isSleeping || hasLoot ? 0 : isWalking ? anim.frame % 2 : Math.floor(anim.frame / 2) % 2,
1057 bugs: r.bugs,
1058 heart: now < r.heartUntil,
1059 frame: anim.frame % 2,
1060 away: r.awaySince > 0 && !isWalking,
1061 loot: hasLoot,
1062 box: now < r.boxUntil,
1063 nightcap: night,
1064 zzz: isSleeping
1065 };
1066};
1067const stepAnim = (anim, r, roll) => {
1068 const target = r.awaySince > 0 ? AWAY_X : HOME_X;
1069 const petX = anim.petX < target ? anim.petX + 1 : anim.petX > target ? anim.petX - 1 : anim.petX;
1070 return { frame: (anim.frame + 1) % 1e5, petX, blink: !anim.blink && roll < 0.08 };
1071};
1072
1073// src/hud.ts
1074const xpBar = (into, toNext, width = 5) => {
1075 const filled = toNext > 0 ? Math.min(width, Math.round(width * into / toNext)) : 0;
1076 return "\u2593".repeat(filled) + "\u2591".repeat(width - filled);
1077};
1078const heartsBar = (left, max = CAPS_PER_HOUR.pet) => "\u2665".repeat(left) + "\u2661".repeat(Math.max(0, max - left));
1079const pct = (p) => {
1080 const v = p * 100;
1081 return v >= 10 ? v.toFixed(0) : v >= 1 ? v.toFixed(1) : v.toFixed(2);
1082};
1083const rarestLine = (g) => {
1084 const t = rarestTrait(g);
1085 const label = t.gene === "mark" || t.gene === "shiny" ? t.value : `${t.value} ${t.gene}`;
1086 return `${label} ${pct(t.probability)}% odds`;
1087};
1088const tierText = (p) => `${p.tier}${p.shiny ? " \xB7 shiny" : ""}${p.genesis ? " \xB7 Genesis" : ""}`;
1089const serialOf = (p) => typeof p.serial === "number" && Number.isInteger(p.serial) ? p.serial : null;
1090const titleOf = (p) => {
1091 const serial = serialOf(p);
1092 return serial === null ? `${p.name} \xB7 syncing` : `${p.name} #${padSerial(serial)}${p.label ? ` \xB7 ${p.label}` : ""}`;
1093};
1094const xpLineOf = (xp) => {
1095 const lv = levelFromXp(xp);
1096 return `lvl ${lv.level} ${xpBar(lv.intoLevel, lv.toNext)} ${lv.intoLevel}/${lv.toNext}`;
1097};
1098const heartsLeftOf = (p, now) => Math.floor(now / HOUR_MS) === p.heartsHour ? Math.max(0, CAPS_PER_HOUR.pet - p.heartsUsed) : CAPS_PER_HOUR.pet;
1099const hudLines = (view, turns, r, now, tz) => {
1100 if (!view) {
1101 const shown = Math.min(turns, HATCH_AFTER_TURNS);
1102 const status2 = turns >= HATCH_AFTER_TURNS ? "hatching..." : "keep working to hatch it";
1103 return { title: "Egg", xp: `turns ${shown}/${HATCH_AFTER_TURNS}`, hearts: "", status: status2, compact: `Egg \xB7 turns ${shown}/${HATCH_AFTER_TURNS} \xB7 ${status2}` };
1104 }
1105 const hearts = heartsBar(heartsLeftOf(view, now));
1106 const status = statusText(lookOf(r, now, tz), now, r.awaySince, tz);
1107 const serial = serialOf(view);
1108 const short = serial === null ? view.name : `${view.name} #${padSerial(serial)}`;
1109 return { title: titleOf(view), xp: xpLineOf(view.xp), hearts, status, compact: `${short} \xB7 lvl ${levelFromXp(view.xp).level} ${hearts} \xB7 ${status}` };
1110};
1111const hudKey = (lines) => Object.values(lines).join("\n");
1112const utcText = (at) => `${new Date(at).toISOString().slice(0, 16).replace("T", " ")} UTC`;
1113const cardUrl = (base, serial) => `${base}/p/${serial}`;
1114const boardUrl = (base, serial) => `${base}/leaderboard/?me=${serial}`;
1115const stageLine = (level) => {
1116 const stage = stageForLevel(level);
1117 if (stage === "baby") return "stage: baby (teen at level 10)";
1118 return `stage: ${stage} (since level ${stage === "teen" ? 10 : 25})`;
1119};
1120const statsText = (view, g, now, sync) => {
1121 const lv = levelFromXp(view.xp);
1122 const lines = [
1123 titleOf(view),
1124 tierText(view),
1125 `lvl ${lv.level}, ${view.xp} xp (${lv.intoLevel}/${lv.toNext} to next) ${xpBar(lv.intoLevel, lv.toNext)}`,
1126 stageLine(lv.level),
1127 `hearts this hour: ${heartsBar(heartsLeftOf(view, now))}`,
1128 `rarest trait: ${rarestLine(g)}`
1129 ];
1130 const serial = serialOf(view);
1131 if (serial === null) lines.push("not on the server yet: it links on the next hatch call");
1132 else lines.push(`card: ${cardUrl(sync.base, serial)}`, `leaderboard: ${boardUrl(sync.base, serial)}`);
1133 lines.push(...sync.extra ?? []);
1134 const waiting = `${sync.pending} event${sync.pending === 1 ? "" : "s"} waiting`;
1135 lines.push(`sync: ${waiting}, ${sync.lastSyncAt === null ? "never synced" : `last sync ${utcText(sync.lastSyncAt)}`}`);
1136 return lines.join("\n");
1137};
1138const eggStatsText = (turns) => `Egg: ${Math.min(turns, HATCH_AFTER_TURNS)}/${HATCH_AFTER_TURNS} answered turns. It hatches after ${HATCH_AFTER_TURNS}.`;
1139const oddsText = (view, g) => {
1140 const lines = [
1141 "Hatch odds (rolled on the server, the same for everyone):",
1142 TIERS.map((t) => `${t} ${TIER_BP[t] / 100}%`).join(" \xB7 "),
1143 `shiny ${SHINY_BP / 100}% on top of any tier`
1144 ];
1145 if (!view || !g) return [...lines, "Your egg shows its traits when it hatches."].join("\n");
1146 lines.push(`${view.name}'s traits:`);
1147 for (const t of traitOdds(g)) lines.push(` ${t.gene} ${t.value} ${pct(t.probability)}%`);
1148 lines.push(`rarest: ${rarestLine(g)}`);
1149 return lines.join("\n");
1150};
1151
1152// src/identity.ts
1153const HEX = "0123456789abcdef";
1154const toHex = (buffer) => {
1155 let out = "";
1156 for (const byte of new Uint8Array(buffer)) out += HEX[byte >> 4] + HEX[byte & 15];
1157 return out;
1158};
1159const sha256Hex = async (text) => toHex(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
1160const machineHashOf = (platformId) => sha256Hex(`${platformId}nibbl`);
1161const parseIoreg = (stdout) => /"IOPlatformUUID" = "([0-9A-F-]+)"/.exec(stdout)?.[1] ?? null;
1162const parseMachineId = (text) => {
1163 const id = text.trim();
1164 return /^[0-9a-f]{32}$/.test(id) ? id : null;
1165};
1166const parseMachineGuid = (stdout) => /MachineGuid\s+REG_SZ\s+([0-9A-Fa-f-]{36})/.exec(stdout)?.[1] ?? null;
1167const readPlatformId = async ($) => {
1168 try {
1169 const mac = await $.process.run(["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"], { timeoutMs: 5e3 });
1170 const id = mac.exitCode === 0 ? parseIoreg(mac.stdout) : null;
1171 if (id) return id;
1172 } catch {
1173 }
1174 try {
1175 const id = parseMachineId(await $.fs.read("/etc/machine-id"));
1176 if (id) return id;
1177 } catch {
1178 }
1179 try {
1180 const id = parseMachineId(await $.fs.read("/var/lib/dbus/machine-id"));
1181 if (id) return id;
1182 } catch {
1183 }
1184 try {
1185 const win = await $.process.run(["reg", "query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"], { timeoutMs: 5e3 });
1186 const id = win.exitCode === 0 ? parseMachineGuid(win.stdout) : null;
1187 if (id) return id;
1188 } catch {
1189 }
1190 return null;
1191};
1192const HASH = /^[0-9a-f]{64}$/;
1193const machineIdentity = async ($) => {
1194 const saved = await $.store.get(K.installId);
1195 const pet = await $.store.get(K.pet);
1196 const installSerial = await $.store.get(K.installSerial);
1197 if (typeof saved === "string" && saved.length >= 16 && installSerial !== void 0 && pet?.serial === installSerial) {
1198 return { hash: await machineHashOf(saved), source: "install" };
1199 }
1200 const cached = await $.store.get(K.machineHash);types/index.d.ts 45 lines1// The PluginState contract of the nibbl mod: every $.state value it reads or writes.
2// Self-contained on purpose (no imports), as the engine requires of a contract.
3
4export type NibblTier = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
5
6// What the band draws: the server pet, or a spike pet still waiting for its first hatch call (serial null).
7// Hearts are kept as "pets used in hour heartsHour", so the row refills by itself when the hour turns.
8export type NibblPetView = {
9 serial: number | null
10 seed: number
11 tier: NibblTier
12 shiny: boolean
13 genesis: boolean
14 name: string
15 label: string | null
16 xp: number
17 heartsHour: number
18 heartsUsed: number
19}
20
21// Zero-token reactions to real events. Every *Until/*Since/*At field is ms since the epoch, 0 = off.
22export type NibblReaction = {
23 bugs: number
24 mood: 'idle' | 'happy' | 'sad'
25 moodUntil: number
26 heartUntil: number
27 lootUntil: number
28 boxUntil: number
29 awaySince: number
30 lastActiveAt: number
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 nibbl: {
36 'v1.loaded': boolean
37 'v1.pet': NibblPetView | null
38 'v1.eggTurns': number
39 'v1.hidden': boolean
40 'v1.react': NibblReaction
41 'v1.tzOffsetMin': number
42 }
43 }
44}
45