SLOPSHOPPER

cc-tamagotchi

A Tamagotchi that lives in Claude Code: it hatches, eats the work Claude does, leaves bugs behind, gets sick, throws tantrums and grows into one of eight…

newpanebandspinnerguardcommand
v0.1.0MITupdated 2026-10-02davidurco/cc-tamagotchi
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-tamagotchi
│ ┃ Kiwi ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ╭──────────────────────────────────╮ │ cc-tamagotchi │ │ ┃ │ KIWI egg │ ⏺ Read(src/auth.ts) │ A Tamagotchi egg appeared! Type /tama to │ │ ┃ │ ▣ client module ./lcd.tsx │ ⎿ Read 6 lines │ see it │ │ ┃ ╰──────────────────────────────────╯ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ gen 1 ⎿ Added 2 lines, removed 1 line │ ┃ laid 0m ago ⏺ Bash(bun test) │ ┃ an egg. it hatches when Claude gets to work ⎿ 3 pass, 1 fail │ ┃ i: stats h: help │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /tama │ │ ▣ client module ./lcd.tsx Kiwi egg wobbling… an egg. it hatches when Claud… /tama ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ client module ./lcd.tsx Kiwi egg wobbling… an egg. it hatches when Claud… /tama ⟨Claude Code's own drawing⟩
Pane · Kiwi
╭──────────────────────────────────╮ │ KIWI egg │ │ ▣ client module ./lcd.tsx │ ╰──────────────────────────────────╯ gen 1 laid 0m ago an egg. it hatches when Claude gets to work i: stats h: help
README

<img src="docs/device.gif" width="260" alt="An egg-shaped coral Tamagotchi named Mochi: an octopus-like pixel pet eats a rice ball, its bugs get washed away, and it throws a party">

cc-tamagotchi

A Tamagotchi that lives inside Claude Code.<br> It hatches when Claude gets to work, eats the code Claude writes, leaves bugs behind,<br> gets sick, throws tantrums, and grows into one of eight adults depending on how you code.

test Claude Code mod zero tokens MIT

claude plugin marketplace add davidurco/cc-tamagotchi
claude plugin install cc-tamagotchi@cc-tamagotchi

Then start claude. An egg appears. Type /tama.

What it is

The first Claude Code mod that is a real virtual pet, not a status line. It lives in a pane beside your transcript, on a pixel LCD that animates on its own: it strolls about, blinks, naps, eats, and reacts to what Claude is doing right now.

  • It eats your work. Every edit is a nibble. Passing tests make it cheer, and they squash a bug. Failing tests make a bug crawl out. A commit is a party.
  • It needs you. Feed it, play with it, clean up its bugs, give it medicine, tuck it in, and discipline its tantrums. Ignore it and it remembers.
  • It grows into who you are. Three days and 800 tool calls later it becomes one of eight adults, picked by how well you cared for it and what kind of work it watched.
  • It costs nothing. No tokens: nothing it does reaches the model. No network, no files, no processes: just Claude Code's own store, clock and screen.
  • It is everywhere you look. The device in a pane, the pet strolling above the prompt while Claude works, its face beside the spinner, its needs on the hint line. And in the desktop app, an egg-shaped device that animates as SVG.

The life of a pet

StageWhen it grows
EggHatches a minute after it appears, at Claude's first tool call (or after three minutes)
BitAfter 1 hour and 40 tool calls
NibbleAfter 1 day and 250 tool calls: few care mistakes make a Bytey, many a Glitchy
TeenAfter 3 days and 800 tool calls, one of eight adults

Which adult? Care comes first: a pet raised with at most one care mistake and firm discipline becomes the rare Unicorn, one raised with eight or more becomes Spaghetto. If you mostly code past 11 pm, it becomes an Owlgorithm. Otherwise it becomes what it watched Claude do most:

It watched Claude…It becomes
run testsAssertoise, who carries the checkmark on its back
edit codeRefactopus, eight arms, eight files open
run shell commandsShellby, who lives in the terminal
read and searchGrepossum, who finds what you lost
commit and pushCommitmunk, cheeks full of commits

Taking care of it

Open the device with /tama. Its buttons have hotkeys while the pane has the keyboard. Click the screen to pat your pet; after that click, the same keys work on the screen.

