SLOPSHOPPER

nibbl

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

newbandrowsguardcommandprocess
★ 4v0.3.0MITupdated 2026-10-16nuromirzak/nibbl/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · nibbl
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /nibbl ⎿ nibbl: Egg: 1/10 answered turns. It hatches after 10. Egg turns 1/10 ▄▀▀ keep working to hatch it ▄▀▀▀▀ ▀▀▀▀▀▀ ▀▀▀▀▀▀ ▀▀▀▀▀▀ ▀▀▀▀▀ ▄▄▄▄▄▄▄▄▄▄▄▄▄▀▀▀ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Egg turns 1/10 keep working to hatch it
README

Nibbl

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.

Install

/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).

Commands

CommandWhat it does
/nibblName, 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 petPets 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 sharePrints 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 achievementsYour 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 notesLists the notes from the maker you have received
/nibbl oddsHatch odds and the odds of each of your pet's traits
/nibbl exportShows the secret code that moves your pet to another machine
/nibbl import <code>Moves a pet to this machine
/nibbl hideHides 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.

Daily greeting, evolution, achievements, invites and the week

  • Greeting. The first answered turn of your local day shows a short greeting (32 lines, by time of day, bundled in the mod). Not on the day your egg hatches.
  • Time of day. When nothing else is going on, the band shows what your pet is up to: sipping coffee in the morning, sunbathing in the afternoon, reading the logs in the evening.
  • Evolution. At level 10 your pet becomes a teen and at level 25 an adult, with a toast and a new look; /nibbl shows stage: teen (since level 10).
  • Achievements. 15 of them, for example Bug Hunter (100 passed checks). A toast shows when you unlock one; /nibbl achievements lists them. They are public on your pet's card.
  • Invites. Type /nibbl invite <serial> before your egg hatches and both pets get the Friends achievement.
  • Nibbl of the Week. The pet that gains the most XP in a week is featured at https://getnibbl.pages.dev/week and gets a toast.
  • One toast at a time. News waits in a small local queue and shows one toast per answered turn, the most important first (week win, achievement, invite, wave, greeting, maker note, update). Level-ups and the hatch show at once.

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.

Odds

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.

What leaves your machine

  • machineHash: the SHA-256 of your hardware id plus "nibbl". The id itself never leaves.
  • Your pet's serial and token.
  • The name and label you set with /nibbl name and /nibbl label. They show on your pet's public card and the leaderboard.
  • The serial you wave at with /nibbl wave <serial>, so the server can deliver the wave. No text goes with it.
  • The inviter's serial, if you enter one with /nibbl invite <serial>: sent once, with the hatch, and then forgotten.
  • Events: only the type (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/

Troubleshooting

  • No band above the prompt. Nibbl needs a Claude Code version with mods (function hooks). Update Claude Code, then restart it. In a short terminal the band shrinks to one line; /nibbl always works.
  • Clicking [♥] or pressing p does nothing. The band takes keys only when it has focus: press ctrl+x tab first, or use /nibbl pet.
  • Still an egg. It hatches after 10 answered turns. /nibbl shows how many are left.
  • "Could not reach the nibbl server". You are offline or the server is busy. Events wait in a local queue and sync later; nothing is lost.
  • New version not showing. Community marketplaces do not auto-update: run /plugin marketplace update nibbl, then /plugin update nibbl@nibbl, and restart. Or turn on auto-update in /plugin, Marketplaces, nibbl.
  • Anything else. Open an issue: https://github.com/nuromirzak/nibbl/issues

Local testing

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).

Source 2 files
hooks/register.js 2561 lines
1// 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 lines
1// 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