KeyCommandWhat it does
f/tama feedA meal: one heart of food, one gram
s/tama snackCandy: one heart of fun, two grams
p/tama playThe left-or-right game. Guess which way it looks; win 3 of 5
c/tama cleanSquash the bugs it left behind
m/tama medicineCure it when it is sick. Sometimes it takes two doses
l/tama lightsTuck it in (it sleeps through Claude's work), or wake it up
d/tama scoldDiscipline a tantrum. Never scold a pet that did nothing wrong
i/tama statsIts life in numbers, and what it is growing into

What to know:

  • Food empties in four awake hours, fun in five. Asleep, everything runs ten times slower.
  • With nothing going on for 20 minutes it naps, and Claude's next tool call wakes it.
  • Bugs show up about half an hour after a meal and now and then on their own. Leave them lying around and it gets sick.
  • It calls you (! on the device, a line under the prompt) when it is hungry, bored, sick, buried in bugs, or throwing a tantrum. A call left unanswered for 15 awake minutes is a care mistake.
  • In classic mode, half a day of hard neglect (starving and miserable, or sick) and it leaves for the cloud. It gets a place in the hall of fame (/tama history), and a new egg arrives (/tama new). Prefer it never leave? /tama mode gentle.
  • Going away for a while? /tama vacation on stops its clock until you are back: no hunger, no aging, no growing.

While Claude works

Above the prompt, your pet strolls along a strip of braille dots while Claude works, and its face rides beside the spinner. When Claude is idle the strip folds into one line: face, name, meters, and whatever it is saying.

Claude…Your pet…
edits a filenibbles the diff (+food)
runs tests that passcheers, and the tests squash a bug
runs tests that faila bug crawls out (−fun)
commitsthrows a party (+fun, +food)
pusheswheee
hits an errorflinches

/tama band auto is the default. band big draws the strip in solid pixels (8 rows), band on keeps the strip up even when Claude is idle, band line keeps it to one line, and band off hides it.

Six shells

/tama shell coral (the default), mint, grape, sky, banana or ink. In the terminal the shell is the device's frame; in the desktop app it is the whole egg.

All commands

Command
/tamaOpen or close the device (/tamagotchi works too)
/tama feed · snack · play · clean · medicine · lights · scold · patTake care of it
/tama stats · history · helpIts numbers, the pets before it, this list
/tama name <name>Rename it
/tama shell <color>coral, mint, grape, sky, banana, ink
`/tama band auto\big\on\line\off`How it shows above the prompt
`/tama vacation on\off`Stop its clock while you are away
`/tama mode classic\gentle`Gentle: it never leaves, however neglected
`/tama spinner on\off`Its face beside the spinner
`/tama sound on\off`Beeps when it calls (macOS, played on the machine Claude Code runs on)
/tama new · resetA new egg once it has left; start over (it asks first)

Every command runs at once, even while Claude is working, and prints nothing into the conversation.

Where it runs

WhereWhat you get
claude in a terminalEverything: the pixel LCD, the band, the spinner face, the hint line
The Code tab of the Claude desktop appThe device as an animated SVG, the band, buttons
The VS Code extension, claude -p, the Agent SDKNo drawing, but its life goes on: Claude's tool calls still feed it

One pet per machine. Every session shares it through Claude Code's plugin store (~/.claude/plugins/store/). The simulation is deterministic, so all your sessions see the same pet, and work done in any of them feeds it.

Is it safe?

A mod runs with your permissions, so check what one does before you install it. Claude Code lists what this one asks for without running it:

git clone https://github.com/davidurco/cc-tamagotchi
claude plugin validate ./cc-tamagotchi/.claude-plugin/plugin.json
calls: $.audio.play, $.clock.after, $.clock.every, $.clock.now, $.command.register, $.state.get,
       $.state.set, $.store.get, $.store.set, $.ui.ask, $.ui.close, $.ui.invalidate, $.ui.log,
       $.ui.open, $.ui.panes, $.ui.resolve, $.ui.toast

No $.fs, $.process, $.http or $.model: it reads no files, runs nothing, sends nothing anywhere, and calls no model. It watches tool calls only to see what kind they were (an edit, a test run, a commit), and it never changes or blocks one.

Requirements

Claude Code v2.1.287 or later, where mods are on by default. Check with claude --version.

To try it without installing anything, load it from a clone for one session:

git clone https://github.com/davidurco/cc-tamagotchi
claude --plugin-dir ./cc-tamagotchi

Development

claude --plugin-dir .            # loads the mod and reloads it on every save
claude plugin test .             # the tests: no session, sign-in or network needed
claude plugin validate ./.claude-plugin/plugin.json
hooks/
  register.tsx      the hooks module: events, /tama, the store, every drawing
  lcd.tsx           the terminal's screen: a Client that animates on the drawing thread
  pet/life.ts       the pet's life as pure functions: time, care, Claude's work, growth
  pet/scene.ts      what the screen shows at a moment, as a display list
  pet/sprites.ts    the pixel art: 1-bit sprites drawn as text
  pet/svg.ts        the desktop's animated device and strip
  pet/paint.ts      half blocks and braille for the terminal
types/index.d.ts    the $.state contract
tests/              claude plugin test
scripts/            the README's pictures, drawn from the mod's own code

Ideas, sprites and new adults are welcome: see CONTRIBUTING.md for how a creature is drawn and added, or open an issue.


<sub>A fan project, not affiliated with or endorsed by Bandai or Anthropic. Tamagotchi is a trademark of Bandai. Made with Claude Code.</sub>

Source 12 files
hooks/register.tsx 1005 lines
1/* @jsx h */
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, Register } from 'claude-code'
4import type { BandMode, Game, Memorial, Pet, Reaction, ReactionKind, Settings, ShellColor, Speech, View } from '../types'
5import {
6  ageOf,
7  ageText,
8  canPlay,
9  care,
10  classify,
11  endVacation,
12  hearts,
13  isPet,
14  memorialOf,
15  newPet,
16  nextEgg,
17  nextGrowth,
18  played,
19  rename,
20  revive,
21  startVacation,
22  tick,
23  turnDone,
24  work,
25  type Care,
26  type LifeEvent,
27  type Outcome,
28} from './pet/life.ts'
29import { REACTION_MS, type SceneInput } from './pet/scene.ts'
30import { species } from './pet/species.ts'
31import { deviceSvg, LCD, laneSvg, SHELLS } from './pet/svg.ts'
32import { bars, CALL_SAYS, compact, face, HELP, meter, NEED_TEXT, statusLine, title } from './pet/words.ts'
33
34// cc-tamagotchi: a virtual pet that lives in Claude Code. It hatches from an egg, eats the work
35// Claude does, leaves bugs behind, gets sick, throws tantrums and grows into one of eight adults
36// depending on how it is cared for and what kind of work it watches. One pet per machine, kept in
37// $.store and shared by every session; nothing it does reaches the model, so it costs no tokens.
38//
39// This module owns the pet: it runs its life forward (./pet/life.ts), applies Claude's tool calls
40// and the person's care, and draws it: the device in a pane (/tama), the band above the prompt,
41// a face beside the spinner. In the terminal the screen is ./lcd.tsx, a Client that animates on the
42// drawing thread; the desktop and mobile apps get one animated SVG (./pet/svg.ts).
43
44type Engine = EngineInterface
45
46const PANE = 'tama'
47const SPEECH_MS = 9_000
48/** In `auto`, how long the band stays big after something happened. */
49const BAND_AFTER_MS = 12_000
50const TICK_MS = 30_000
51
52const DEFAULT_SETTINGS: Settings = { band: 'auto', spinner: true, sound: false, mode: 'classic', shell: 'coral' }
53
54const petAtom = atom({ plugin: 'cc-tamagotchi', key: 'pet' } as const, null)
55const settingsAtom = atom({ plugin: 'cc-tamagotchi', key: 'settings' } as const, DEFAULT_SETTINGS)
56const viewAtom = atom({ plugin: 'cc-tamagotchi', key: 'view' } as const, 'main')
57const gameAtom = atom({ plugin: 'cc-tamagotchi', key: 'game' } as const, null)
58const reactionAtom = atom({ plugin: 'cc-tamagotchi', key: 'reaction' } as const, null)
59const speechAtom = atom({ plugin: 'cc-tamagotchi', key: 'speech' } as const, null)
60const workingAtom = atom({ plugin: 'cc-tamagotchi', key: 'working' } as const, false)
61const graveAtom = atom({ plugin: 'cc-tamagotchi', key: 'graveyard' } as const, [])
62
63const BANDS: readonly BandMode[] = ['auto', 'big', 'on', 'line', 'off']
64const COLORS = Object.keys(SHELLS) as ShellColor[]
65
66// one change to the pet at a time in this session; sessions side by side meet in the store
67let queue: Promise<unknown> = Promise.resolve()
68
69const isSettings = (v: unknown): v is Partial<Settings> => typeof v === 'object' && v !== null
70
71/** Whether the device is on screen: the engine's record, which outlives a reload of this module. */
72async function isPaneUp($: Engine): Promise<boolean> {
73  const panes = await $.ui.panes().catch(() => [])
74  return panes.some(pane => pane.id === PANE && pane.isShown && pane.isPlaced)
75}
76
77// ── the pet in the store ────────────────────────────────────────────────────────────────────
78
79/** The settings as the store has them now (another session may have changed them), shown as well. */
80async function loadSettings($: Engine): Promise<Settings> {
81  const saved = await $.store.get('settings').catch(() => undefined)
82  const settings = { ...DEFAULT_SETTINGS, ...(isSettings(saved) ? saved : {}) } as Settings
83  const shown = await read($, settingsAtom)
84  if (JSON.stringify(shown) !== JSON.stringify(settings)) await update($, settingsAtom, () => settings)
85  return settings
86}
87
88async function saveSettings($: Engine, patch: Partial<Settings>): Promise<Settings> {
89  const settings = { ...(await loadSettings($)), ...patch }
90  await update($, settingsAtom, () => settings)
91  await $.store.set('settings', settings).catch(err => $.ui.log(`cc-tamagotchi: settings not saved: ${err}`, { to: 'debug' }))
92  return settings
93}
94
95async function loadGraveyard($: Engine): Promise<Memorial[]> {
96  const saved = await $.store.get('graveyard').catch(() => undefined)
97  const list = Array.isArray(saved) ? (saved as Memorial[]) : []
98  await update($, graveAtom, () => list)
99  return list
100}
101
102/**
103 * The one way the pet changes: read the newest copy from the store (another session may have
104 * written it), run it forward to now, apply `fn`, write it back, show it, and play what happened.
105 */
106function change($: Engine, fn: ((pet: Pet, now: number) => Outcome | null) | null): Promise<Outcome | null> {
107  const run = async (): Promise<Outcome | null> => {
108    const settings = await loadSettings($)
109    for (let attempt = 0; ; attempt++) {
110      const now = await $.clock.now()
111      const saved = await $.store.get('pet').catch(() => undefined)
112      const stored = isPet(saved) ? revive(saved) : newPet(now)
113      const { pet: ticked, events } = tick(stored, now, settings.mode)
114      const out = fn ? fn(ticked, now) : null
115      // what the change brings about at once: the tool call that hatches the egg, the one that grows it
116      const after = out ? tick(out.pet, now, settings.mode) : { pet: ticked, events: [] }
117      events.push(...after.events)
118      const pet = after.pet
119      const isNew = !isPet(saved) || JSON.stringify(pet) !== JSON.stringify(stored)
120      const next = isNew ? { ...pet, rev: stored.rev + 1 } : stored
121      if (isNew) {
122        // another session wrote since the read: do it again from theirs (the store has no compare-and-set)
123        const again = await $.store.get('pet').catch(() => undefined)
124        if (attempt < 2 && isPet(again) && isPet(saved) && again.rev !== saved.rev) continue
125        await $.store.set('pet', next).catch(err => $.ui.log(`cc-tamagotchi: pet not saved: ${err}`, { to: 'debug' }))
126      }
127      await update($, petAtom, () => next)
128      await announce($, events, next, settings)
129      // hatching and growing up are the moment: the tool call that caused them waits its turn
130      const grew = events.some(ev => ev.kind === 'hatched' || ev.kind === 'evolved')
131      if (out?.reaction && !grew) await react($, out.reaction)
132      if (out?.say && !grew) await say($, out.say)
133      return out
134    }
135  }
136  const p = queue.then(run, run)
137  queue = p.catch(() => undefined)
138  return p
139}
140
141async function react($: Engine, kind: ReactionKind, from?: Reaction['from']): Promise<void> {
142  const now = await $.clock.now()
143  await update($, reactionAtom, (r: Reaction | null) => ({ kind, id: (r?.id ?? 0) + 1, at: now, ...(from ? { from } : {}) }))
144  // the SVG surfaces play a reaction once, then need the idle loop drawn back
145  $.clock.after(REACTION_MS[kind] + 80, () => $.ui.invalidate('ui.render'))
146}
147
148async function say($: Engine, text: string): Promise<void> {
149  const now = await $.clock.now()
150  await update($, speechAtom, () => ({ text, at: now }))
151  $.clock.after(SPEECH_MS + 80, () => $.ui.invalidate('ui.render'))
152  $.clock.after(BAND_AFTER_MS + 80, () => $.ui.invalidate('ui.render'))
153}
154
155function beep($: Engine, settings: Settings): void {
156  if (settings.sound) $.audio.play({ asset: 'fx/beep.wav' }).catch(() => undefined)
157}
158
159/** What the simulation reported: toasts for the big moments, words for the small ones. */
160async function announce($: Engine, events: LifeEvent[], pet: Pet, settings: Settings): Promise<void> {
161  for (const ev of events) {
162    switch (ev.kind) {
163      case 'hatched':
164        await react($, 'hatch')
165        await say($, 'hello, world!')
166        $.ui.toast(`${pet.name} hatched! Say hi with /tama`)
167        beep($, settings)
168        break
169      case 'evolved':
170        await react($, 'evolve', ev.from)
171        await say($, `i'm a ${species(ev.to).name} now!`)
172        $.ui.toast(`${pet.name} grew into a ${species(ev.to).name}!`, { timeoutMs: 8000 })
173        beep($, settings)
174        break
175      case 'sick':
176        $.ui.toast(`${pet.name} is sick. /tama medicine`)
177        break
178      case 'calling':
179        await say($, CALL_SAYS[ev.need])
180        beep($, settings)
181        break
182      case 'departed': {
183        const saved = await $.store.get('graveyard').catch(() => undefined)
184        const list = [...(Array.isArray(saved) ? (saved as Memorial[]) : []), memorialOf(pet)].slice(-50)
185        await update($, graveAtom, () => list)
186        await $.store.set('graveyard', list).catch(() => undefined)
187        $.ui.toast(`${pet.name} has left for the cloud. /tama to see`, { timeoutMs: 10000 })
188        break
189      }
190      case 'bug':
191        if (!pet.asleep) await say($, 'oops… a bug')
192        break
193      default:
194        break
195    }
196  }
197}
198
199// ── what the person does ────────────────────────────────────────────────────────────────────
200
201async function doCare($: Engine, what: Care): Promise<void> {
202  const out = await change($, (pet, now) => care(pet, what, now))
203  if (out && !out.ok && out.say && !out.reaction) $.ui.toast(out.say)
204}
205
206async function startGame($: Engine): Promise<void> {
207  const pet = await read($, petAtom)
208  const why = pet ? canPlay(pet) : 'no pet yet'
209  if (why) {
210    $.ui.toast(why)
211    return
212  }
213  await update($, gameAtom, () => ({ round: 0, wins: 0, answer: null, guess: null, isOver: false }))
214  await update($, viewAtom, () => 'game')
215}
216
217// one guess at a time, so two quick presses play two rounds and a game ends once
218let guesses: Promise<unknown> = Promise.resolve()
219
220function guess($: Engine, dir: 'left' | 'right'): Promise<void> {
221  const p = guesses.then(() => guessNow($, dir))
222  guesses = p.catch(() => undefined)
223  return p
224}
225
226async function guessNow($: Engine, dir: 'left' | 'right'): Promise<void> {
227  const game = await read($, gameAtom)
228  if (!game || game.isOver) return
229  const answer: 'left' | 'right' = Math.random() < 0.5 ? 'left' : 'right'
230  const next: Game = { round: game.round + 1, wins: game.wins + (answer === dir ? 1 : 0), answer, guess: dir, isOver: game.round + 1 >= 5 }
231  await update($, gameAtom, () => next)
232  if (next.isOver) await change($, (pet, now) => played(pet, next.wins, now))
233  else await react($, answer === dir ? 'pat' : 'lose')
234}
235
236async function setView($: Engine, view: View): Promise<void> {
237  if (view !== 'game') await update($, gameAtom, () => null)
238  await update($, viewAtom, () => view)
239}
240
241async function newEgg($: Engine): Promise<void> {
242  await change($, (pet, now) => (pet.gone ? { pet: nextEgg(pet, now), ok: true, say: 'a new egg!' } : null))
243  await setView($, 'main')
244}
245
246async function openPane($: Engine, view?: View): Promise<void> {
247  if (view) await setView($, view)
248  const pet = await read($, petAtom)
249  // inline it asks for room for the screen and its buttons; docked, for the doubled screen
250  await $.ui.open({ id: PANE, title: pet ? pet.name : 'tamagotchi', focus: true, rows: 22, columns: 72 })
251  $.ui.invalidate('ui.render')
252}
253
254/** A key: a button's hotkey, or a key pressed on the screen once clicked. */
255async function press($: Engine, key: string): Promise<void> {
256  const view = await read($, viewAtom)
257  const pet = await read($, petAtom)
258  if (view === 'game') {
259    const game = await read($, gameAtom)
260    if (key === 'left' || key === 'a') return guess($, 'left')
261    if (key === 'right' || key === 'd') return guess($, 'right')
262    if (key === 'p' && game?.isOver) return startGame($)
263    if (key === 'x' || key === 'b' || key === 'return') return setView($, 'main')
264    return
265  }
266  if (view !== 'main' && (key === 'b' || key === 'return')) return setView($, 'main')
267  if (pet?.gone && key === 'n') return newEgg($)
268  switch (key) {
269    case 'f':
270      return doCare($, 'meal')
271    case 's':
272      return doCare($, 'snack')
273    case 'p':
274      return startGame($)
275    case 'c':
276      return doCare($, 'clean')
277    case 'm':
278      return doCare($, 'medicine')
279    case 'l':
280      return doCare($, 'lights')
281    case 'd':
282      return doCare($, 'scold')
283    case 'i':
284      return setView($, view === 'stats' ? 'main' : 'stats')
285    case 'r':
286      return setView($, view === 'history' ? 'main' : 'history')
287    case 'h':
288    case '?':
289      return setView($, view === 'help' ? 'main' : 'help')
290    default:
291      return
292  }
293}
294
295// ── /tama ───────────────────────────────────────────────────────────────────────────────────
296
297async function runCommand($: Engine, args: string): Promise<void> {
298  const [verb = '', ...rest] = args.trim().split(/\s+/)
299  const arg = rest.join(' ').trim()
300  const on = (v: string) => (v === 'on' || v === 'true' ? true : v === 'off' || v === 'false' ? false : null)
301  const v = verb.toLowerCase()
302  switch (v) {
303    case '':
304      if (await isPaneUp($)) {
305        await $.ui.close({ id: PANE })
306        $.ui.invalidate('ui.render')
307        return
308      }
309      return openPane($, 'main')
310    case 'open':
311    case 'show':
312      return openPane($)
313    case 'close':
314    case 'hide':
315      await $.ui.close({ id: PANE })
316      $.ui.invalidate('ui.render')
317      return
318    case 'feed':
319    case 'meal':
320    case 'eat':
321      return doCare($, 'meal')
322    case 'snack':
323    case 'candy':
324      return doCare($, 'snack')
325    case 'clean':
326    case 'flush':
327      return doCare($, 'clean')
328    case 'medicine':
329    case 'med':
330    case 'heal':
331      return doCare($, 'medicine')
332    case 'lights':
333    case 'sleep':
334    case 'wake':
335      return doCare($, 'lights')
336    case 'scold':
337    case 'discipline':
338      return doCare($, 'scold')
339    case 'pat':
340    case 'pet':
341      return doCare($, 'pat')
342    case 'play':
343    case 'game':
344      await startGame($)
345      return (await read($, viewAtom)) === 'game' ? openPane($) : undefined
346    case 'stats':
347    case 'info':
348      return openPane($, 'stats')
349    case 'help':
350    case '?':
351      return openPane($, 'help')
352    case 'history':
353    case 'hall':
354      await loadGraveyard($)
355      return openPane($, 'history')
356    case 'name':
357    case 'rename':
358      if (!arg) return openPane($, 'name')
359      await change($, pet => ({ pet: rename(pet, arg), ok: true, say: `my name is ${rename(pet, arg).name}!` }))
360      return
361    case 'band':
362      if (!BANDS.includes(arg as BandMode)) {
363        $.ui.toast(`band: ${BANDS.join(', ')}`)
364        return
365      }
366      await saveSettings($, { band: arg as BandMode })
367      $.ui.toast(`band: ${arg}`)
368      return
369    case 'shell':
370    case 'color':
371      if (!COLORS.includes(arg as ShellColor)) {
372        $.ui.toast(`shell: ${COLORS.join(', ')}`)
373        return
374      }
375      await saveSettings($, { shell: arg as ShellColor })
376      return
377    case 'spinner':
378    case 'sound': {
379      const value = on(arg)
380      const current = await loadSettings($)
381      const next = value ?? !current[v === 'sound' ? 'sound' : 'spinner']
382      await saveSettings($, v === 'sound' ? { sound: next } : { spinner: next })
383      $.ui.toast(`${v}: ${next ? 'on' : 'off'}`)
384      return
385    }
386    case 'mode':
387      if (arg !== 'classic' && arg !== 'gentle') {
388        $.ui.toast('mode: classic (it can leave if badly neglected) or gentle (it never does)')
389        return
390      }
391      await saveSettings($, { mode: arg })
392      $.ui.toast(`mode: ${arg}`)
393      return
394    case 'vacation': {
395      const value = on(arg)
396      const pet = await read($, petAtom)
397      const going = value ?? pet?.vacation === null
398      await change($, (p, now) => ({ pet: going ? startVacation(p, now) : endVacation(p, now), ok: true, say: going ? 'have a nice trip!' : 'welcome back!' }))
399      $.ui.toast(going ? 'vacation: its clock is stopped until /tama vacation off' : 'vacation over: its clock runs again')
400      return
401    }
402    case 'new':
403      return newEgg($)
404    case 'reset': {
405      const pet = await read($, petAtom)
406      let answer = 'Keep it'
407      try {
408        answer = await $.ui.ask(`Say goodbye to ${pet?.name ?? 'your pet'} and start a new egg?`, ['Keep it', 'Start over'])
409      } catch {
410        return
411      }
412      if (answer !== 'Start over') return
413      await change($, (p, now) => ({ pet: { ...nextEgg(p, now), generation: p.generation }, ok: true, say: 'a new egg!' }))
414      return
415    }
416    default:
417      $.ui.toast(`no /tama ${verb}. /tama help lists them`)
418  }
419}
420
421// ── drawing ─────────────────────────────────────────────────────────────────────────────────
422
423type Ctx = {
424  pet: Pet
425  settings: Settings
426  view: View
427  game: Game | null
428  reaction: Reaction | null
429  speech: Speech | null
430  working: boolean
431  now: number
432  graveyard: Memorial[]
433}
434
435async function context($: Engine, working?: boolean): Promise<Ctx | null> {
436  const pet = await read($, petAtom)
437  if (!pet) return null
438  return {
439    pet,
440    settings: await read($, settingsAtom),
441    view: await read($, viewAtom),
442    game: await read($, gameAtom),
443    reaction: await read($, reactionAtom),
444    speech: await read($, speechAtom),
445    working: working ?? (await read($, workingAtom)),
446    now: await $.clock.now(),
447    graveyard: await read($, graveAtom),
448  }
449}
450
451function sceneOf(c: Ctx): SceneInput {
452  const { pet, reaction } = c
453  return {
454    species: pet.species,
455    seed: pet.id,
456    asleep: pet.asleep,
457    tucked: pet.tucked,
458    sick: pet.sick > 0,
459    bugs: pet.bugs,
460    calling: pet.calling !== null && pet.calling !== 'tantrum',
461    tantrum: pet.tantrum,
462    sad: pet.happy < 25 && pet.species !== 'egg',
463    working: c.working,
464    gone: pet.gone !== null,
465    reaction: reaction ? { kind: reaction.kind, id: reaction.id, ...(reaction.from ? { from: reaction.from } : {}) } : null,
466    game: c.view === 'game' && c.game ? { answer: c.game.answer, guess: c.game.guess, round: c.game.round } : null,
467  }
468}
469
470const reactionAge = (c: Ctx) => (c.reaction ? c.now - c.reaction.at : null)
471const isReacting = (c: Ctx) => c.reaction !== null && c.now - c.reaction.at < REACTION_MS[c.reaction.kind]
472const spoken = (c: Ctx) => (c.speech && c.now - c.speech.at < SPEECH_MS ? c.speech.text : null)
473
474function icons(p: Pet) {
475  return {
476    food: p.hunger <= 25,
477    light: p.asleep,
478    game: p.happy <= 25,
479    medicine: p.sick > 0,
480    bath: p.bugs > 0,
481    meter: false,
482    discipline: p.tantrum,
483    attention: p.calling !== null && !p.gone,
484  }
485}
486
487type Action = { key: string; label: string; hotkey: string }
488
489/** The buttons for a view: each one also answers its key on the clicked screen. */
490function actionsOf(c: Ctx): Action[] {
491  const { pet, view, game } = c
492  if (view === 'game') {
493    return game?.isOver
494      ? [
495          { key: 'p', label: 'again', hotkey: 'p' },
496          { key: 'x', label: 'done', hotkey: 'x' },
497        ]
498      : [
499          { key: 'left', label: '← left', hotkey: 'a' },
500          { key: 'right', label: 'right →', hotkey: 'd' },
501          { key: 'x', label: 'quit', hotkey: 'x' },
502        ]
503  }
504  if (view !== 'main') return [{ key: 'b', label: 'back', hotkey: 'b' }]
505  if (pet.gone) {
506    return [
507      { key: 'n', label: 'new egg', hotkey: 'n' },
508      { key: 'r', label: 'history', hotkey: 'r' },
509    ]
510  }
511  if (pet.species === 'egg') {
512    return [
513      { key: 'i', label: 'stats', hotkey: 'i' },
514      { key: 'h', label: 'help', hotkey: 'h' },
515    ]
516  }
517  return [
518    { key: 'f', label: 'feed', hotkey: 'f' },
519    { key: 's', label: 'snack', hotkey: 's' },
520    { key: 'p', label: 'play', hotkey: 'p' },
521    { key: 'c', label: 'clean', hotkey: 'c' },
522    { key: 'm', label: 'medicine', hotkey: 'm' },
523    { key: 'l', label: pet.asleep ? 'wake' : 'lights', hotkey: 'l' },
524    { key: 'd', label: 'scold', hotkey: 'd' },
525    { key: 'i', label: 'stats', hotkey: 'i' },
526  ]
527}
528
529/** The lines beside the screen: meters while it lives, a memorial once it has gone. */
530function infoLines(c: Ctx): { label: string; value: string; color?: string }[] {
531  const { pet, now } = c
532  if (pet.gone) {
533    return [
534      { label: 'lived', value: ageText(ageOf(pet, now)) },
535      { label: 'watched', value: `${compact(pet.stats.toolCalls)} tool calls` },
536      { label: 'saw', value: `${pet.stats.commits} commits` },
537    ]
538  }
539  if (pet.species === 'egg') {
540    return [
541      { label: 'gen', value: `${pet.generation}` },
542      { label: 'laid', value: `${ageText(now - pet.born)} ago` },
543    ]
544  }
545  const lines: { label: string; value: string; color?: string }[] = [
546    { label: 'food', value: meter(pet.hunger), color: hearts(pet.hunger) <= 1 ? 'red' : 'redBright' },
547    { label: 'fun', value: meter(pet.happy), color: hearts(pet.happy) <= 1 ? 'red' : 'yellow' },
548    { label: 'disc', value: bars(pet.discipline), color: 'cyan' },
549    { label: 'age', value: `${ageText(ageOf(pet, now))} · gen ${pet.generation}` },
550    { label: 'weight', value: `${pet.weight}g` },
551  ]
552  if (pet.bugs > 0) lines.push({ label: 'bugs', value: 'ж '.repeat(pet.bugs).trim(), color: 'green' })
553  if (pet.sick > 0) lines.push({ label: 'sick', value: `needs ${pet.sick === 1 ? 'a dose' : '2 doses'}`, color: 'magenta' })
554  if (pet.vacation !== null) lines.push({ label: 'away', value: 'on vacation', color: 'blue' })
555  return lines
556}
557
558function statsLines(c: Ctx): [string, string][] {
559  const { pet, now } = c
560  const s = pet.stats
561  const sp = species(pet.species)
562  const lines: [string, string][] = [
563    ['form', `${sp.name}: ${sp.blurb}`],
564    ['next', nextGrowth(pet, now) ?? 'fully grown'],
565    ['age', `${ageText(ageOf(pet, now))} · generation ${pet.generation}`],
566    ['care', `${pet.mistakes} care mistake${pet.mistakes === 1 ? '' : 's'} · discipline ${Math.round(pet.discipline)}`],
567    ['watched', `${compact(s.toolCalls)} tool calls · ${compact(s.turns)} turns · ${compact(s.tokens)} tokens`],
568    ['work', `${s.edits} edits · ${s.searches} reads · ${s.shell} commands · ${s.agents} agents`],
569    ['tests', `${s.tests} passed · ${s.testFails} failed`],
570    ['git', `${s.commits} commits · ${s.pushes} pushes`],
571    ['care', `${s.meals} meals · ${s.snacks} snacks · ${s.games} games (${s.wins} won) · ${s.cleans} cleans · ${s.meds} doses · ${s.pats} pats`],
572  ]
573  return lines
574}
575
576function historyLines(c: Ctx): string[] {
577  if (c.graveyard.length === 0) return ['No pets have left yet. Long may it last.']
578  return [...c.graveyard]
579    .reverse()
580    .slice(0, 12)
581    .map(m => `gen ${m.generation} · ${m.name} the ${species(m.species).name} · lived ${ageText(m.gone - m.born)} · ${compact(m.toolCalls)} tool calls`)
582}
583
584// ── /tama's pane ────────────────────────────────────────────────────────────────────────────
585
586function paneTerminal($: Engine, el: any, c: Ctx, cols: number, rows: number, docked: boolean) {
587  const { Box, Text, Button, Client, Input } = el
588  const shell = SHELLS[c.settings.shell] ?? SHELLS.coral
589  const { pet } = c
590  const sp = species(pet.species)
591  // the screen doubles where there is room for it: 64×16 cells, else 32×8
592  const scale: 1 | 2 = cols >= 72 && rows >= 26 ? 2 : 1
593  const wide = cols >= (scale === 2 ? 68 : 36) + 30
594  const lcd = (
595    <Client
596      key={`lcd:${scale}`}
597      module="./lcd.tsx"
598      width={32 * scale}
599      height={8 * scale}
600      props={{ input: sceneOf(c), mode: 'device', scale, age: reactionAge(c) ?? 999999, ink: LCD.ink, bg: LCD.bg }}
601    />
602  )
603  const attention = pet.calling !== null && !pet.gone
604  const device = (
605    <Box flexDirection="column" borderStyle="round" borderColor={shell.base} paddingX={1} width={32 * scale + 4}>
606      <Box flexDirection="row" justifyContent="space-between">
607        <Text bold color={shell.base}>
608          {pet.name.toUpperCase()}
609        </Text>
610        <Text color={attention ? 'redBright' : shell.base} bold={attention}>
611          {attention ? '! ' : ''}
612          {pet.gone ? 'gone' : sp.name.toLowerCase()}
613        </Text>
614      </Box>
615      {lcd}
616    </Box>
617  )
618
619  let side: unknown
620  if (c.view === 'stats') {
621    side = (
622      <Box flexDirection="column">
623        <Text bold>{title(pet)}</Text>
624        {statsLines(c).map(([k, v]) => (
625          <Text wrap="wrap">
626            <Text dimColor>{k.padEnd(8)}</Text>
627            {v}
628          </Text>
629        ))}
630      </Box>
631    )
632  } else if (c.view === 'help') {
633    side = (
634      <Box flexDirection="column">
635        <Text bold>/tama</Text>
636        {HELP.map(([k, v]) => (
637          <Text wrap="wrap">
638            <Text color={shell.base}>{k}</Text>
639            <Text dimColor>{`  ${v}`}</Text>
640          </Text>
641        ))}
642        <Text dimColor wrap="wrap">
643          Click the screen to pat it; then its keys work there too. Esc gives the prompt back.
644        </Text>
645      </Box>
646    )
647  } else if (c.view === 'history') {
648    side = (
649      <Box flexDirection="column">
650        <Text bold>Hall of fame</Text>
651        {historyLines(c).map(line => (
652          <Text dimColor wrap="wrap">
653            {line}
654          </Text>
655        ))}
656      </Box>
657    )
658  } else if (c.view === 'game' && c.game) {
659    const g = c.game
660    side = (
661      <Box flexDirection="column">
662        <Text bold>Left or right?</Text>
663        <Text dimColor wrap="wrap">
664          Guess which way {pet.name} will look. Win 3 of 5.
665        </Text>
666        <Text>
667          {'round '}
668          {Math.min(5, g.round + (g.isOver ? 0 : 1))}
669          {'/5 · won '}
670          {g.wins}
671        </Text>
672        {g.answer ? (
673          <Text color={g.answer === g.guess ? 'greenBright' : 'red'}>{g.answer === g.guess ? `yes! it looked ${g.answer}` : `no, it looked ${g.answer}`}</Text>
674        ) : null}
675        {g.isOver ? <Text bold>{g.wins >= 3 ? `${pet.name} had a blast!` : `${pet.name} wants a rematch`}</Text> : null}
676      </Box>
677    )
678  } else if (c.view === 'name' && Input) {
679    side = (
680      <Box flexDirection="column">
681        <Text bold>A new name</Text>
682        <Input
683          key="name"
684          label="Name"
685          placeholder={pet.name}
686          value=""
687          submitLabel="rename"
688          autoFocus
689          onSubmit={async (value: string) => {
690            if (value.trim()) await change($, p => ({ pet: rename(p, value), ok: true, say: `my name is ${rename(p, value).name}!` }))
691            await setView($, 'main')
692          }}
693        />
694      </Box>
695    )
696  } else {
697    side = (
698      <Box flexDirection="column">
699        {infoLines(c).map(line => (
700          <Text>
701            <Text dimColor>{line.label.padEnd(7)}</Text>
702            {line.color ? <Text color={line.color}>{line.value}</Text> : line.value}
703          </Text>
704        ))}
705      </Box>
706    )
707  }
708
709  const words = spoken(c)
710  return (
711    <Box flexDirection="column">
712      <Box flexDirection={wide ? 'row' : 'column'} columnGap={2}>
713        {device}
714        {side}
715      </Box>
716      <Text wrap="truncate-end" color={words ? shell.base : undefined} dimColor={!words}>
717        {words ? `“${words}”` : statusLine(pet)}
718      </Text>
719      <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
720        {actionsOf(c).map(a => (
721          <Button key={`act:${a.key}`} label={a.label} hotkey={a.hotkey} plain onPress={() => press($, a.key)} />
722        ))}
723      </Box>
724    </Box>
725  )
726}
727
728function paneRemote($: Engine, el: any, c: Ctx, cols: number) {
729  const { Box, Text, Button, Svg, Input } = el
730  const { pet } = c
731  const svg = deviceSvg({ input: sceneOf(c), rt: isReacting(c) ? reactionAge(c) : null, name: pet.name, shell: c.settings.shell, icons: icons(pet) })
732  const words = spoken(c)
733  const lines =
734    c.view === 'stats'
735      ? statsLines(c).map(([k, v]) => `${k}: ${v}`)
736      : c.view === 'help'
737        ? HELP.map(([k, v]) => `${k}: ${v}`)
738        : c.view === 'history'
739          ? historyLines(c)
740          : c.view === 'game' && c.game
741            ? [
742                `Left or right? Round ${Math.min(5, c.game.round + (c.game.isOver ? 0 : 1))} of 5, won ${c.game.wins}.`,
743                ...(c.game.answer ? [c.game.answer === c.game.guess ? `Yes! It looked ${c.game.answer}.` : `No, it looked ${c.game.answer}.`] : []),
744              ]
745            : infoLines(c).map(l => `${l.label}  ${l.value}`)
746  return (
747    <Box flexDirection="column" gap={1}>
748      <Box flexDirection={cols >= 60 ? 'row' : 'column'} columnGap={3} alignItems="flex-start">
749        <Svg key="device" source={svg} alt={`${title(pet)}: ${statusLine(pet)}`} width={220} height={250} isInteractive />
750        <Box flexDirection="column">
751          <Text bold>{title(pet)}</Text>
752          {lines.map(line => (
753            <Text wrap="wrap">{line}</Text>
754          ))}
755          {c.view === 'name' && Input ? (
756            <Input
757              key="name"
758              label="Name"
759              placeholder={pet.name}
760              value=""
761              submitLabel="rename"
762              autoFocus
763              onSubmit={async (value: string) => {
764                if (value.trim()) await change($, p => ({ pet: rename(p, value), ok: true }))
765                await setView($, 'main')
766              }}
767            />
768          ) : null}
769        </Box>
770      </Box>
771      <Text dimColor={!words}>{words ? `“${words}”` : statusLine(pet)}</Text>
772      <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
773        {actionsOf(c).map(a => (
774          <Button key={`act:${a.key}`} label={a.label} hotkey={a.hotkey} onPress={() => press($, a.key)} />
775        ))}
776      </Box>
777    </Box>
778  )
779}
780
781// ── the band above the prompt ───────────────────────────────────────────────────────────────
782
783function bandLine(el: any, c: Ctx) {
784  const { Box, Text } = el
785  const { pet } = c
786  const shell = SHELLS[c.settings.shell] ?? SHELLS.coral
787  const words = spoken(c)
788  const alive = !pet.gone && pet.species !== 'egg'
789  return (
790    <Box flexDirection="row" columnGap={1}>
791      <Text color={shell.base} bold>
792        {face(pet)}
793      </Text>
794      <Text bold>{pet.name}</Text>
795      {alive ? <Text color="redBright">{meter(pet.hunger)}</Text> : null}
796      {alive ? <Text color="yellow">{meter(pet.happy)}</Text> : null}
797      {alive && pet.bugs > 0 ? <Text color="green">{'ж'.repeat(pet.bugs)}</Text> : null}
798      <Text dimColor={!words && pet.calling === null} color={pet.calling ? 'redBright' : undefined} wrap="truncate-end">
799        {words ? `“${words}”` : statusLine(pet)}
800      </Text>
801    </Box>
802  )
803}
804
805function bandLaneSide(el: any, c: Ctx) {
806  const { Box, Text } = el
807  const { pet } = c
808  const shell = SHELLS[c.settings.shell] ?? SHELLS.coral
809  const words = spoken(c)
810  const alive = !pet.gone && pet.species !== 'egg'
811  return (
812    <Box flexDirection="column" width={30}>
813      <Text wrap="truncate-end">
814        <Text bold color={shell.base}>
815          {pet.name}
816        </Text>
817        <Text dimColor>{` ${species(pet.species).name.toLowerCase()}`}</Text>
818      </Text>
819      {alive ? (
820        <Text>
821          <Text dimColor>{'food '}</Text>
822          <Text color="redBright">{meter(pet.hunger)}</Text>
823          <Text dimColor>{' fun '}</Text>
824          <Text color="yellow">{meter(pet.happy)}</Text>
825        </Text>
826      ) : (
827        <Text dimColor>{pet.gone ? 'gone to the cloud' : 'wobbling…'}</Text>
828      )}
829      <Text wrap="truncate-end" color={words ? shell.base : pet.calling ? 'redBright' : undefined} dimColor={!words && !pet.calling}>
830        {words ? `“${words}”` : statusLine(pet)}
831      </Text>
832      <Text dimColor>{'/tama'}</Text>
833    </Box>
834  )
835}
836
837// ── the hooks ───────────────────────────────────────────────────────────────────────────────
838
839export const register: Register = on => {
840  on('session.start', async ($, e, next) => {
841    const r = await next(e)
842    await loadSettings($)
843    await loadGraveyard($)
844    const saved = await $.store.get('pet').catch(() => undefined)
845    const isFirst = !isPet(saved)
846    await change($, null)
847    // a headless run (claude -p, the SDK) still feeds it from tool calls, but draws nothing
848    if (e.isInteractive) {
849      if (isFirst) $.ui.toast('A Tamagotchi egg appeared! Type /tama to see it', { timeoutMs: 8000 })
850      // the pet's clock: every half minute, run its life forward (and see what other sessions did)
851      $.clock.every(TICK_MS, () => {
852        void change($, null)
853      })
854    }
855    for (const name of ['tama', 'tamagotchi']) {
856      await $.command
857        .register({
858          name,
859          description: name === 'tama' ? 'Your Tamagotchi: open it, or feed, play, clean, medicine, lights, stats, help…' : 'Your Tamagotchi (same as /tama)',
860          argumentHint: '[feed|snack|play|clean|medicine|lights|scold|stats|help|name|band|shell]',
861          immediate: true,
862        })
863        .catch(err => $.ui.log(`cc-tamagotchi: /${name} not registered: ${err}`, { to: 'debug' }))
864    }
865    return r
866  })
867
868  // /clear, /resume and /branch reset $.state: put the pet back from the store
869  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
870    await loadSettings($)
871    await loadGraveyard($)
872    await change($, null)
873    return next(e)
874  })
875
876  on('command.run', { command: ['tama', 'tamagotchi'] }, async ($, e) => {
877    await runCommand($, e.args)
878    return {}
879  })
880
881  // Claude's work feeds it: every finished tool call, told apart by kind
882  on('tool.call', async ($, e, next) => {
883    const r = await next(e)
884    const shellCommand = (e as { command?: unknown }).command
885    const kind = classify(e.tool, typeof shellCommand === 'string' ? shellCommand : undefined, 'deny' in r && r.deny !== undefined ? undefined : r.isError !== true)
886    // the pet follows the tool's result; it never holds it up
887    if (kind) void change($, (pet, now) => (pet.vacation !== null ? null : work(pet, kind.work, kind.isError, now))).catch(() => undefined)
888    return r
889  })
890
891  on('turn.start', async ($, e, next) => {
892    await update($, workingAtom, () => true)
893    return next(e)
894  })
895
896  on('turn.complete', async ($, e, next) => {
897    const r = await next(e)
898    if (e.agentId === undefined) {
899      await update($, workingAtom, () => false)
900      const tokens = e.usage?.output_tokens ?? 0
901      void change($, (pet, now) => (pet.vacation !== null ? null : { pet: turnDone(pet, tokens, new Date(now).getHours(), now), ok: true })).catch(() => undefined)
902    }
903    return r
904  })
905
906  // a prompt wakes a napping pet
907  on('prompt.submit', async ($, e, next) => {
908    void change($, (pet, now) => (pet.asleep && !pet.tucked ? { pet: { ...pet, asleep: false, lastActive: now }, ok: true, reaction: 'wake' } : null)).catch(() => undefined)
909    return next(e)
910  })
911
912  // the screen in the terminal: a click pats, keys press the device's buttons
913  on('ui.message', async ($, e, next) => {
914    const data = e.data as { act?: unknown; key?: unknown } | null
915    if (data?.act === 'pat') await doCare($, 'pat')
916    else if (data?.act === 'key' && typeof data.key === 'string') await press($, data.key)
917    return next(e)
918  })
919
920  on('ui.close', async ($, e, next) => {
921    const r = await next(e)
922    if (e.id === PANE) $.ui.invalidate('ui.render')
923    return r
924  })
925
926  on('ui.render', { component: 'Pane', requestId: 'tama' }, async ($, e) => {
927    const c = await context($)
928    const el = $.ui.resolve(e)
929    if (!c) return el.Text({ children: ['waking up…'] })
930    const cols = e.props.bodyColumns
931    if (e.surface === 'terminal') {
932      // docked, the pane has its body's rows; inline above the prompt, about what it asked for
933      const rows = e.props.placement === 'dock' ? e.props.scroll.bodyRows : (e.viewport?.rows ?? 24) >= 60 ? 26 : 20
934      return paneTerminal($, el, c, cols, rows, e.props.placement === 'dock')
935    }
936    return paneRemote($, el, c, cols)
937  })
938
939  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
940    const c = await context($, e.props.isWorking)
941    // the device is up: it says everything the band would
942    if (!c || e.props.hasSurvey || c.settings.band === 'off' || (await isPaneUp($))) return next(e)
943    const el: any = $.ui.resolve(e)
944    const { Box, Client, Svg } = el
945    const { pet } = c
946    const lively = e.props.isWorking || pet.species === 'egg' || isReacting(c) || (c.speech !== null && c.now - c.speech.at < BAND_AFTER_MS)
947    // auto and big: the strip while something is going on, else one line; on: the strip always
948    const blocks = c.settings.band === 'big'
949    // below 60 columns the strip and its side text do not fit: one line it is
950    const big = e.props.bodyColumns >= 60 && e.props.maxRows >= (blocks ? 10 : 6) && (c.settings.band === 'on' || ((c.settings.band === 'auto' || blocks) && lively))
951    const shell = SHELLS[c.settings.shell] ?? SHELLS.coral
952    let mine: unknown
953    if (!big) {
954      mine = bandLine(el, c)
955    } else if (e.surface === 'terminal') {
956      // dots: 16 pixels in 4 rows; blocks: 16 pixels in 8 rows, each pixel a whole cell wide
957      const laneCols = Math.max(16, Math.min(blocks ? 96 : 90, e.props.bodyColumns - 34))
958      mine = (
959        <Box flexDirection="row" columnGap={2}>
960          <Client
961            key={blocks ? 'lane:blocks' : 'lane'}
962            module="./lcd.tsx"
963            width={laneCols}
964            height={blocks ? 8 : 4}
965            props={{ input: sceneOf(c), mode: blocks ? 'strip' : 'lane', age: reactionAge(c) ?? 999999, ink: shell.base }}
966          />
967          {bandLaneSide(el, c)}
968        </Box>
969      )
970    } else if (Svg) {
971      const svg = laneSvg(sceneOf(c), isReacting(c) ? reactionAge(c) : null, shell.base, 80)
972      mine = (
973        <Box flexDirection="row" columnGap={2} alignItems="center">
974          <Svg key="lane" source={svg} alt={statusLine(pet)} width={240} height={48} isInteractive />
975          {bandLaneSide(el, c)}
976        </Box>
977      )
978    } else {
979      mine = bandLine(el, c)
980    }
981    return (
982      <Box flexDirection="column">
983        {mine}
984        {await next(e)}
985      </Box>
986    )
987  })
988
989  // its face rides beside the spinner while Claude works
990  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
991    const settings = await read($, settingsAtom)
992    const pet = await read($, petAtom)
993    if (!settings.spinner || !pet || pet.species === 'egg' || pet.gone) return next(e)
994    return next({ ...e, props: { ...e.props, suffix: `${e.props.suffix} ${face(pet)}` } })
995  })
996
997  // when it needs something and Claude is idle, the hint line under the prompt says so
998  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
999    if (e.props.isWorking || e.props.isDraft) return next(e)
1000    const pet = await read($, petAtom)
1001    if (!pet?.calling || pet.gone) return next(e)
1002    return next({ ...e, props: { ...e.props, tail: `${pet.name} ${NEED_TEXT[pet.calling]} · /tama` } })
1003  })
1004}
1005
hooks/pet/life.ts 594 lines
1import type { Memorial, Need, Pet, PetStats, ReactionKind, Settings, SpeciesId } from '../../types'
2import { newId, pick, roll } from './rng.ts'
3import { species } from './species.ts'
4
5// The pet's life, as pure functions of the pet and the time. Nothing here touches the engine:
6// the hooks module reads the pet from $.store, runs it forward with `tick`, applies what the person
7// did (`care`) and what Claude did (`work`), and writes it back.
8
9export const MINUTE = 60_000
10export const HOUR = 60 * MINUTE
11export const DAY = 24 * HOUR
12
13/** No activity from Claude or the person for this long, and it naps. */
14export const NAP_AFTER = 20 * MINUTE
15/** Tucked in, it sleeps at most this long before it is just napping. */
16const TUCK_MAX = 10 * HOUR
17/** Full to empty in four awake hours; content to miserable in five. Asleep, a tenth as fast. */
18const HUNGER_PER_MIN = 100 / (4 * 60)
19const HAPPY_PER_MIN = 100 / (5 * 60)
20const ASLEEP = 0.1
21/** Awake minutes a call may go unanswered before it counts as a care mistake. */
22export const CALL_GRACE = 15
23/** Minutes of hard neglect before a pet leaves, in classic mode: half a day awake, days asleep. */
24export const DEPART_AFTER = 12 * 60
25/** A pet left alone longer than this is simulated from this far back only. */
26const MAX_SIM = 30 * DAY
27export const MAX_BUGS = 4
28
29export const HATCH_AFTER = MINUTE
30export const GROWTH: Record<'baby' | 'child' | 'teen', { age: number; toolCalls: number }> = {
31  baby: { age: HOUR, toolCalls: 40 },
32  child: { age: DAY, toolCalls: 250 },
33  teen: { age: 3 * DAY, toolCalls: 800 },
34}
35
36const NAMES = ['Mochi', 'Pixel', 'Tofu', 'Bitsy', 'Dumpling', 'Biscuit', 'Momo', 'Kiwi', 'Taro', 'Pudding', 'Noodle', 'Sprout', 'Bean', 'Pico', 'Waffle', 'Nori', 'Gizmo', 'Peanut']
37
38export const emptyStats = (): PetStats => ({
39  toolCalls: 0, edits: 0, tests: 0, testFails: 0, commits: 0, pushes: 0, shell: 0, searches: 0, agents: 0, errors: 0,
40  turns: 0, nightTurns: 0, tokens: 0, meals: 0, snacks: 0, games: 0, wins: 0, cleans: 0, meds: 0, pats: 0, scolds: 0,
41})
42
43export function newPet(now: number, opts: { name?: string; generation?: number; id?: string } = {}): Pet {
44  const id = opts.id ?? newId(now)
45  return {
46    v: 1,
47    id,
48    name: opts.name ?? pick(NAMES, roll(id, 'name')),
49    species: 'egg',
50    generation: opts.generation ?? 1,
51    born: now,
52    hatchedAt: null,
53    evolvedAt: now,
54    at: now,
55    hunger: 100,
56    happy: 100,
57    discipline: 0,
58    weight: species('egg').weight,
59    bugs: 0,
60    sick: 0,
61    asleep: false,
62    tucked: false,
63    lastActive: now,
64    lastMeal: null,
65    lastPat: 0,
66    tantrum: false,
67    calling: null,
68    callWait: 0,
69    callingCounted: false,
70    mistakes: 0,
71    neglect: 0,
72    vacation: null,
73    gone: null,
74    rev: 0,
75    stats: emptyStats(),
76  }
77}
78
79/** Whether a value read back from the store is a pet this version can run. */
80export function isPet(value: unknown): value is Pet {
81  if (typeof value !== 'object' || value === null) return false
82  const p = value as Partial<Pet>
83  return p.v === 1 && typeof p.id === 'string' && typeof p.at === 'number' && typeof p.species === 'string' && typeof p.stats === 'object'
84}
85
86/** A pet from the store, with any field an older version did not write filled in. */
87export function revive(value: Pet): Pet {
88  const fresh = newPet(value.born ?? value.at, { id: value.id, name: value.name })
89  return { ...fresh, ...value, stats: { ...emptyStats(), ...value.stats } }
90}
91
92// ── reading the pet ─────────────────────────────────────────────────────────────────────────
93
94/** 0 to 4 hearts. Anything above an empty meter shows at least one. */
95export const hearts = (value: number): number => Math.max(0, Math.min(4, Math.ceil(value / 25 - 1e-9)))
96
97export const stageOf = (p: Pet) => species(p.species).stage
98
99/** What it is calling about right now, most urgent first. */
100export function needOf(p: Pet): Need | null {
101  if (p.gone || p.species === 'egg') return null
102  if (p.sick > 0) return 'sick'
103  if (p.hunger <= 0) return 'hungry'
104  if (p.happy <= 0) return 'sad'
105  if (p.bugs >= 3) return 'bugs'
106  if (p.tantrum) return 'tantrum'
107  return null
108}
109
110export function ageText(ms: number): string {
111  const m = Math.floor(ms / MINUTE)
112  if (m < 60) return `${m}m`
113  const h = Math.floor(m / 60)
114  if (h < 24) return `${h}h ${m % 60}m`
115  const d = Math.floor(h / 24)
116  return `${d}d ${h % 24}h`
117}
118
119export const ageOf = (p: Pet, now: number): number => (p.hatchedAt === null ? 0 : (p.gone?.at ?? now) - p.hatchedAt)
120
121/** The next form it is growing towards and what is still missing, for the stats page. */
122export function nextGrowth(p: Pet, now: number): string | null {
123  const stage = stageOf(p)
124  if (stage === 'egg') return 'hatches soon'
125  if (stage === 'adult') return null
126  const need = GROWTH[stage]
127  const age = ageOf(p, now)
128  const parts: string[] = []
129  if (age < need.age) parts.push(`${ageText(need.age - age)} older`)
130  if (p.stats.toolCalls < need.toolCalls) parts.push(`${need.toolCalls - p.stats.toolCalls} more tool calls`)
131  return parts.length ? `grows after ${parts.join(' and ')}` : 'about to grow'
132}
133
134// ── time ────────────────────────────────────────────────────────────────────────────────────
135
136export type LifeEvent =
137  | { kind: 'hatched' }
138  | { kind: 'evolved'; from: SpeciesId; to: SpeciesId }
139  | { kind: 'bug' }
140  | { kind: 'sick' }
141  | { kind: 'tantrum' }
142  | { kind: 'calling'; need: Need }
143  | { kind: 'mistake'; need: Need }
144  | { kind: 'napped' }
145  | { kind: 'departed' }
146
147const clone = (p: Pet): Pet => ({ ...p, stats: { ...p.stats } })
148
149/**
150 * Runs the pet's life forward to `now`, a whole minute at a time, and says what happened.
151 * Pure and deterministic: every roll is seeded by the pet's id and the minute.
152 */
153export function tick(p: Pet, now: number, mode: Settings['mode'] = 'classic'): { pet: Pet; events: LifeEvent[] } {
154  const events: LifeEvent[] = []
155  if (p.gone || p.vacation !== null) return { pet: p, events }
156  const pet = clone(p)
157
158  if (pet.species === 'egg') {
159    pet.at = Math.max(pet.at, now)
160    hatch(pet, now, events)
161    return events.length ? { pet, events } : { pet: now > p.at ? pet : p, events }
162  }
163
164  if (now - pet.at > MAX_SIM) pet.at = now - MAX_SIM
165  const steps = Math.max(0, Math.floor((now - pet.at) / MINUTE))
166  for (let i = 0; i < steps && !pet.gone; i++) {
167    minute(pet, pet.at + MINUTE, mode, events)
168    pet.at += MINUTE
169  }
170  if (!pet.gone) grow(pet, now, events)
171  return steps > 0 || events.length > 0 ? { pet, events } : { pet: p, events }
172}
173
174function hatch(pet: Pet, now: number, events: LifeEvent[]): void {
175  const waited = now - pet.born
176  if (waited >= HATCH_AFTER && (pet.stats.toolCalls >= 1 || waited >= 3 * MINUTE)) {
177    pet.species = 'bit'
178    pet.hatchedAt = now
179    pet.evolvedAt = now
180    pet.lastActive = Math.max(pet.lastActive, now)
181    pet.weight = species('bit').weight
182    events.push({ kind: 'hatched' })
183  }
184}
185
186function minute(pet: Pet, t: number, mode: Settings['mode'], events: LifeEvent[]): void {
187  const m = Math.floor(t / MINUTE)
188
189  // sleep: a nap after a while with nothing going on; a tucked-in pet sleeps through Claude's work
190  if (pet.tucked && t - pet.lastActive >= TUCK_MAX) pet.tucked = false
191  if (!pet.asleep && t - pet.lastActive >= NAP_AFTER) {
192    pet.asleep = true
193    events.push({ kind: 'napped' })
194  }
195  const f = pet.asleep ? ASLEEP : 1
196
197  pet.hunger = Math.max(0, pet.hunger - HUNGER_PER_MIN * f * (pet.sick ? 1.25 : 1))
198  pet.happy = Math.max(0, pet.happy - HAPPY_PER_MIN * f * (1 + 0.25 * pet.bugs) * (pet.sick ? 2 : 1))
199
200  // bugs: one about half an hour after each meal, and now and then while awake
201  if (pet.lastMeal !== null) {
202    const due = pet.lastMeal + (28 + Math.floor(roll(pet.id, pet.lastMeal, 'digest') * 17)) * MINUTE
203    if (t >= due && t - MINUTE < due) addBug(pet, events)
204  }
205  if (!pet.asleep && roll(pet.id, m, 'bug') < 1 / 150) addBug(pet, events)
206
207  // sickness: bugs left lying around, starving, or too many snacks
208  if (pet.sick === 0) {
209    let risk = 0
210    if (pet.bugs >= 2) risk += 0.002 * pet.bugs
211    if (pet.hunger <= 0) risk += 0.004
212    if (pet.weight > species(pet.species).weight * 1.6) risk += 0.001
213    if (roll(pet.id, m, 'sick') < risk * f) {
214      pet.sick = roll(pet.id, m, 'doses') < 0.35 ? 2 : 1
215      pet.tantrum = false
216      events.push({ kind: 'sick' })
217    }
218  }
219
220  // a tantrum: calling for nothing, which is what discipline is for
221  const isLittle = pet.species === 'bit'
222  if (!pet.asleep && !pet.tantrum && !isLittle && pet.sick === 0 && pet.hunger > 50 && pet.happy > 50 && pet.discipline < 100) {
223    if (roll(pet.id, m, 'tantrum') < 1 / 300) {
224      pet.tantrum = true
225      events.push({ kind: 'tantrum' })
226    }
227  }
228
229  // calls: one left unanswered for a quarter of an awake hour is a care mistake
230  const need = needOf(pet)
231  if (need !== pet.calling) {
232    pet.calling = need
233    pet.callWait = 0
234    pet.callingCounted = false
235    if (need) events.push({ kind: 'calling', need })
236  } else if (need) {
237    pet.callWait += f
238    if (!pet.callingCounted && pet.callWait >= CALL_GRACE) {
239      pet.mistakes += 1
240      pet.callingCounted = true
241      events.push({ kind: 'mistake', need })
242      // an ignored tantrum blows over
243      if (need === 'tantrum') pet.tantrum = false
244    }
245  }
246
247  // hard neglect: starving and miserable, or sick. In classic mode, half an awake day of it and it leaves.
248  // Gentle mode counts none of it, so switching to classic starts the count from nothing.
249  const isHard = (pet.hunger <= 0 && pet.happy <= 0) || pet.sick > 0
250  if (mode === 'gentle') pet.neglect = 0
251  else pet.neglect = isHard ? Math.min(DEPART_AFTER, pet.neglect + f) : Math.max(0, pet.neglect - f / 2)
252  if (mode === 'classic' && isHard && pet.neglect >= DEPART_AFTER) {
253    pet.gone = { at: t, cause: 'neglect' }
254    pet.asleep = false
255    pet.calling = null
256    events.push({ kind: 'departed' })
257  }
258}
259
260function addBug(pet: Pet, events: LifeEvent[]): void {
261  if (pet.bugs >= MAX_BUGS) return
262  pet.bugs += 1
263  events.push({ kind: 'bug' })
264}
265
266/** Grows into the next form once it is old enough and has watched enough of Claude's work. */
267function grow(pet: Pet, now: number, events: LifeEvent[]): void {
268  const stage = stageOf(pet)
269  if (stage === 'egg' || stage === 'adult') return
270  const need = GROWTH[stage]
271  if (ageOf(pet, now) < need.age || pet.stats.toolCalls < need.toolCalls) return
272  const from = pet.species
273  const to: SpeciesId = stage === 'baby' ? 'nibble' : stage === 'child' ? (pet.mistakes <= 3 ? 'bytey' : 'glitchy') : adultFor(pet)
274  pet.weight = Math.max(1, pet.weight + species(to).weight - species(from).weight)
275  pet.species = to
276  pet.evolvedAt = now
277  events.push({ kind: 'evolved', from, to })
278}
279
280/** Which adult a teen becomes: care first, then the work it watched Claude do the most of. */
281export function adultFor(pet: Pet): SpeciesId {
282  const s = pet.stats
283  if (pet.mistakes >= 8 || (pet.species === 'glitchy' && pet.mistakes >= 6)) return 'spaghetto'
284  if (pet.mistakes <= 1 && pet.discipline >= 75) return 'unicorn'
285  if (s.turns >= 20 && s.nightTurns / s.turns >= 0.4) return 'owlgorithm'
286  // weighted so a habit counts for what it is, not for how often a tool happens to be called
287  const scores: [SpeciesId, number][] = [
288    ['grepossum', s.searches],
289    ['refactopus', s.edits * 1.5],
290    ['shellby', s.shell * 1.3],
291    ['assertoise', (s.tests + s.testFails) * 6],
292    ['commitmunk', s.commits * 25 + s.pushes * 15],
293  ]
294  return scores.reduce((best, one) => (one[1] > best[1] ? one : best))[0]
295}
296
297/** After care or work, what it calls about is what it needs now: a fed pet stops calling hungry at once. */
298function settle(out: Outcome): Outcome {
299  const pet = out.pet
300  const need = needOf(pet)
301  if (need === pet.calling) return out
302  return { ...out, pet: { ...pet, calling: need, callWait: 0, callingCounted: false } }
303}
304
305// ── the person's care ───────────────────────────────────────────────────────────────────────
306
307export type Care = 'meal' | 'snack' | 'clean' | 'medicine' | 'lights' | 'scold' | 'pat'
308
309export type Outcome = { pet: Pet; reaction?: ReactionKind; say?: string; ok: boolean }
310
311/** What the person did. The pet is assumed ticked to `now` already. */
312export function care(p: Pet, what: Care, now: number): Outcome {
313  return settle(careFor(p, what, now))
314}
315
316function careFor(p: Pet, what: Care, now: number): Outcome {
317  if (p.gone) return { pet: p, ok: false, say: `${p.name} is not here any more` }
318  if (p.species === 'egg') return { pet: p, ok: false, say: 'it is still an egg: give it a minute' }
319  const pet = clone(p)
320  const name = pet.name
321
322  // anything but the lights and a pat wakes it
323  if (what !== 'lights' && what !== 'pat' && pet.asleep) {
324    pet.asleep = false
325    pet.tucked = false
326  }
327  if (what !== 'lights') pet.lastActive = Math.max(pet.lastActive, now)
328
329  switch (what) {
330    case 'meal': {
331      if (pet.tantrum) return { pet, ok: false, reaction: 'refuse', say: `${name} won't eat. A tantrum: try discipline` }
332      if (pet.hunger >= 100) return { pet, ok: false, reaction: 'refuse', say: `${name} is full` }
333      pet.hunger = Math.min(100, pet.hunger + 25)
334      pet.weight += 1
335      pet.lastMeal = now
336      pet.stats.meals += 1
337      return { pet, ok: true, reaction: 'eat', say: pick(['*munch munch*', 'nom nom nom', 'tasty!', 'rice ball!'], roll(pet.id, now, 'say')) }
338    }
339    case 'snack': {
340      if (pet.tantrum) return { pet, ok: false, reaction: 'refuse', say: `${name} throws the candy away. A tantrum: try discipline` }
341      pet.happy = Math.min(100, pet.happy + 25)
342      pet.hunger = Math.min(100, pet.hunger + 5)
343      pet.weight += 2
344      pet.stats.snacks += 1
345      return { pet, ok: true, reaction: 'snack', say: pick(['sweet!!', 'yum, sugar', '*crunch*', 'more?'], roll(pet.id, now, 'say')) }
346    }
347    case 'clean': {
348      if (pet.bugs === 0) return { pet, ok: false, reaction: 'clean', say: 'already squeaky clean' }
349      const n = pet.bugs
350      pet.bugs = 0
351      pet.happy = Math.min(100, pet.happy + 4)
352      pet.stats.cleans += 1
353      return { pet, ok: true, reaction: 'clean', say: n === 1 ? 'squashed the bug' : `squashed ${n} bugs` }
354    }
355    case 'medicine': {
356      if (pet.sick === 0) {
357        pet.happy = Math.max(0, pet.happy - 5)
358        return { pet, ok: false, reaction: 'refuse', say: `${name} is not sick, and medicine tastes awful` }
359      }
360      pet.sick -= 1
361      pet.stats.meds += 1
362      return { pet, ok: true, reaction: 'medicine', say: pet.sick === 0 ? `${name} feels better` : 'one more dose to go' }
363    }
364    case 'lights': {
365      if (pet.asleep) {
366        pet.asleep = false
367        pet.tucked = false
368        pet.lastActive = Math.max(pet.lastActive, now)
369        return { pet, ok: true, reaction: 'wake', say: 'good morning!' }
370      }
371      pet.asleep = true
372      pet.tucked = true
373      pet.lastActive = now
374      return { pet, ok: true, reaction: 'sleep', say: 'lights out. zzz' }
375    }
376    case 'scold': {
377      if (pet.tantrum) {
378        pet.tantrum = false
379        pet.discipline = Math.min(100, pet.discipline + 25)
380        pet.stats.scolds += 1
381        return { pet, ok: true, reaction: 'scold', say: `${name} behaves now` }
382      }
383      pet.happy = Math.max(0, pet.happy - 20)
384      return { pet, ok: false, reaction: 'scold', say: `${name} did nothing wrong. :(` }
385    }
386    case 'pat': {
387      if (pet.asleep) return { pet, ok: false, say: 'shh, it is sleeping' }
388      if (now - pet.lastPat < 5_000) return { pet, ok: false, reaction: 'pat' }
389      pet.happy = Math.min(100, pet.happy + 2)
390      pet.lastPat = now
391      pet.lastActive = Math.max(pet.lastActive, now)
392      pet.stats.pats += 1
393      return { pet, ok: true, reaction: 'pat', say: pick(['♥', 'hehe', 'purr…', '*wiggles*'], roll(pet.id, now, 'say')) }
394    }
395  }
396}
397
398/** Whether a game can start, and why not. */
399export function canPlay(p: Pet): string | null {
400  if (p.gone) return `${p.name} is not here any more`
401  if (p.species === 'egg') return 'it is still an egg'
402  if (p.sick > 0) return `${p.name} is too sick to play`
403  if (p.hunger <= 0) return `${p.name} is too hungry to play`
404  return null
405}
406
407/** A finished game of five rounds. Three wins lift its mood; any game burns a gram. */
408export function played(p: Pet, wins: number, now: number): Outcome {
409  return settle(playedFor(p, wins, now))
410}
411
412function playedFor(p: Pet, wins: number, now: number): Outcome {
413  const pet = clone(p)
414  pet.asleep = false
415  pet.tucked = false
416  pet.lastActive = Math.max(pet.lastActive, now)
417  pet.stats.games += 1
418  pet.weight = Math.max(species(pet.species).weight - 5, pet.weight - 1)
419  pet.hunger = Math.max(0, pet.hunger - 4)
420  if (wins >= 3) {
421    pet.happy = Math.min(100, pet.happy + 25)
422    pet.stats.wins += 1
423    return { pet, ok: true, reaction: 'win', say: `won ${wins} of 5!` }
424  }
425  pet.happy = Math.min(100, pet.happy + 5)
426  return { pet, ok: true, reaction: 'lose', say: `${wins} of 5. again?` }
427}
428
429export function rename(p: Pet, name: string): Pet {
430  const clean = name.replace(/[^\p{L}\p{N} _.'-]/gu, '').trim().slice(0, 16)
431  return clean ? { ...p, name: clean } : p
432}
433
434export function startVacation(p: Pet, now: number): Pet {
435  return p.vacation !== null || p.gone ? p : { ...p, vacation: now }
436}
437
438/** Ends a vacation: the time away never happened, so its age and its growth skip it too. */
439export function endVacation(p: Pet, now: number): Pet {
440  if (p.vacation === null) return p
441  const away = Math.max(0, now - p.vacation)
442  return {
443    ...p,
444    vacation: null,
445    born: p.born + away,
446    hatchedAt: p.hatchedAt === null ? null : p.hatchedAt + away,
447    evolvedAt: p.evolvedAt + away,
448    at: now,
449    lastActive: now,
450    asleep: false,
451    tucked: false,
452    lastMeal: null,
453  }
454}
455
456export function memorialOf(p: Pet): Memorial {
457  return {
458    name: p.name,
459    species: p.species,
460    generation: p.generation,
461    born: p.born,
462    gone: p.gone?.at ?? p.at,
463    toolCalls: p.stats.toolCalls,
464    commits: p.stats.commits,
465  }
466}
467
468/** The next generation: a new egg, the old pet's name kept unless the person gives another. */
469export const nextEgg = (p: Pet, now: number): Pet => newPet(now, { generation: p.generation + 1 })
470
471// ── Claude's work ───────────────────────────────────────────────────────────────────────────
472
473export type Work = 'edit' | 'test-pass' | 'test-fail' | 'commit' | 'push' | 'shell' | 'search' | 'agent' | 'other'
474
475const EDITS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
476const SEARCHES = new Set(['Read', 'Grep', 'Glob', 'LS', 'WebSearch', 'WebFetch', 'NotebookRead', 'ToolSearch'])
477const AGENTS = new Set(['Task', 'Agent'])
478const SHELLS = new Set(['Bash', 'PowerShell'])
479
480// Test runners across the usual stacks, matched where a command starts (after any `cd x &&`, an
481// env assignment or `npx`), so `cat jest.config.js` or a commit message that names pytest is not a run.
482const TEST_START =
483  /^(?:(?:npx|bunx|pnpx|pnpm\s+(?:exec|dlx)|yarn\s+(?:exec|dlx)|uv\s+run|poetry\s+run|pipenv\s+run|hatch\s+run)\s+)?(?:jest|vitest|mocha|ava|tap|pytest|rspec|phpunit|tox|nox|ctest|playwright\s+test)\b|^(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test\b|^python3?\s+-m\s+(?:pytest|unittest)\b|^go\s+test\b|^cargo\s+(?:test|nextest)\b|^make\s+(?:test|check)\b|^(?:\.\/)?(?:mvn|mvnw|gradle|gradlew|sbt)\b.*\btest\b|^dotnet\s+test\b|^mix\s+test\b|^deno\s+test\b|^swift\s+test\b|^(?:claude\s+)?plugin\s+test\b/
484
485/** The commands of a shell line, each without leading env assignments or wrappers. */
486export function commandsOf(line: string): string[] {
487  return line
488    .split(/&&|\|\||;|\||\n/)
489    .map(part =>
490      part
491        .trim()
492        .replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/, '')
493        .replace(/^(?:time|sudo|exec|env|command|nice)\s+/, ''),
494    )
495    .filter(Boolean)
496}
497
498const isGit = (part: string, verb: string) => new RegExp(`^git\\s+(?:-C\\s+\\S+\\s+|-c\\s+\\S+\\s+)*${verb}\\b`).test(part) && !/--dry-run\b/.test(part)
499
500/**
501 * What a finished tool call was, for the pet: `ok` is false when the tool failed and undefined
502 * when the call was refused (which feeds nothing). Shell lines are read command by command.
503 */
504export function classify(tool: string, command: string | undefined, ok: boolean | undefined): { work: Work; isError: boolean } | null {
505  if (ok === undefined) return null
506  if (SHELLS.has(tool) && command) {
507    const parts = commandsOf(command)
508    if (ok && parts.some(part => isGit(part, 'commit'))) return { work: 'commit', isError: false }
509    if (ok && parts.some(part => isGit(part, 'push'))) return { work: 'push', isError: false }
510    if (parts.some(part => TEST_START.test(part))) return { work: ok ? 'test-pass' : 'test-fail', isError: false }
511    return { work: 'shell', isError: !ok }
512  }
513  if (EDITS.has(tool)) return { work: ok ? 'edit' : 'other', isError: !ok }
514  if (SEARCHES.has(tool)) return { work: 'search', isError: !ok }
515  if (AGENTS.has(tool)) return { work: 'agent', isError: !ok }
516  return { work: 'other', isError: !ok }
517}
518
519/** Claude or the person was active: a napping pet wakes (a tucked-in one sleeps on). */
520export function touch(p: Pet, now: number): { pet: Pet; woke: boolean } {
521  if (p.gone) return { pet: p, woke: false }
522  const woke = p.asleep && !p.tucked
523  return { pet: { ...p, lastActive: Math.max(p.lastActive, now), asleep: woke ? false : p.asleep }, woke }
524}
525
526/** One tool call Claude finished. The pet is assumed ticked to `now` already. */
527export function work(p: Pet, w: Work, isError: boolean, now: number): Outcome {
528  return settle(workFor(p, w, isError, now))
529}
530
531function workFor(p: Pet, w: Work, isError: boolean, now: number): Outcome {
532  if (p.gone) return { pet: p, ok: false }
533  const pet = touch(clone(p), now).pet
534  const s = pet.stats
535  s.toolCalls += 1
536  if (isError) s.errors += 1
537  const isEgg = pet.species === 'egg'
538  const awake = !pet.asleep
539
540  switch (w) {
541    case 'edit':
542      s.edits += 1
543      if (!isEgg) pet.hunger = Math.min(100, pet.hunger + 1)
544      return { pet, ok: true, reaction: awake ? 'nibble' : undefined }
545    case 'test-pass':
546      s.tests += 1
547      if (isEgg) return { pet, ok: true }
548      pet.happy = Math.min(100, pet.happy + 6)
549      if (pet.bugs > 0) {
550        pet.bugs -= 1
551        return { pet, ok: true, reaction: 'cheer', say: 'the tests squashed a bug!' }
552      }
553      return { pet, ok: true, reaction: awake ? 'cheer' : undefined, say: awake ? pick(['tests pass! ♪', 'all green ♪', 'yay, green!'], roll(pet.id, now, 'say')) : undefined }
554    case 'test-fail':
555      s.testFails += 1
556      if (isEgg) return { pet, ok: true }
557      pet.happy = Math.max(0, pet.happy - 4)
558      pet.bugs = Math.min(MAX_BUGS, pet.bugs + 1)
559      return { pet, ok: true, reaction: 'flinch', say: pick(['a bug crawled out of the tests!', 'red… a bug!', 'eek, failing tests'], roll(pet.id, now, 'say')) }
560    case 'commit':
561      s.commits += 1
562      if (isEgg) return { pet, ok: true }
563      pet.happy = Math.min(100, pet.happy + 12)
564      pet.hunger = Math.min(100, pet.hunger + 3)
565      return { pet, ok: true, reaction: 'party', say: pick(['committed! party!', 'into the reflog it goes ♪', 'ship it!'], roll(pet.id, now, 'say')) }
566    case 'push':
567      s.pushes += 1
568      if (isEgg) return { pet, ok: true }
569      pet.happy = Math.min(100, pet.happy + 8)
570      return { pet, ok: true, reaction: 'party', say: 'pushed! wheee' }
571    case 'shell':
572      s.shell += 1
573      return { pet, ok: true, reaction: isError && awake && !isEgg ? 'flinch' : undefined }
574    case 'search':
575      s.searches += 1
576      return { pet, ok: true }
577    case 'agent':
578      s.agents += 1
579      return { pet, ok: true }
580    case 'other':
581      return { pet, ok: true, reaction: isError && awake && !isEgg ? 'flinch' : undefined }
582  }
583}
584
585/** A turn of Claude's ended: count it, the tokens it wrote, and whether it was late at night. */
586export function turnDone(p: Pet, tokens: number, hour: number, now: number): Pet {
587  if (p.gone) return p
588  const pet = touch(clone(p), now).pet
589  pet.stats.turns += 1
590  pet.stats.tokens += Math.max(0, Math.round(tokens))
591  if (hour >= 23 || hour < 5) pet.stats.nightTurns += 1
592  return pet
593}
594
hooks/pet/scene.ts 333 lines
1import type { ReactionKind, SpeciesId } from '../../types'
2import { blank, draw, invert, spriteWidth, type Bitmap } from './bitmap.ts'
3import { roll } from './rng.ts'
4import * as S from './sprites.ts'
5import type { Sprite } from './sprites.ts'
6import { SPECIES } from './species.ts'
7
8// What the LCD shows at a moment: the pet walking about, its bugs, the icons around it, and the
9// animation of whatever just happened. `scene` is a pure function of what the hooks module knows
10// (SceneInput) and the screen's clock, and returns a display list: the terminal rasterizes it into
11// a Bitmap, the desktop turns a run of them into an animated SVG.
12
13/** Every picture a scene can place, by name: the SVG defines each once and reuses it. */
14export const ART: Record<string, Sprite> = {
15  ...Object.fromEntries(Object.values(SPECIES).map(s => [s.id, s.sprite])),
16  angel: S.ANGEL,
17  crack: S.CRACK,
18  bug0: S.BUG[0]!,
19  bug1: S.BUG[1]!,
20  rice0: S.RICE[0]!,
21  rice1: S.RICE[1]!,
22  rice2: S.RICE[2]!,
23  candy0: S.CANDY[0]!,
24  candy1: S.CANDY[1]!,
25  candy2: S.CANDY[2]!,
26  pill: S.PILL,
27  heart: S.SMALL_HEART,
28  note: S.NOTE,
29  skull: S.SKULL,
30  bang: S.BANG,
31  sweat: S.SWEAT,
32  zz: S.ZZ,
33  z: S.SMALL_Z,
34  spark0: S.SPARK[0]!,
35  spark1: S.SPARK[1]!,
36  left: S.ARROW_LEFT,
37  right: S.ARROW_RIGHT,
38  check: S.CHECK,
39  cross: S.CROSS,
40  star: S.STAR,
41  wave: S.WAVE,
42}
43
44export type ArtName = keyof typeof ART & string
45
46export type Draw = { art: ArtName; x: number; y: number; flip?: boolean; eyesClosed?: boolean; mouthOpen?: boolean }
47export type Frame = { draws: Draw[]; dark: boolean }
48
49/** Everything about the pet a screen needs, as plain data: what the hooks module hands a Client. */
50export type SceneInput = {
51  species: SpeciesId
52  seed: string
53  asleep: boolean
54  tucked: boolean
55  sick: boolean
56  bugs: number
57  calling: boolean
58  tantrum: boolean
59  sad: boolean
60  working: boolean
61  gone: boolean
62  reaction: { kind: ReactionKind; id: number; from?: SpeciesId } | null
63  game: { answer: 'left' | 'right' | null; guess: 'left' | 'right' | null; round: number } | null
64}
65
66/** How long each animation plays, in milliseconds. */
67export const REACTION_MS: Record<ReactionKind, number> = {
68  eat: 2400,
69  snack: 2400,
70  refuse: 1500,
71  happy: 1400,
72  pat: 1400,
73  clean: 1800,
74  medicine: 1600,
75  scold: 1400,
76  cheer: 1200,
77  flinch: 900,
78  party: 2000,
79  nibble: 500,
80  hatch: 2600,
81  evolve: 3200,
82  sleep: 1200,
83  wake: 1000,
84  win: 2000,
85  lose: 1500,
86}
87
88const SEGMENT = 2400
89const STEP = 130
90/** The walk repeats after this many segments, so the desktop can loop it seamlessly. */
91export const WALK_SEGMENTS = 4
92export const WALK_LOOP = SEGMENT * WALK_SEGMENTS
93
94const size = (art: ArtName) => {
95  const s = ART[art]!
96  return { w: spriteWidth(s), h: s.length }
97}
98
99/** Where a walking pet is at time t: it strolls to a new spot each segment, then waits. */
100export function walk(seed: string, t: number, maxX: number): { x: number; flip: boolean; moving: boolean; hop: boolean } {
101  if (maxX <= 0) return { x: 0, flip: false, moving: false, hop: false }
102  const k = Math.floor(t / SEGMENT)
103  const spot = (n: number) => Math.round(roll(seed, 'walk', ((n % WALK_SEGMENTS) + WALK_SEGMENTS) % WALK_SEGMENTS) * maxX)
104  const from = spot(k)
105  const to = spot(k + 1)
106  const local = t - k * SEGMENT
107  const dx = to - from
108  const moved = Math.min(Math.abs(dx), Math.floor(local / STEP))
109  const moving = moved < Math.abs(dx)
110  return { x: from + Math.sign(dx) * moved, flip: dx < 0 || (dx === 0 && spot(k - 1) > from), moving, hop: moving && Math.floor(local / (STEP * 2)) % 2 === 1 }
111}
112
113/**
114 * The display list at clock `t` (ms since the screen began) on a w×h screen. `rt` is how long the
115 * current reaction has been playing, or null when none is.
116 */
117export function scene(input: SceneInput, t: number, rt: number | null, w = 32, h = 16): Frame {
118  const draws: Draw[] = []
119  const reaction = input.reaction && rt !== null && rt < REACTION_MS[input.reaction.kind] ? input.reaction.kind : null
120  const r = rt ?? 0
121  const blink = (period: number, on: number) => t % period < on
122
123  if (input.gone) {
124    const { w: sw, h: sh } = size('angel')
125    const bob = Math.floor(t / 600) % 2
126    draws.push({ art: 'angel', x: Math.floor((w - sw) / 2), y: h - sh - 1 - bob })
127    return { draws, dark: false }
128  }
129
130  if (input.species === 'egg' || (reaction === 'hatch' && r < 2100)) {
131    return egg(input, t, reaction === 'hatch' ? r : null, w, h)
132  }
133
134  // the pet's sprite this frame: during an evolution it flickers between the old form and the new
135  let art: ArtName = input.species
136  if (reaction === 'evolve' && input.reaction?.from && Math.floor(r / 250) % 2 === 0 && r < REACTION_MS.evolve - 600) art = input.reaction.from
137  const { w: sw, h: sh } = size(art)
138
139  // bugs stand at the right edge, in one column or two; the pet keeps out of their way
140  const bugCols = input.bugs === 0 ? 0 : input.bugs <= 2 ? 1 : 2
141  // the right edge also holds the corner icon (a spark while Claude works, a skull while sick)
142  const corner = !input.asleep && (input.working || input.sick) ? 6 : 0
143  const room = w - Math.max(bugCols * 6, corner)
144  const maxX = Math.max(0, room - sw)
145  const center = Math.floor(maxX / 2)
146
147  // lights out: the screen goes dark and only the Z's show
148  if (input.tucked && reaction !== 'wake') {
149    const z = Math.floor(t / 700) % 2 === 0 ? 'zz' : 'z'
150    const { w: zw } = size(z)
151    draws.push({ art: z, x: Math.floor((w - zw) / 2) + (z === 'z' ? 3 : 0), y: Math.floor(h / 2) - 2 - (z === 'z' ? 2 : 0) })
152    return { draws, dark: true }
153  }
154
155  let x = center
156  let y = h - sh
157  let flip = false
158  let eyesClosed = false
159  let mouthOpen = false
160  const front: Draw[] = []
161
162  const isStill = input.asleep || input.sick || input.game !== null || (reaction !== null && reaction !== 'nibble' && reaction !== 'cheer' && reaction !== 'flinch')
163  if (!isStill) {
164    const step = walk(input.seed, t, maxX)
165    x = step.x
166    flip = step.flip
167    if (step.hop) y -= 1
168  } else if (input.sick && !reaction) {
169    // too sick to stroll: shuffles a little
170    x = Math.max(0, Math.min(maxX, center + (Math.floor(t / 900) % 2)))
171  }
172
173  // a blink every few seconds
174  if (blink(3700, 160)) eyesClosed = true
175
176  if (input.asleep) {
177    eyesClosed = true
178    const z = Math.floor(t / 800) % 2 === 0 ? 'zz' : 'z'
179    const zx = x + sw + 4 <= room ? x + sw : Math.max(0, x - 4)
180    front.push({ art: z, x: zx, y: z === 'zz' ? 0 : 1 })
181  }
182
183  if (input.game) {
184    // it stands in the middle; the way it looked, and whether the guess was right, at that side
185    const g = input.game
186    x = center
187    if (g.answer) {
188      flip = g.answer === 'left'
189      const side = g.answer === 'left' ? 0 : room - 7
190      front.push({ art: g.answer === 'left' ? 'left' : 'right', x: g.answer === 'left' ? 0 : room - 4, y: 1 })
191      front.push({ art: g.guess === g.answer ? 'check' : 'cross', x: side, y: 9 })
192    } else if (blink(800, 400)) {
193      front.push({ art: 'left', x: 0, y: 4 }, { art: 'right', x: room - 4, y: 4 })
194    }
195  }
196
197  switch (reaction) {
198    case 'eat':
199    case 'snack': {
200      x = Math.min(maxX, 2)
201      flip = false
202      const bite = Math.floor(r / 600)
203      mouthOpen = bite % 2 === 1
204      const food = `${reaction === 'eat' ? 'rice' : 'candy'}${Math.min(2, Math.floor(r / 800))}` as ArtName
205      front.push({ art: food, x: x + sw + 1, y: h - 8 })
206      break
207    }
208    case 'refuse':
209      flip = Math.floor(r / 250) % 2 === 1
210      eyesClosed = true
211      front.push({ art: 'cross', x: Math.min(w - 6, x + sw + 1), y: 2 })
212      break
213    case 'happy':
214    case 'pat':
215    case 'win': {
216      eyesClosed = true
217      y -= Math.floor(r / 200) % 2 === 1 ? 2 : 0
218      const rise = Math.floor(r / 350)
219      front.push({ art: 'heart', x: Math.min(w - 5, x + sw), y: Math.max(0, 5 - rise) })
220      if (reaction === 'win') front.push({ art: 'heart', x: Math.max(0, x - 5), y: Math.max(0, 7 - rise) })
221      break
222    }
223    case 'cheer': {
224      y -= Math.floor(r / 200) % 2 === 1 ? 2 : 0
225      mouthOpen = true
226      front.push({ art: 'note', x: Math.min(w - 5, x + sw), y: Math.max(0, 4 - Math.floor(r / 300)) })
227      break
228    }
229    case 'party': {
230      y -= Math.floor(r / 250) % 2 === 1 ? 2 : 0
231      mouthOpen = true
232      // confetti around it, never over its face
233      const burst = Math.floor(r / 250)
234      const spots = [...Array(Math.max(0, x - 5)).keys(), ...Array.from({ length: Math.max(0, room - 5 - (x + sw)) }, (_, i) => x + sw + 1 + i)]
235      for (let i = 0; i < 3 && spots.length; i++) {
236        const sx = spots[Math.floor(roll(input.reaction!.id, burst, i, 'x') * spots.length)]!
237        front.push({ art: 'star', x: sx, y: Math.floor(roll(input.reaction!.id, burst, i, 'y') * 11) })
238      }
239      break
240    }
241    case 'flinch':
242      eyesClosed = r > 300
243      if (r < 300) y -= 2
244      front.push({ art: 'bang', x: Math.min(w - 2, x + sw + 1), y: 1 })
245      break
246    case 'nibble':
247      mouthOpen = r < 250
248      break
249    case 'medicine':
250      eyesClosed = r > 500
251      front.push({ art: 'pill', x: Math.min(w - 8, x + sw + 1), y: r < 800 ? 6 : 8 })
252      if (r > 1100) x = Math.max(0, x + (Math.floor(r / 100) % 2))
253      break
254    case 'scold':
255      eyesClosed = true
256      y += 1
257      front.push({ art: 'bang', x: Math.min(w - 2, x + sw + 1), y: 0 })
258      break
259    case 'lose':
260      eyesClosed = true
261      front.push({ art: 'sweat', x: Math.max(0, x - 4), y: 3 })
262      break
263    case 'wake':
264      y -= r < 300 ? 2 : 0
265      front.push({ art: 'bang', x: Math.min(w - 2, x + sw + 1), y: 1 })
266      break
267    case 'sleep':
268      eyesClosed = true
269      break
270    case 'hatch':
271      y -= Math.floor(r / 150) % 2 === 1 ? 1 : 0
272      break
273    default:
274      break
275  }
276
277  // the state around it, when no animation says otherwise
278  if (!reaction && !input.asleep) {
279    // the corner above the bugs: a skull while sick, else a spark while Claude works
280    if (input.sick) {
281      if (blink(1000, 600)) front.push({ art: 'skull', x: w - 6, y: 0 })
282    } else if (input.working) front.push({ art: Math.floor(t / 350) % 2 ? 'spark1' : 'spark0', x: w - 5, y: 0 })
283    if (input.tantrum) {
284      x = Math.max(0, Math.min(maxX, x + (Math.floor(t / 150) % 2 ? 1 : -1)))
285      if (blink(500, 300)) front.push({ art: 'bang', x: Math.min(room - 2, x + sw + 1), y: 1 })
286    } else if (input.calling && blink(1000, 500)) {
287      front.push({ art: 'bang', x: Math.min(room - 2, x + sw + 1), y: 1 })
288    }
289    if (input.sad && !input.calling) front.push({ art: 'sweat', x: Math.max(0, x - 4), y: 3 })
290  }
291
292  // bugs, wiggling; a clean washes them away right to left
293  const wave = reaction === 'clean' ? w - Math.floor((r / REACTION_MS.clean) * (w + 6)) : null
294  for (let i = 0; i < input.bugs; i++) {
295    const bx = w - 5 - (i >= 2 ? 6 : 0)
296    if (wave !== null && bx >= wave) continue
297    draws.push({ art: (Math.floor(t / 400) + i) % 2 ? 'bug1' : 'bug0', x: bx, y: h - 5 - (i % 2) * 6 })
298  }
299  if (wave !== null) draws.push({ art: 'wave', x: wave, y: 0 })
300
301  draws.push({ art, x, y, flip, eyesClosed, mouthOpen })
302  draws.push(...front)
303  return { draws, dark: false }
304}
305
306function egg(input: SceneInput, t: number, r: number | null, w: number, h: number): Frame {
307  const { w: ew, h: eh } = size('egg')
308  const x = Math.floor((w - ew) / 2)
309  const draws: Draw[] = []
310  if (r !== null) {
311    // hatching: a fast wobble, the crack, a flash
312    const tilt = r < 1200 ? (Math.floor(r / 120) % 2 ? 1 : -1) : 0
313    draws.push({ art: 'egg', x: x + tilt, y: h - eh })
314    if (r >= 900) draws.push({ art: 'crack', x: x + tilt, y: h - eh })
315    return { draws, dark: r >= 1700 && Math.floor(r / 100) % 2 === 0 }
316  }
317  const period = input.working ? 900 : 2600
318  const phase = t % period
319  const tilt = phase < 240 ? -1 : phase < 480 ? 1 : 0
320  draws.push({ art: 'egg', x: x + tilt, y: h - eh })
321  return { draws, dark: false }
322}
323
324/** Rasterizes a frame onto a w×h bitmap. */
325export function rasterize(frame: Frame, w = 32, h = 16): Bitmap {
326  const b = blank(w, h)
327  for (const d of frame.draws) {
328    const s = ART[d.art]
329    if (s) draw(b, s, d.x, d.y, { flip: d.flip, eyesClosed: d.eyesClosed, mouthOpen: d.mouthOpen })
330  }
331  return frame.dark ? invert(b) : b
332}
333
hooks/pet/species.ts 44 lines
1import type { SpeciesId, Stage } from '../../types'
2import * as S from './sprites.ts'
3import type { Sprite } from './sprites.ts'
4
5// Who the pet can be. It hatches as a Bit, grows into a Nibble, then a teen picked by how well it
6// was cared for, then one of eight adults picked by care and by the work it watched Claude do.
7
8export type Species = {
9  id: SpeciesId
10  name: string
11  stage: Stage
12  sprite: Sprite
13  /** One line about it, for the stats page and the README. */
14  blurb: string
15  /** How it is reached, for the evolution chart. */
16  how: string
17  /** Grams it weighs when it reaches this form. */
18  weight: number
19  /** Its face beside the spinner and in the one-line band: content, then sleepy. */
20  face: string
21}
22
23export const SPECIES: Record<SpeciesId, Species> = {
24  egg: { id: 'egg', name: 'Egg', stage: 'egg', sprite: S.EGG, blurb: 'Something is moving inside.', how: 'Where every pet starts.', weight: 5, face: '( )' },
25  bit: { id: 'bit', name: 'Bit', stage: 'baby', sprite: S.BIT, blurb: 'The smallest unit of joy.', how: 'Hatches after a minute and a tool call.', weight: 5, face: '(•ω•)' },
26  nibble: { id: 'nibble', name: 'Nibble', stage: 'child', sprite: S.NIBBLE, blurb: 'Four bits of mischief.', how: '1 hour old and 40 tool calls watched.', weight: 10, face: '(=^ω^=)' },
27  bytey: { id: 'bytey', name: 'Bytey', stage: 'teen', sprite: S.BYTEY, blurb: 'A well-raised teenager. Eight bits, all in order.', how: '1 day old, 250 tool calls, few care mistakes.', weight: 18, face: 'ʕ•ω•ʔ' },
28  glitchy: { id: 'glitchy', name: 'Glitchy', stage: 'teen', sprite: S.GLITCHY, blurb: 'Some of its bits got flipped along the way.', how: '1 day old, 250 tool calls, many care mistakes.', weight: 16, face: '(⊙_⊙)' },
29  assertoise: { id: 'assertoise', name: 'Assertoise', stage: 'adult', sprite: S.ASSERTOISE, blurb: 'Slow, steady, and every test green. Carries the checkmark on its back.', how: 'Grows up watching Claude run tests.', weight: 30, face: '(✓_✓)' },
30  refactopus: { id: 'refactopus', name: 'Refactopus', stage: 'adult', sprite: S.REFACTOPUS, blurb: 'Eight arms, eight files open, zero fear of a big diff.', how: 'Grows up watching Claude edit code.', weight: 26, face: '(◕ω◕)' },
31  shellby: { id: 'shellby', name: 'Shellby', stage: 'adult', sprite: S.SHELLBY, blurb: 'Lives in the terminal. Pipes things into other things.', how: 'Grows up watching Claude run shell commands.', weight: 24, face: '(°▽°)' },
32  grepossum: { id: 'grepossum', name: 'Grepossum', stage: 'adult', sprite: S.GREPOSSUM, blurb: 'Reads every file before it says a word. Finds what you lost.', how: 'Grows up watching Claude read and search.', weight: 22, face: '(ºoº)' },
33  commitmunk: { id: 'commitmunk', name: 'Commitmunk', stage: 'adult', sprite: S.COMMITMUNK, blurb: 'Stuffs its cheeks with commits and buries them in the reflog.', how: 'Grows up watching Claude commit and push.', weight: 28, face: '(^•^)' },
34  owlgorithm: { id: 'owlgorithm', name: 'Owlgorithm', stage: 'adult', sprite: S.OWLGORITHM, blurb: 'Does its best work after midnight. Who? You.', how: 'Grows up coding late at night.', weight: 25, face: '(⊙v⊙)' },
35  spaghetto: { id: 'spaghetto', name: 'Spaghetto', stage: 'adult', sprite: S.SPAGHETTO, blurb: 'Tangled, saucy, and somehow still running in production.', how: 'Grows up with many care mistakes.', weight: 34, face: '(@_@)' },
36  unicorn: { id: 'unicorn', name: 'Unicorn', stage: 'adult', sprite: S.UNICORN, blurb: 'The mythical 10x pet. Raised with perfect care and firm discipline.', how: 'At most one care mistake and discipline of 75 or more.', weight: 22, face: '(✧ω✧)' },
37}
38
39export const ADULTS: readonly SpeciesId[] = ['assertoise', 'refactopus', 'shellby', 'grepossum', 'commitmunk', 'owlgorithm', 'spaghetto', 'unicorn']
40
41export const species = (id: SpeciesId): Species => SPECIES[id] ?? SPECIES.bit
42
43export const isSpecies = (value: unknown): value is SpeciesId => typeof value === 'string' && Object.hasOwn(SPECIES, value)
44
hooks/pet/svg.ts 191 lines
1import type { ShellColor } from '../../types'
2import { blank, draw, spriteWidth } from './bitmap.ts'
3import { ART, REACTION_MS, scene, WALK_LOOP, type ArtName, type Draw, type SceneInput } from './scene.ts'
4
5// The pet for surfaces that draw SVG (the desktop app, the mobile app, the editor): a whole
6// animation in one document. Each picture is defined once and placed by <use>; the frames are
7// groups shown in turn by SMIL, so the drawing moves without the hooks module redrawing it.
8
9export const LCD = { bg: '#b8c4a0', ink: '#2d3226', ghost: '#a7b38f', dark: '#3a4032', glow: '#c9d6ad' } as const
10
11export const SHELLS: Record<ShellColor, { base: string; dark: string; light: string }> = {
12  coral: { base: '#e8846b', dark: '#b4553f', light: '#f6b9a6' },
13  mint: { base: '#76c9a6', dark: '#3f8f6d', light: '#c3eedb' },
14  grape: { base: '#a58ad8', dark: '#6c55a3', light: '#d9cdf3' },
15  sky: { base: '#77b4ef', dark: '#457fba', light: '#c7e2fb' },
16  banana: { base: '#f0cd62', dark: '#b9922c', light: '#fbecb6' },
17  ink: { base: '#474c59', dark: '#24272e', light: '#7b8191' },
18}
19
20const esc = (s: string) => s.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`)
21
22/** One path for a picture: a 1×1 rectangle per run of lit pixels in a row. */
23function pathOf(art: ArtName, variant: Pick<Draw, 'flip' | 'eyesClosed' | 'mouthOpen'>): string {
24  const s = ART[art]!
25  const w = spriteWidth(s)
26  const b = blank(w, s.length)
27  draw(b, s, 0, 0, variant)
28  let d = ''
29  for (let y = 0; y < b.h; y++) {
30    let x = 0
31    while (x < w) {
32      if (!b.px[y * w + x]) {
33        x++
34        continue
35      }
36      let n = 1
37      while (x + n < w && b.px[y * w + x + n]) n++
38      d += `M${x} ${y}h${n}v1h-${n}z`
39      x += n
40    }
41  }
42  return d
43}
44
45type Clip = { frames: { draws: Draw[]; dark: boolean; ms: number }[]; loop: boolean }
46
47/**
48 * Samples the scene into frames: the reaction once if one is playing (`rt` ms into it), else the
49 * walk's whole loop. Equal neighbours merge into one longer frame.
50 */
51export function clip(input: SceneInput, rt: number | null, w: number, h: number, every = 130): Clip {
52  const playing = input.reaction && rt !== null && rt < REACTION_MS[input.reaction.kind]
53  const from = playing ? rt! : 0
54  const until = playing ? REACTION_MS[input.reaction!.kind] : WALK_LOOP
55  const frames: Clip['frames'] = []
56  let last = ''
57  for (let t = from; t < until; t += every) {
58    const f = scene(input, playing ? t : t, playing ? t : null, w, h)
59    const k = JSON.stringify(f)
60    if (k === last) frames[frames.length - 1]!.ms += every
61    else frames.push({ ...f, ms: every })
62    last = k
63  }
64  return { frames, loop: !playing }
65}
66
67/**
68 * The frames as SVG: <defs> holding each picture once, then one group per frame. `scale` is the
69 * size of a pixel, (ox, oy) where the screen's corner sits, `ink` the lit color.
70 */
71function animate(c: Clip, ox: number, oy: number, scale: number, w: number, h: number, ink: string, dark: { bg: string; ink: string } | null, idPrefix: string): string {
72  const ids = new Map<string, string>()
73  let defs = ''
74  const ref = (d: Draw) => {
75    const variant = { flip: d.flip, eyesClosed: d.eyesClosed, mouthOpen: d.mouthOpen }
76    const key = `${d.art}|${d.flip ? 1 : 0}${d.eyesClosed ? 1 : 0}${d.mouthOpen ? 1 : 0}`
77    let id = ids.get(key)
78    if (!id) {
79      id = `${idPrefix}${ids.size}`
80      ids.set(key, id)
81      defs += `<path id="${id}" d="${pathOf(d.art, variant)}"/>`
82    }
83    return id
84  }
85  const total = c.frames.reduce((n, f) => n + f.ms, 0)
86  const dur = `${(total / 1000).toFixed(2)}s`
87  let at = 0
88  let body = ''
89  c.frames.forEach((f, i) => {
90    const uses = f.draws.map(d => `<use href="#${ref(d)}" x="${d.x}" y="${d.y}"/>`).join('')
91    const fill = f.dark && dark ? dark.ink : ink
92    const bg = f.dark && dark ? `<rect width="${w}" height="${h}" fill="${dark.bg}"/>` : ''
93    const a = at / total
94    const b = (at + f.ms) / total
95    at += f.ms
96    let anim = ''
97    if (c.frames.length > 1) {
98      // shown from a to b of each cycle; a clip that plays once holds its last frame
99      const isLast = i === c.frames.length - 1
100      const repeat = c.loop ? ' repeatCount="indefinite"' : ' fill="freeze"'
101      const values = i === 0 ? '1;0' : isLast && !c.loop ? '0;1' : '0;1;0'
102      const times = i === 0 ? `0;${b.toFixed(4)}` : isLast && !c.loop ? `0;${a.toFixed(4)}` : `0;${a.toFixed(4)};${b.toFixed(4)}`
103      anim = `<animate attributeName="opacity" values="${values}" keyTimes="${times}" dur="${dur}" calcMode="discrete"${repeat}/>`
104    }
105    body += `<g fill="${fill}"${i === 0 || c.frames.length === 1 ? '' : ' opacity="0"'}>${anim}${bg}${uses}</g>`
106  })
107  return `<defs>${defs}</defs><g transform="translate(${ox} ${oy}) scale(${scale})">${body}</g>`
108}
109
110export type DeviceView = {
111  input: SceneInput
112  rt: number | null
113  name: string
114  shell: ShellColor
115  /** Makes the document's ids its own, for a page that inlines several. */
116  uid?: string
117  /** One still frame at this moment instead of the animation (the README's pictures). */
118  at?: { t: number; rt: number | null }
119  /** Which of the eight icons around the screen are lit. */
120  icons: { food: boolean; light: boolean; game: boolean; medicine: boolean; bath: boolean; meter: boolean; discipline: boolean; attention: boolean }
121}
122
123// the eight icons around the screen, 7×7 each
124const ICONS: Record<keyof DeviceView['icons'], string[]> = {
125  food: ['..###..', '.#####.', '##...##', '#.....#', '#.###.#', '#.###.#', '.#####.'],
126  light: ['..###..', '.#...#.', '#.....#', '#.....#', '.#...#.', '..###..', '..###..'],
127  game: ['..###..', '.#.#.#.', '#..#..#', '#######', '#..#..#', '.#.#.#.', '..###..'],
128  medicine: ['...#...', '..###..', '...#...', '.#####.', '.#...#.', '.#...#.', '.#####.'],
129  bath: ['.......', '.#..#..', '#.##.##', '.......', '.#..#..', '#.##.##', '.......'],
130  meter: ['......#', '.....##', '....###', '...####', '..#####', '.######', '#######'],
131  discipline: ['..###..', '..###..', '..###..', '..###..', '.......', '..###..', '..###..'],
132  attention: ['.#####.', '#.....#', '#.#.#.#', '#.....#', '#.###.#', '#.....#', '.#####.'],
133}
134
135function icon(name: keyof DeviceView['icons'], x: number, y: number, on: boolean, blinks: boolean): string {
136  let d = ''
137  ICONS[name].forEach((row, iy) => {
138    for (let ix = 0; ix < row.length; ix++) if (row[ix] === '#') d += `M${ix} ${iy}h1v1h-1z`
139  })
140  const anim = on && blinks ? `<animate attributeName="opacity" values="1;0.15;1" keyTimes="0;0.5;1" dur="1s" calcMode="discrete" repeatCount="indefinite"/>` : ''
141  return `<path transform="translate(${x} ${y}) scale(1.4)" d="${d}" fill="${on ? LCD.ink : LCD.ghost}">${anim}</path>`
142}
143
144/** The whole device: an egg-shaped shell, the screen with its icons, three buttons, the name. */
145export function deviceSvg(v: DeviceView): string {
146  const c = SHELLS[v.shell] ?? SHELLS.coral
147  const u = v.uid ?? 't'
148  const W = 220
149  const H = 250
150  const px = 4
151  const sx = 46
152  const sy = 84
153  const frames: Clip = v.at ? { frames: [{ ...scene(v.input, v.at.t, v.at.rt, 32, 16), ms: 1 }], loop: false } : clip(v.input, v.rt, 32, 16)
154  const screen = animate(frames, sx, sy, px, 32, 16, LCD.ink, { bg: LCD.dark, ink: LCD.glow }, `${u}p`)
155  const top = (['food', 'light', 'game', 'medicine'] as const).map((n, i) => icon(n, 52 + i * 32, 68, v.icons[n], false)).join('')
156  const bottom = (['bath', 'meter', 'discipline', 'attention'] as const).map((n, i) => icon(n, 52 + i * 32, 155, v.icons[n], n === 'attention')).join('')
157  const name = esc(v.name.toUpperCase().slice(0, 12))
158  return [
159    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">`,
160    `<defs><radialGradient id="${u}shell" cx="38%" cy="30%" r="75%"><stop offset="0" stop-color="${c.light}"/><stop offset="0.55" stop-color="${c.base}"/><stop offset="1" stop-color="${c.dark}"/></radialGradient>`,
161    `<linearGradient id="${u}lcd" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#c3cfab"/><stop offset="1" stop-color="${LCD.bg}"/></linearGradient></defs>`,
162    // the key ring and the shell
163    `<circle cx="110" cy="11" r="8" fill="none" stroke="${c.dark}" stroke-width="3"/>`,
164    `<path d="M110 16 C176 16 210 92 210 152 C210 214 168 246 110 246 C52 246 10 214 10 152 C10 92 44 16 110 16 Z" fill="url(#${u}shell)" stroke="${c.dark}" stroke-width="2"/>`,
165    `<path d="M70 40 C84 30 100 27 112 27" fill="none" stroke="#fff" stroke-opacity="0.45" stroke-width="5" stroke-linecap="round"/>`,
166    `<text x="110" y="53" text-anchor="middle" font-family="ui-monospace, Menlo, monospace" font-size="10" font-weight="700" letter-spacing="3" fill="${c.dark}">${name}</text>`,
167    // the screen's bezel and glass
168    `<rect x="36" y="60" width="148" height="112" rx="14" fill="${c.dark}"/>`,
169    `<rect x="40" y="64" width="140" height="104" rx="10" fill="url(#${u}lcd)"/>`,
170    top,
171    bottom,
172    `<g>${screen}</g>`,
173    // the three buttons
174    ...[
175      [76, 200, 'A'],
176      [110, 212, 'B'],
177      [144, 200, 'C'],
178    ].map(([x, y, l]) => `<circle cx="${x}" cy="${y}" r="10" fill="${c.dark}"/><circle cx="${x}" cy="${(y as number) - 1.5}" r="8.5" fill="${c.light}"/><text x="${x}" y="${(y as number) + 18}" text-anchor="middle" font-family="ui-monospace, Menlo, monospace" font-size="7" fill="${c.dark}">${l}</text>`),
179    `</svg>`,
180  ].join('')
181}
182
183/** The pet strolling along a strip: the band above the prompt, where SVG is drawn. */
184export function laneSvg(input: SceneInput, rt: number | null, color: string, columns = 96, uid = 'l'): string {
185  const px = 3
186  const w = columns
187  const h = 16
188  const body = animate(clip(input, rt, w, h), 0, 0, px, w, h, color, { bg: color, ink: '#ffffff' }, uid)
189  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w * px} ${h * px}" width="${w * px}" height="${h * px}">${body}</svg>`
190}
191
hooks/pet/words.ts 79 lines
1import type { Need, Pet } from '../../types'
2import { hearts, stageOf } from './life.ts'
3import { species } from './species.ts'
4
5// What the pet looks like and says in text: its face, its meters, the line about how it is doing.
6
7export const meter = (value: number, full = '♥', empty = '♡'): string => full.repeat(hearts(value)) + empty.repeat(4 - hearts(value))
8
9/** Discipline as four bars. */
10export const bars = (value: number): string => '■'.repeat(hearts(value)) + '□'.repeat(4 - hearts(value))
11
12/** Its face as text: for the spinner and the one-line band. */
13export function face(p: Pet): string {
14  if (p.gone) return '(  )'
15  if (p.species === 'egg') return '( )'
16  if (p.asleep) return '(-_-)zZ'
17  if (p.sick > 0) return '(×_×)'
18  if (p.tantrum) return '(ò_ó)'
19  if (p.hunger <= 0 || p.happy <= 0) return '(;_;)'
20  if (p.happy < 40) return '(._.)'
21  return species(p.species).face
22}
23
24export const NEED_TEXT: Record<Need, string> = {
25  hungry: 'is hungry',
26  sad: 'is bored and sad',
27  sick: 'is sick: medicine!',
28  bugs: 'is surrounded by bugs',
29  tantrum: 'is throwing a tantrum',
30}
31
32/** One line about how it is doing, most pressing first. */
33export function statusLine(p: Pet): string {
34  if (p.gone) return `${p.name} has left for the cloud. /tama new for a new egg`
35  if (p.vacation !== null) return `${p.name} is on vacation. /tama vacation off when you are back`
36  if (p.species === 'egg') return 'an egg. it hatches when Claude gets to work'
37  if (p.calling) return `${p.name} ${NEED_TEXT[p.calling]}`
38  if (p.tucked) return `${p.name} is tucked in`
39  if (p.asleep) return `${p.name} is napping`
40  if (p.bugs > 0) return `${p.name} left ${p.bugs === 1 ? 'a bug' : `${p.bugs} bugs`} behind`
41  if (p.hunger < 30) return `${p.name} is getting peckish`
42  if (p.happy < 30) return `${p.name} wants to play`
43  return `${p.name} is ${p.happy >= 75 ? 'happy' : 'doing fine'}`
44}
45
46export function title(p: Pet): string {
47  const s = species(p.species)
48  return stageOf(p) === 'egg' ? 'an egg' : `${p.name} the ${s.name}`
49}
50
51export const compact = (n: number): string =>
52  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
53
54/** What the pet says when a need starts calling. */
55export const CALL_SAYS: Record<Need, string> = {
56  hungry: 'my tummy is empty…',
57  sad: 'play with me?',
58  sick: 'i feel funny… (×_×)',
59  bugs: 'so… many… bugs',
60  tantrum: 'NO NO NO NO',
61}
62
63export const HELP: readonly [string, string][] = [
64  ['/tama', 'open or close the device'],
65  ['/tama feed · snack', 'a meal fills a heart of hunger, a snack a heart of fun'],
66  ['/tama play', 'the left-or-right game: win 3 of 5'],
67  ['/tama clean', 'squash the bugs it left behind'],
68  ['/tama medicine', 'cure it when it is sick (sometimes twice)'],
69  ['/tama lights', 'tuck it in, or wake it up'],
70  ['/tama scold', 'discipline a tantrum (never a well-behaved pet)'],
71  ['/tama stats · history', 'its life in numbers, and the pets before it'],
72  ['/tama name <name>', 'rename it'],
73  ['/tama shell <color>', 'coral, mint, grape, sky, banana or ink'],
74  ['/tama band auto|big|on|line|off', 'how it shows above the prompt'],
75  ['/tama vacation on|off', 'stop its clock while you are away'],
76  ['/tama mode classic|gentle', 'gentle: it never leaves, however neglected'],
77  ['/tama spinner · sound on|off', 'its face beside the spinner; beeps'],
78]
79
hooks/lcd.tsx 93 lines
1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { key } from './pet/bitmap.ts'
4import { bigBlocks, braille, halfBlocks } from './pet/paint.ts'
5import { rasterize, REACTION_MS, scene, type SceneInput } from './pet/scene.ts'
6
7// The pet's screen in the terminal: a surface module the hooks module mounts with <Client>. It runs
8// on the drawing thread with a frame clock of its own, so the pet walks, blinks and plays its
9// animations without the hooks module redrawing anything. A click pats the pet; once clicked, the
10// keys of the device's buttons reach the hooks module through `post`.
11//
12// Never name a local `h` here: every JSX tag compiles to a call of `h`.
13
14export type LcdProps = {
15  input: SceneInput
16  /**
17   * `device`: the 32×16 screen, pixels as half blocks (or doubled). `lane`: a strip as wide as the
18   * region, in braille dots, 4 rows. `strip`: the same strip in half blocks, 8 rows.
19   */
20  mode: 'device' | 'lane' | 'strip'
21  scale?: 1 | 2
22  /** How long ago the current reaction began, as the hooks module drew it. */
23  age?: number
24  ink: string
25  bg?: string
26}
27
28type State = { rows: string[]; frame: string }
29
30const TICK = 110
31
32// per instance: the props it was last drawn with, its clock and the reaction it is playing
33type Live = { props: LcdProps; t: number; seen: number; startedAt: number }
34const lives = new WeakMap<object, Live>()
35
36function render(live: Live, columns: number): { rows: string[]; frame: string } {
37  const { input, mode } = live.props
38  const playing = input.reaction && input.reaction.id === live.seen ? live.t - live.startedAt : null
39  const rt = playing !== null && input.reaction && playing < REACTION_MS[input.reaction.kind] ? playing : null
40  const w = mode === 'lane' ? Math.max(24, columns * 2) : mode === 'strip' ? Math.max(24, columns) : 32
41  const bitmap = rasterize(scene(input, live.t, rt, w, 16), w, 16)
42  const rows = mode === 'lane' ? braille(bitmap) : mode === 'device' && live.props.scale === 2 ? bigBlocks(bitmap) : halfBlocks(bitmap)
43  return { rows, frame: key(bitmap) }
44}
45
46export default function Lcd(props: LcdProps, surface: ClientSurface<State>) {
47  const { Box, Text } = surface.elements
48  let live = lives.get(surface)
49
50  if (!live) {
51    // a reaction already playing when the screen mounts plays on from where it is
52    const age = props.age ?? Infinity
53    live = { props, t: 0, seen: props.input.reaction?.id ?? -1, startedAt: -age }
54    lives.set(surface, live)
55    const first = live
56    surface.every(TICK, () => {
57      first.t += TICK
58      const next = render(first, surface.columns)
59      if (next.frame !== surface.state?.frame) surface.setState(next)
60    })
61    surface.onPointer(ev => {
62      if (ev.type === 'down') surface.post({ act: 'pat' })
63    })
64    surface.onKey(ev => {
65      if (!ev.ctrl && !ev.meta) surface.post({ act: 'key', key: ev.key })
66    })
67  }
68
69  // new props: a new reaction starts now, on this screen's clock
70  live.props = props
71  const id = props.input.reaction?.id ?? -1
72  if (id !== live.seen) {
73    live.seen = id
74    live.startedAt = live.t - Math.min(props.age ?? 0, 400)
75  }
76
77  // drawn from the live props every call; the clock's setState only says a new frame is due
78  const drawn = render(live, surface.columns || 48)
79  return (
80    <Box flexDirection="column">
81      {drawn.rows.map(row =>
82        props.bg ? (
83          <Text color={props.ink} backgroundColor={props.bg}>
84            {row}
85          </Text>
86        ) : (
87          <Text color={props.ink}>{row}</Text>
88        ),
89      )}
90    </Box>
91  )
92}
93
hooks/pet/rng.ts 39 lines
1// Deterministic randomness. The pet's life is simulated minute by minute in every session that
2// loads the mod; seeding each roll by the pet's id, the minute and what the roll is for makes them
3// all reach the same pet, whichever session simulates it first.
4
5/** FNV-1a over the string: a 32-bit seed. */
6export function hash(text: string): number {
7  let h = 0x811c9dc5
8  for (let i = 0; i < text.length; i++) {
9    h ^= text.charCodeAt(i)
10    h = Math.imul(h, 0x01000193)
11  }
12  return h >>> 0
13}
14
15/** One number in [0, 1) for this seed: mulberry32's first draw. */
16export function unit(seed: number): number {
17  let t = (seed + 0x6d2b79f5) >>> 0
18  t = Math.imul(t ^ (t >>> 15), t | 1)
19  t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
20  return ((t ^ (t >>> 14)) >>> 0) / 4294967296
21}
22
23/** A roll in [0, 1) named by its parts: `roll(pet.id, minute, 'bug')`. */
24export const roll = (...parts: readonly (string | number)[]): number => unit(hash(parts.join(':')))
25
26/** One item of `list`, picked by the roll. */
27export const pick = <T>(list: readonly T[], r: number): T => list[Math.min(list.length - 1, Math.floor(r * list.length))]!
28
29/** A fresh id: random where the environment offers it. */
30export function newId(now: number): string {
31  const bytes = new Uint8Array(6)
32  try {
33    crypto.getRandomValues(bytes)
34  } catch {
35    for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(unit(hash(`${now}:${i}`)) * 256)
36  }
37  return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
38}
39
hooks/pet/bitmap.ts 61 lines
1import type { Sprite } from './sprites.ts'
2
3// A 1-bit picture: `w` by `h` pixels, one byte each (1 on, 0 off), row-major.
4// Everything the LCD shows is composed into one of these, then painted for the surface at hand.
5
6export type Bitmap = { w: number; h: number; px: Uint8Array }
7
8export type Face = { eyesClosed?: boolean; mouthOpen?: boolean }
9
10export const blank = (w: number, h: number): Bitmap => ({ w, h, px: new Uint8Array(w * h) })
11
12export const get = (b: Bitmap, x: number, y: number): number =>
13  x < 0 || y < 0 || x >= b.w || y >= b.h ? 0 : b.px[y * b.w + x]!
14
15export const set = (b: Bitmap, x: number, y: number, on = 1): void => {
16  if (x >= 0 && y >= 0 && x < b.w && y < b.h) b.px[y * b.w + x] = on
17}
18
19export const spriteWidth = (s: Sprite): number => s.reduce((w, row) => Math.max(w, row.length), 0)
20
21// whether the sprite's pixel at (x, y) is lit for this face
22function lit(s: Sprite, x: number, y: number, face: Face): boolean {
23  const c = s[y]?.[x]
24  if (c === '#') return true
25  if (c === 'M') return face.mouthOpen === true
26  if (c === 'E') return !face.eyesClosed || s[y + 1]?.[x] !== 'E'
27  return false
28}
29
30/**
31 * Draws `s` with its top-left corner at (x, y); `flip` mirrors it (a creature walking left),
32 * `invert` draws its lit pixels dark-on-light as off (used to cut a sprite out of a lit area).
33 */
34export function draw(b: Bitmap, s: Sprite, x: number, y: number, opts: Face & { flip?: boolean; erase?: boolean } = {}): void {
35  const w = spriteWidth(s)
36  for (let sy = 0; sy < s.length; sy++) {
37    for (let sx = 0; sx < w; sx++) {
38      if (!lit(s, sx, sy, opts)) continue
39      set(b, x + (opts.flip ? w - 1 - sx : sx), y + sy, opts.erase ? 0 : 1)
40    }
41  }
42}
43
44/** Every lit pixel becomes off and every off one lit: the screen with the lights out. */
45export function invert(b: Bitmap): Bitmap {
46  const px = new Uint8Array(b.px.length)
47  for (let i = 0; i < px.length; i++) px[i] = b.px[i] ? 0 : 1
48  return { w: b.w, h: b.h, px }
49}
50
51/** A cheap fingerprint, so a surface redraws only when a frame actually changed. */
52export function key(b: Bitmap): string {
53  let out = ''
54  for (let i = 0; i < b.px.length; i += 6) {
55    let n = 0
56    for (let j = 0; j < 6 && i + j < b.px.length; j++) n = (n << 1) | b.px[i + j]!
57    out += String.fromCharCode(48 + n)
58  }
59  return out
60}
61
hooks/pet/sprites.ts 323 lines
1// The pixel art. Every sprite is 1-bit, drawn as rows of characters:
2//
3//   #  ink, always on
4//   .  off (transparent)
5//   E  eye: on while the eyes are open; a closed eye keeps only its lowest row
6//   M  mouth: on only while the mouth is open (eating, cheering)
7//
8// Creatures face right as drawn; a sprite walking left is mirrored. Sprites stand on their last row.
9
10export type Sprite = readonly string[]
11
12// ── the egg ─────────────────────────────────────────────────────────────────────────────────
13
14export const EGG: Sprite = [
15  '......####......',
16  '.....#....#.....',
17  '....#......#....',
18  '...#..##....#...',
19  '...#.####...#...',
20  '..#...##.....#..',
21  '..#..........#..',
22  '.#.......##...#.',
23  '.#......####..#.',
24  '.#.##....##...#.',
25  '.#####........#.',
26  '.#.##.........#.',
27  '.#............#.',
28  '..#..........#..',
29  '...##......##...',
30  '.....######.....',
31]
32
33// drawn over the egg as it is about to hatch
34export const CRACK: Sprite = [
35  '................',
36  '................',
37  '................',
38  '................',
39  '................',
40  '................',
41  '..#.#...#.#..#..',
42  '...#.#.#...#.#..',
43  '......#.........',
44  '................',
45]
46
47// ── babies, children, teens ─────────────────────────────────────────────────────────────────
48
49export const BIT: Sprite = [
50  '...####...',
51  '.##....##.',
52  '#........#',
53  '#..E..E..#',
54  '#..E..E..#',
55  '#...##...#',
56  '.#..MM..#.',
57  '..######..',
58]
59
60export const NIBBLE: Sprite = [
61  '.#........#.',
62  '#.#......#.#',
63  '#..######..#',
64  '#..........#',
65  '#..E....E..#',
66  '#..E....E..#',
67  '#..........#',
68  '#....##....#',
69  '.#...MM...#.',
70  '..########..',
71  '..##....##..',
72  '.###....###.',
73]
74
75export const BYTEY: Sprite = [
76  '....######....',
77  '..##......##..',
78  '.#..........#.',
79  '#............#',
80  '#...E....E...#',
81  '#...E....E...#',
82  '#............#',
83  '#.....##.....#',
84  '##....MM....##',
85  '#.#........#.#',
86  '#..##....##..#',
87  '.....####.....',
88  '....#....#....',
89  '...##....##...',
90]
91
92export const GLITCHY: Sprite = [
93  '.....#####....',
94  '...##.....#...',
95  '..#.......#.#.',
96  '.#.........#..',
97  '.#..E...E...#.',
98  '#...E...E...#.',
99  '#............#',
100  '#..##.##.##..#',
101  '.#.MM.MM.MM.#.',
102  '.#..........#.',
103  '..#...#.....#.',
104  '..#..#.#...#..',
105  '.#..#...###...',
106  '.##.##...##...',
107]
108
109// ── adults ──────────────────────────────────────────────────────────────────────────────────
110
111export const ASSERTOISE: Sprite = [
112  '................',
113  '.....######.....',
114  '....#......#....',
115  '....#.E..E.#....',
116  '....#.E..E.#....',
117  '....#..##..#....',
118  '.....#.MM.#.....',
119  '...##########...',
120  '..#..........#..',
121  '.#.........#..#.',
122  '.#..#.....#...#.',
123  '.#...#...#....#.',
124  '.#....#.#.....#.',
125  '..#....#.....#..',
126  '..############..',
127  '..##........##..',
128]
129
130export const REFACTOPUS: Sprite = [
131  '.....######.....',
132  '...##......##...',
133  '..#..........#..',
134  '.#............#.',
135  '.#...E....E...#.',
136  '.#...E....E...#.',
137  '.#............#.',
138  '..#....##....#..',
139  '..#....MM....#..',
140  '..############..',
141  '.#..#..##..#..#.',
142  '#..#..#..#..#..#',
143  '#.#..#....#..#.#',
144  '.#..#......#..#.',
145  '.#.#........#.#.',
146  '..#..........#..',
147]
148
149export const SHELLBY: Sprite = [
150  '##............##',
151  '#.#..........#.#',
152  '##.#.EE..EE.#.##',
153  '.#.#.EE..EE.#.#.',
154  '...#..#..#..#...',
155  '...#..#..#..#...',
156  '..############..',
157  '.#............#.',
158  '#......##......#',
159  '#......MM......#',
160  '#..............#',
161  '.#............#.',
162  '..############..',
163  '.#..#..#..#..#..',
164  '#..#..#....#..#.',
165  '................',
166]
167
168export const GREPOSSUM: Sprite = [
169  '.##........##...',
170  '#..#......#..#..',
171  '#.#.######.#.#..',
172  '.#..........#...',
173  '.#..E....E..#...',
174  '.#..E....E..#...',
175  '..#...##...#....',
176  '...#..MM..#.....',
177  '....######......',
178  '...#......#.###.',
179  '..#........#...#',
180  '..#........#...#',
181  '..#.........###.',
182  '...#......#....#',
183  '....######......',
184  '...##....##.....',
185]
186
187export const COMMITMUNK: Sprite = [
188  '..##........##..',
189  '.#..#......#..#.',
190  '.#.#.######.#.#.',
191  '..#....##....#..',
192  '.#.....##.....#.',
193  '#..E...##...E..#',
194  '#..E........E..#',
195  '#.##........##.#',
196  '####...##...####',
197  '.###...MM...###.',
198  '..#..........#..',
199  '...##########...',
200  '...#.##..##.#...',
201  '...#........#...',
202  '...#........#...',
203  '..###......###..',
204]
205
206export const OWLGORITHM: Sprite = [
207  '.#............#.',
208  '.##..........##.',
209  '.#.##########.#.',
210  '.#..........#.#.',
211  '#..####..####..#',
212  '#.#.E..##.E..#.#',
213  '#.#.E..##.E..#.#',
214  '#..####..####..#',
215  '#.......##.....#',
216  '.#......MM....#.',
217  '.#..#......#..#.',
218  '.#.#.#....#.#.#.',
219  '.#..#......#..#.',
220  '..#..........#..',
221  '...##########...',
222  '...#.#....#.#...',
223]
224
225export const SPAGHETTO: Sprite = [
226  '....#.#..#.#....',
227  '..#..#.##.#..#..',
228  '.#.##......##.#.',
229  '#.#..........#.#',
230  '#..............#',
231  '.#..E......E..#.',
232  '#...E......E...#',
233  '#......##......#',
234  '.#.....MM.....#.',
235  '#..............#',
236  '.##.########.##.',
237  '#..#.#..#.#.#..#',
238  '#.#..#..#..#.#.#',
239  '.#..#..#...#..#.',
240  '..#.#...#..#.#..',
241  '.#...#..#...#...',
242]
243
244export const UNICORN: Sprite = [
245  '.......#........',
246  '.......##.......',
247  '......#.##......',
248  '..#...##.#...#..',
249  '.#.#..#..#..#.#.',
250  '.#..##....##..#.',
251  '#..............#',
252  '#..E........E..#',
253  '#..E........E..#',
254  '#..............#',
255  '.#.....##.....#.',
256  '..#....MM....#..',
257  '...##########...',
258  '...#.#....#.#...',
259  '...#.#....#.#...',
260  '..##.##..##.##..',
261]
262
263export const ANGEL: Sprite = [
264  '....########....',
265  '...#........#...',
266  '....########....',
267  '.....######.....',
268  '...##......##...',
269  '..#..........#..',
270  '..#..#....#..#..',
271  '..#..#....#..#..',
272  '..#....##....#..',
273  '..#..........#..',
274  '..#..........#..',
275  '..#..........#..',
276  '..#.##....##.#..',
277  '..##..#..#..##..',
278  '..#....##....#..',
279  '................',
280]
281
282// ── things on the screen ────────────────────────────────────────────────────────────────────
283
284// a bug, the pet's droppings: two frames, legs in and out
285export const BUG: readonly Sprite[] = [
286  ['.#.#.', '..#..', '#####', '.###.', '#.#.#'],
287  ['#...#', '.#.#.', '.###.', '#####', '.#.#.'],
288]
289
290export const RICE: readonly Sprite[] = [
291  ['...##...', '..####..', '.##..##.', '.#....#.', '#......#', '#.####.#', '#.####.#', '.######.'],
292  ['...##...', '..####..', '.##..##.', '.#....#.', '#.......', '#.####..', '#.####..', '.#####..'],
293  ['........', '........', '........', '........', '#.......', '#.##....', '#.##....', '.###....'],
294]
295
296export const CANDY: readonly Sprite[] = [
297  ['..####..', '.#.##.#.', '#.#..#.#', '#.#.##.#', '.#....#.', '..####..', '...#....', '...#....'],
298  ['..##....', '.#.#....', '#.#.....', '#.#.#...', '.#..#...', '..##....', '...#....', '...#....'],
299  ['........', '........', '........', '........', '........', '........', '...#....', '...#....'],
300]
301
302export const PILL: Sprite = ['..####..', '.#..###.', '#...####', '#...####', '#...####', '#...####', '.#..###.', '..####..']
303
304export const HEART: Sprite = ['.##.##.', '#######', '#######', '.#####.', '..###..', '...#...']
305export const SMALL_HEART: Sprite = ['##.##', '#####', '.###.', '..#..']
306export const NOTE: Sprite = ['..###', '..#.#', '..#..', '###..', '###..']
307export const SKULL: Sprite = ['.####.', '#.##.#', '######', '.#..#.', '.####.']
308export const BANG: Sprite = ['##', '##', '##', '##', '..', '##']
309export const SWEAT: Sprite = ['.#.', '###', '###', '.#.']
310export const ZZ: Sprite = ['####', '..#.', '.#..', '####']
311export const SMALL_Z: Sprite = ['###', '.#.', '###']
312export const SPARK: readonly Sprite[] = [
313  ['..#..', '..#..', '#####', '..#..', '..#..'],
314  ['#...#', '.#.#.', '..#..', '.#.#.', '#...#'],
315]
316export const ARROW_LEFT: Sprite = ['...#', '..##', '.###', '####', '.###', '..##', '...#']
317export const ARROW_RIGHT: Sprite = ['#...', '##..', '###.', '####', '###.', '##..', '#...']
318export const CHECK: Sprite = ['......#', '.....##', '#...##.', '##.##..', '.###...', '..#....']
319export const CROSS: Sprite = ['#....#', '##..##', '.####.', '..##..', '.####.', '##..##', '#....#']
320export const STAR: Sprite = ['..#..', '.###.', '#####', '.###.', '.#.#.']
321export const WAVE: Sprite = ['..#.', '.#..', '#...', '#...', '.#..', '..#.', '.#..', '#...', '#...', '.#..', '..#.', '.#..', '#...', '#...', '.#..', '..#.']
322export const UFO: Sprite = ['....####....', '..##....##..', '############', '#.#.#..#.#.#', '.##########.', '...#....#...']
323
hooks/pet/paint.ts 53 lines
1import { get, type Bitmap } from './bitmap.ts'
2
3// Turning a bitmap into terminal characters. Two pixels per cell, one above the other, with the
4// half blocks (crisp, square pixels: the device's LCD), or eight per cell with braille dots (a
5// dot-matrix look a quarter the height: the band above the prompt).
6
7const HALF = [' ', '▄', '▀', '█'] as const
8
9/** One string per row of cells, h/2 rows: top pixel ▀, bottom ▄, both █. */
10export function halfBlocks(b: Bitmap): string[] {
11  const rows: string[] = []
12  for (let y = 0; y < b.h; y += 2) {
13    let row = ''
14    for (let x = 0; x < b.w; x++) row += HALF[(get(b, x, y) << 1) | get(b, x, y + 1)]
15    rows.push(row)
16  }
17  return rows
18}
19
20/** Doubled: each pixel two cells wide and one tall, so a 32×16 screen fills 64×16 cells. */
21export function bigBlocks(b: Bitmap): string[] {
22  const rows: string[] = []
23  for (let y = 0; y < b.h; y++) {
24    let row = ''
25    for (let x = 0; x < b.w; x++) row += get(b, x, y) ? '██' : '  '
26    rows.push(row)
27  }
28  return rows
29}
30
31// the braille dot for each pixel of a 2×4 cell, as Unicode numbers them
32const DOT = [
33  [0x01, 0x08],
34  [0x02, 0x10],
35  [0x04, 0x20],
36  [0x40, 0x80],
37] as const
38
39/** One string per row of cells, h/4 rows, each cell a braille character (blank cells a space). */
40export function braille(b: Bitmap): string[] {
41  const rows: string[] = []
42  for (let y = 0; y < b.h; y += 4) {
43    let row = ''
44    for (let x = 0; x < b.w; x += 2) {
45      let bits = 0
46      for (let dy = 0; dy < 4; dy++) for (let dx = 0; dx < 2; dx++) if (get(b, x + dx, y + dy)) bits |= DOT[dy]![dx]!
47      row += bits ? String.fromCharCode(0x2800 + bits) : ' '
48    }
49    rows.push(row)
50  }
51  return rows
52}
53
types/index.d.ts 179 lines
1// cc-tamagotchi's type contract: the values it keeps in $.state, and the shapes they are made of.
2
3export type SpeciesId =
4  | 'egg'
5  | 'bit'
6  | 'nibble'
7  | 'bytey'
8  | 'glitchy'
9  | 'assertoise'
10  | 'refactopus'
11  | 'shellby'
12  | 'grepossum'
13  | 'commitmunk'
14  | 'owlgorithm'
15  | 'spaghetto'
16  | 'unicorn'
17
18export type Stage = 'egg' | 'baby' | 'child' | 'teen' | 'adult'
19
20/** What Claude's work and the person's care added up to over the pet's life. */
21export type PetStats = {
22  toolCalls: number
23  edits: number
24  tests: number
25  testFails: number
26  commits: number
27  pushes: number
28  shell: number
29  searches: number
30  agents: number
31  errors: number
32  turns: number
33  nightTurns: number
34  tokens: number
35  meals: number
36  snacks: number
37  games: number
38  wins: number
39  cleans: number
40  meds: number
41  pats: number
42  scolds: number
43}
44
45/** The pet, as kept in $.store and shared by every session on the machine. */
46export type Pet = {
47  v: 1
48  /** Seeds every random choice its life makes, so all sessions simulate the same pet. */
49  id: string
50  name: string
51  species: SpeciesId
52  generation: number
53  /** When the egg appeared, when it hatched, and when its current form began (ms since the epoch). */
54  born: number
55  hatchedAt: number | null
56  evolvedAt: number
57  /** How far its life has been simulated, a whole minute at a time. */
58  at: number
59  /** 0 to 100: four hearts of 25. */
60  hunger: number
61  happy: number
62  discipline: number
63  /** Grams. */
64  weight: number
65  /** Bugs on the screen: what this pet leaves behind. At most four. */
66  bugs: number
67  /** Doses of medicine still needed; 0 is healthy. */
68  sick: number
69  asleep: boolean
70  /** Put to bed by the person: Claude's work does not wake it. */
71  tucked: boolean
72  /** The last time Claude or the person did anything. After a while without, it naps. */
73  lastActive: number
74  lastMeal: number | null
75  lastPat: number
76  tantrum: boolean
77  /** The need it is calling about, the awake minutes it has waited, and whether that was counted. */
78  calling: Need | null
79  callWait: number
80  callingCounted: boolean
81  /** Care mistakes: a call left unanswered for too long. They shape what it grows into. */
82  mistakes: number
83  /** Minutes of hard neglect (starving and miserable, or sick); in classic mode too many and it leaves. */
84  neglect: number
85  /** When a vacation began: its time stands still until the person ends it. */
86  vacation: number | null
87  gone: { at: number; cause: 'neglect' } | null
88  rev: number
89  stats: PetStats
90}
91
92export type Need = 'hungry' | 'sad' | 'sick' | 'bugs' | 'tantrum'
93
94/** One line of the hall of fame: a pet that has gone. */
95export type Memorial = {
96  name: string
97  species: SpeciesId
98  generation: number
99  born: number
100  gone: number
101  toolCalls: number
102  commits: number
103}
104
105export type BandMode = 'auto' | 'big' | 'on' | 'line' | 'off'
106export type ShellColor = 'coral' | 'mint' | 'grape' | 'sky' | 'banana' | 'ink'
107
108export type Settings = {
109  band: BandMode
110  /** Show the pet's face beside the spinner while Claude works. */
111  spinner: boolean
112  sound: boolean
113  /** classic: a badly neglected pet leaves. gentle: it never does. */
114  mode: 'classic' | 'gentle'
115  shell: ShellColor
116}
117
118export type ReactionKind =
119  | 'eat'
120  | 'snack'
121  | 'refuse'
122  | 'happy'
123  | 'pat'
124  | 'clean'
125  | 'medicine'
126  | 'scold'
127  | 'cheer'
128  | 'flinch'
129  | 'party'
130  | 'nibble'
131  | 'hatch'
132  | 'evolve'
133  | 'sleep'
134  | 'wake'
135  | 'win'
136  | 'lose'
137
138/** An animation the screens play once. `id` grows with each, so a screen knows a new one began. */
139export type Reaction = { kind: ReactionKind; id: number; at: number; from?: SpeciesId }
140
141export type View = 'main' | 'stats' | 'help' | 'history' | 'game' | 'name'
142
143/** The left-or-right game: five rounds, three wins make it happy. */
144export type Game = {
145  round: number
146  wins: number
147  /** Which way it looked last round, and which way the person guessed. */
148  answer: 'left' | 'right' | null
149  guess: 'left' | 'right' | null
150  isOver: boolean
151}
152
153export type Speech = { text: string; at: number }
154
155// inside the augmentation a name resolves in claude-code first (it has a Settings of its own),
156// so the contract names its values through aliases no module there shares
157export type TamagotchiPet = Pet
158export type TamagotchiSettings = Settings
159export type TamagotchiView = View
160export type TamagotchiGame = Game
161export type TamagotchiReaction = Reaction
162export type TamagotchiSpeech = Speech
163export type TamagotchiMemorial = Memorial
164
165declare module 'claude-code' {
166  interface PluginState {
167    'cc-tamagotchi': {
168      pet: TamagotchiPet | null
169      settings: TamagotchiSettings
170      view: TamagotchiView
171      game: TamagotchiGame | null
172      reaction: TamagotchiReaction | null
173      speech: TamagotchiSpeech | null
174      working: boolean
175      graveyard: TamagotchiMemorial[]
176    }
177  }
178}
179