SLOPSHOPPER

monster

Raise a digital monster that grows from your Claude Code work

newpanebandguardcommandtoast
v0.1.0no licenseupdated 2026-10-09K-Mertin/claude-monster-pet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · monster
│ ┃ Byte ✕ › fix the failing auth test and add an╭────────────────────────────╮ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ monster │ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Read(src/auth.ts) │ 🏅 Byte earned “Shipped │ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Read 6 lines │ it” │ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Update(src/auth.ts) ╰────────────────────────────╯ │ ┃ ⎿ Added 2 lines, removed 1 line ╭───────────────────────────╮ │ ┃ ⏺ Bash(bun test) │ monster │ │ ┃ ⎿ 3 pass, 1 fail │ ✨ Byte evolved into baby! │ │ ┃ ╰───────────────────────────╯ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /pet │ ┃ ⎿ monster: Byte · Lv 1 baby — Badge: Shipped it │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ Byte · Lv 1 baby · grumpy · 1m old │ ┃ food ░░░░░░░░░░ joy █░░░░░░░░░ energy │ ┃ ██████████ stress █░░░░░░░░░ │ ┃ xp ░░░░░░░░░░░░░░░░░░░░ 10/40 → Lv 2 Byte · Lv 1 baby food ░░░░░░░░ joy █░░░░░░░ nrg █████ xp ░░░░░░░░ ▄▀▄ Badge: Shipped it ▄▄▀▀▀▀▄▄ ▄▀▀▀▀▀▀▀▀▀▀▄ ▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Byte · Lv 1 baby food ░░░░░░░░ joy █░░░░░░░ nrg █████ xp ░░░░░░░░ ▄▀▄ Badge: Shipped it ▄▄▀▀▀▀▄▄ ▄▀▀▀▀▀▀▀▀▀▀▄ ▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀
Pane · Byte
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ Byte · Lv 1 baby · grumpy · 1m old food ░░░░░░░░░░ joy █░░░░░░░░░ energy ██████████ stress █░░░░░░░░░ xp ░░░░░░░░░░░░░░░░░░░░ 10/40 → Lv 2 [ Home ] [ Items 3 ] [ Games ] [ Style ] [ Badges 2/18 ] [ Feed ] [ Play ] [ Pat ♥ ] [ Talk ] [ Tuck in ] [ Hide band · Badge: Shipped it · Badge: Hello, world · Byte hatched! · Byte reached level 1. · Shipped! Pushed to the remote.
README

Claude Monster Pet

A Claude Code mod that hatches a pixel-art digital monster and raises it from your work. Tokens feed it, passing tests and commits cheer it up, failed commands stress it, and the way you work decides what it evolves into.

Install

In a Claude Code terminal session (Claude Code 2.1.287 or later):

/plugin install monster --marketplace K-Mertin/claude-monster-pet

Answer y to add the marketplace, then pick a scope. The egg appears above your prompt.

Use

  • The band above the prompt shows your monster with its food, joy, energy and XP, plus what it is up to.
  • /pet opens its habitat, with five tabs:
  • Home: Feed, Play, Pat, Talk, Clean (after meals it makes a mess), Tuck in (it sleeps and recovers)
  • Items: treats earned from your work, each with its own effect
  • Games: a three-round treat hunt with prizes, and training for Power, Wisdom and Speed
  • Style: hats unlocked by badges
  • Badges: 18 achievements, each announced with a toast
  • Commands: /pet feed|play|talk|clean|tuck, /pet use cookie|coffee|gem|bug, /pet train power|wisdom|speed, /pet hat <hat>|none, /pet name <name>, /pet hide|show

How it grows

Your workEffect
Tokens Claude processesFood and XP; a ☕ coffee every 50k tokens
Passing test runsJoy, XP and a 🍪 cookie; a 🐛 bug snack when a failing test passes again
Git commits and pushesJoy, XP and a 💎 gem
Finished todo itemsJoy and XP
New subagentsIt waves at them
Failed or denied commandsStress; too much makes it sick, and it recovers over time
A new day of workA daily gift and a growing streak, with gems at 3, 7 and 30 days
Hours awayIt gets hungry and sleeps. It never dies.

Stages: egg → baby (Lv 1) → child (Lv 4) → adult (Lv 10) → ultimate (Lv 20).

At the child stage it picks an evolution line from how you have worked:

LineWhenLooksAdult (bright / shadow)Ultimate (bright / shadow)
Forgemostly shell commandshorns, flame tailEmberkin / CinderhornSolforge / Ashtyrant
Scribemostly reading and editing codea floating ghost with a quillQuillwisp / InkshadeLorewraith / Hexscript
Summonermany subagentsbig earsCallpup / HollowearChoirlord / Legionmaw
Wandererlots of web researchwings and a beakSkylark / DuskwingZephyrus / Stormcrow

How well you care for it decides whether its adult form is bright or shadow. Each monster also hatches with a personality (cheerful, lazy, curious or grumpy) that colors what it says.

There is one monster per machine, shared by every session, saved in ~/.claude/monster/pet.json. The mod makes no model calls and no network requests.

Develop

claude --plugin-dir .
claude plugin validate .
claude plugin test .
Source 6 files
hooks/register.tsx 466 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import { talkLine } from './lines'
5import {
6  BADGES, HATS, ITEMS, LINE_NAMES, PERSONALITIES, SKILL_NAMES,
7  age, applyAll, face, formName, hatch, isAsleep, isSick, level, normalize, xpFor,
8  type Event, type Hat, type Item, type Pet, type Skill,
9} from './pet'
10import { FRAME_MS, cells, cropAll, framesSvg, habitatFrames, petFrames } from './render'
11
12const PANE = 'monster'
13const FLUSH_MS = 5000
14const TABS = ['home', 'items', 'games', 'style', 'badges'] as const
15type Tab = (typeof TABS)[number]
16
17const petAtom = atom({ plugin: 'monster', key: 'pet' } as const, null)
18const hiddenAtom = atom({ plugin: 'monster', key: 'isHidden' } as const, false)
19const tabAtom = atom({ plugin: 'monster', key: 'tab' } as const, 'home')
20const huntAtom = atom({ plugin: 'monster', key: 'hunt' } as const, null)
21
22// ── The shared pet lives in one file every session reads and writes; each session queues
23// what happened and folds it in on a short timer.
24let file = ''
25let pending: Event[] = []
26const todoDone = new Map<string, number>()
27
28function bar(n: number, width = 8) {
29  const k = Math.round((Math.max(0, Math.min(100, n)) / 100) * width)
30  return '█'.repeat(k) + '░'.repeat(width - k)
31}
32
33function xpPct(p: Pet) {
34  const lv = level(p.xp)
35  return ((p.xp - xpFor(lv)) / (xpFor(lv + 1) - xpFor(lv))) * 100
36}
37
38async function load($: EngineInterface): Promise<Pet> {
39  try {
40    if (file && (await $.fs.exists(file))) return normalize(JSON.parse(await $.fs.read(file)) as Partial<Pet>)
41  } catch {}
42  const kept = (await $.store.get('pet')) as Partial<Pet> | undefined
43  if (kept) return normalize(kept)
44  const now = await $.clock.now()
45  return hatch(now, 'Byte', PERSONALITIES[Math.floor(Math.random() * PERSONALITIES.length)]!)
46}
47
48async function flush($: EngineInterface) {
49  const events = pending
50  pending = []
51  const now = await $.clock.now()
52  const before = await load($)
53  const pet = applyAll(before, events, now)
54  try {
55    if (file) await $.fs.write(file, JSON.stringify(pet))
56    else await $.store.set('pet', pet)
57  } catch {}
58  // Announce what this session's events earned.
59  for (const id of pet.badges.filter(b => !before.badges.includes(b))) {
60    const b = BADGES.find(x => x.id === id)
61    if (b) $.ui.toast(`🏅 ${pet.name} earned “${b.name}”${b.hat ? ` · new hat: ${HATS[b.hat]}` : ''}`)
62  }
63  if (pet.stage !== before.stage) $.ui.toast(`✨ ${pet.name} evolved into ${formName(pet)}!`)
64  await update($, petAtom, () => pet)
65}
66
67function queue(e: Event) {
68  pending.push(e)
69  if (pending.length > 500) pending.splice(0, pending.length - 500)
70}
71
72async function act($: EngineInterface, e: Event) {
73  queue(e)
74  await flush($)
75}
76
77function title(p: Pet) {
78  const line = p.line && p.stage === 'child' ? `${LINE_NAMES[p.line]} ` : ''
79  return `${p.name} · Lv ${level(p.xp)} ${line}${formName(p)}`
80}
81
82function said(p: Pet, now: number) {
83  return p.said && now - p.said.at < 60_000 ? p.said.text : undefined
84}
85
86function status(p: Pet, now: number) {
87  const words = said(p, now)
88  if (words && now - p.said!.at < 15_000) return `“${words}”`
89  const last = p.log.at(-1)
90  if (last && now - last.at < 15000) return last.text
91  switch (face(p, now)) {
92    case 'sleep': return (p.asleepUntil ?? 0) > now ? 'zzz… tucked in' : 'zzz… (sleeping while you are away)'
93    case 'sick': return 'feeling sick from all the errors'
94    case 'hungry': return 'hungry… give Claude some work, or feed it'
95    case 'sleepy': return 'sleepy… a coffee would help'
96    case 'sad': return 'a bit lonely'
97    case 'wave': return 'waves at the new agent 👋'
98    case 'sweat': return 'working hard…'
99    case 'cheer': return '♪'
100    case 'ouch': return 'ouch!'
101    case 'eat': return 'nom nom'
102    default:
103      if (p.mess >= 2) return 'it’s getting messy in here'
104      return `${age(p, now)} old · ${p.streak.days > 1 ? `🔥 ${p.streak.days}-day streak · ` : ''}${Math.round(p.stats.tokens / 1000)}k tokens eaten`
105  }
106}
107
108function since(ms: number) {
109  const m = Math.ceil(ms / 60_000)
110  return m >= 60 ? `${Math.ceil(m / 60)}h` : `${m}m`
111}
112
113const PRIZES: Item[] = ['cookie', 'cookie', 'cookie', 'coffee', 'coffee', 'gem']
114
115export const register: Register = on => {
116  let ticker: Timer | undefined
117  let animator: Timer | undefined
118  let frame = 0
119  let bandFrames: string[] = []
120  let paneFrames: string[] = []
121  let bandId = ''
122
123  on('session.start', async ($, e, next) => {
124    const home = await $.env.get('HOME')
125    file = home ? `${home}/.claude/monster/pet.json` : ''
126    await $.command.register({
127      name: 'pet',
128      description: 'See and care for your monster',
129      argumentHint: '[feed|play|talk|clean|tuck|use <item>|train <skill>|hat <hat>|name <name>|hide|show]',
130    })
131    ticker?.cancel()
132    ticker = $.clock.every(FLUSH_MS, () => void flush($))
133    // Animate the terminal drawings: swap their frames in place.
134    animator?.cancel()
135    animator = $.clock.every(FRAME_MS, () => {
136      frame++
137      const b = bandFrames[frame % Math.max(1, bandFrames.length)]
138      if (b && bandId) void $.ui.blit({ requestId: bandId, key: 'pet', cells: b })
139      const p = paneFrames[frame % Math.max(1, paneFrames.length)]
140      if (p) void $.ui.blit({ requestId: PANE, key: 'habitat', cells: p })
141    })
142    void flush($)
143    return next(e)
144  })
145
146  on('command.run', { command: 'pet' }, async ($, e) => {
147    const [verb, ...rest] = e.args.trim().split(/\s+/)
148    const arg = rest.join(' ')
149    const now = await $.clock.now()
150    const p0 = await read($, petAtom)
151    switch (verb) {
152      case 'feed': queue({ kind: 'feed', at: now }); break
153      case 'play': queue({ kind: 'play', at: now }); break
154      case 'clean': queue({ kind: 'clean', at: now }); break
155      case 'tuck': queue({ kind: 'tuck', at: now }); break
156      case 'talk': if (p0) queue({ kind: 'talk', text: talkLine(p0, now), at: now }); break
157      case 'use': if (arg in ITEMS) queue({ kind: 'use', item: arg as Item, at: now }); break
158      case 'train': if (arg in SKILL_NAMES) queue({ kind: 'train', skill: arg as Skill, at: now }); break
159      case 'hat': queue({ kind: 'equip', hat: arg === 'none' || !arg ? null : (arg as Hat), at: now }); break
160      case 'name': if (arg) queue({ kind: 'rename', name: arg.slice(0, 24), at: now }); break
161      case 'hide':
162      case 'show': await update($, hiddenAtom, () => verb === 'hide'); break
163    }
164    await flush($)
165    const p = await read($, petAtom)
166    if (!verb) await $.ui.open({ id: PANE, title: p ? p.name : 'Monster', rows: 40 })
167    return { text: p ? `${title(p)} — ${status(p, now)}` : 'Your monster is on its way.' }
168  })
169
170  // ── What it eats and learns from.
171  on('turn.step', async function* ($, e, next) {
172    const r = yield* next(e)
173    const u = r.usage
174    if (u) queue({ kind: 'tokens', n: u.input_tokens + u.output_tokens + u.cache_creation_input_tokens, at: Date.now() })
175    return r
176  })
177
178  on('agent.spawn', async ($, e, next) => {
179    const r = await next(e)
180    if (r.agentId) queue({ kind: 'agent', at: Date.now() })
181    return r
182  }).catch(($, e, next) => next(e))
183
184  on('tool.call', async ($, e, next) => {
185    const started = Date.now()
186    let r: Awaited<ReturnType<typeof next>> | undefined
187    try {
188      r = await next(e)
189      return r
190    } finally {
191      const input = e as unknown as Record<string, unknown>
192      const tool = String(e.tool)
193      const failed = !r || r.isError === true || r.deny !== undefined
194      const command = typeof input.command === 'string' ? input.command : undefined
195      queue({ kind: 'tool', tool, command, failed, ms: Date.now() - started, at: Date.now() })
196      if (tool === 'TodoWrite' && r && 'result' in r) {
197        const todos = ((r.result ?? {}) as { newTodos?: { status: string }[] }).newTodos ?? []
198        const done = todos.filter(t => t.status === 'completed').length
199        const key = e.agentId ?? 'main'
200        const before = todoDone.get(key) ?? 0
201        if (done > before) queue({ kind: 'task', n: done - before, at: Date.now() })
202        todoDone.set(key, done)
203      }
204      if (tool === 'TaskUpdate' && !failed && input.status === 'completed') queue({ kind: 'task', n: 1, at: Date.now() })
205    }
206  }).catch(($, e, next) => next(e))
207
208  // ── The band above the prompt.
209  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
210    const p = await read($, petAtom)
211    if (e.props.hasSurvey || !p || (await read($, hiddenAtom))) {
212      bandFrames = []
213      return next(e)
214    }
215    const now = await $.clock.now()
216    const frames = cropAll(petFrames(p, now))
217    const { Box, Text } = $.ui.resolve(e)
218    const info = (
219      <Box flexDirection="column">
220        <Text color="#FFCD75" bold>{title(p)}{p.streak.days > 1 ? ` · 🔥${p.streak.days}` : ''}</Text>
221        <Text>
222          <Text color="#EF7D57">food {bar(p.hunger)}</Text> <Text color="#A7F070">joy {bar(p.joy)}</Text>{' '}
223          <Text color="#FFCD75">nrg {bar(p.energy, 5)}</Text> <Text color="#73EFF7">xp {bar(xpPct(p))}</Text>
224          {isSick(p) && <Text color="#E24B4A"> sick</Text>}
225        </Text>
226        <Text dimColor>{status(p, now)}</Text>
227      </Box>
228    )
229    if (e.surface === 'terminal') {
230      const { Raster } = $.ui.resolve(e)
231      bandFrames = frames.map(f => cells(f).cells)
232      bandId = e.requestId
233      const first = cells(frames[0]!)
234      return (
235        <Box flexDirection="row" gap={1}>
236          <Raster key="pet" columns={first.columns} rows={first.rows} cells={first.cells} />
237          {info}
238        </Box>
239      )
240    }
241    if (e.surface === 'desktop') {
242      const { Svg } = $.ui.resolve(e)
243      return (
244        <Box flexDirection="row" gap={2} alignItems="center">
245          <Svg source={framesSvg(frames, 3)} alt={`${p.name}, your monster`} />
246          {info}
247        </Box>
248      )
249    }
250    return <Box>{info}</Box>
251  })
252
253  // ── The /pet pane.
254  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
255    const p = await read($, petAtom)
256    const { Box, Button, Text } = $.ui.resolve(e)
257    if (!p) return <Text dimColor>Your monster is on its way…</Text>
258    const now = await $.clock.now()
259    const tab = (await read($, tabAtom)) as Tab
260    const hunt = await read($, huntAtom)
261    const frames = habitatFrames(p, now, new Date(now).getHours(), tab === 'games' ? hunt : null)
262    const lv = level(p.xp)
263
264    const header = (
265      <Box flexDirection="column">
266        <Text color="#FFCD75" bold>
267          {title(p)} · {p.personality} · {age(p, now)} old{p.streak.days > 1 ? ` · 🔥 ${p.streak.days}-day streak` : ''}
268          {isAsleep(p, now) ? ' · asleep' : ''}{isSick(p) ? ' · sick' : ''}
269        </Text>
270        <Text>
271          <Text color="#EF7D57">food {bar(p.hunger, 10)}</Text>  <Text color="#A7F070">joy {bar(p.joy, 10)}</Text>  <Text color="#FFCD75">energy {bar(p.energy, 10)}</Text>  <Text color="#E24B4A">stress {bar(p.stress, 10)}</Text>
272        </Text>
273        <Text color="#73EFF7">xp {bar(xpPct(p), 20)} {Math.floor(p.xp)}/{xpFor(lv + 1)} → Lv {lv + 1}</Text>
274        {said(p, now) && <Text color="#F4F4F4">💬 “{said(p, now)}”</Text>}
275      </Box>
276    )
277
278    const tabs = (
279      <Box flexDirection="row" gap={1}>
280        {TABS.map(t => (
281          <Button
282            key={`tab-${t}`}
283            label={t === 'items' ? `Items ${Object.values(p.items).reduce((a, b) => a + b, 0)}` : t === 'badges' ? `Badges ${p.badges.length}/${BADGES.length}` : t[0]!.toUpperCase() + t.slice(1)}
284            variant={t === tab ? 'primary' : 'secondary'}
285            onPress={() => update($, tabAtom, () => t)}
286          />
287        ))}
288      </Box>
289    )
290
291    const body = await (async () => {
292      if (tab === 'items') {
293        return (
294          <Box flexDirection="column">
295            {(Object.keys(ITEMS) as Item[]).map(item => (
296              <Box key={`it-${item}`} flexDirection="row" gap={1}>
297                <Button key={`use-${item}`} label={p.items[item] > 0 ? `Use ${ITEMS[item].name}` : `${ITEMS[item].name}: none`} onPress={() => act($, { kind: 'use', item, at: Date.now() })} />
298                <Text>
299                  {ITEMS[item].icon} ×{p.items[item]}  <Text dimColor>{ITEMS[item].what}</Text>
300                </Text>
301              </Box>
302            ))}
303          </Box>
304        )
305      }
306      if (tab === 'games') {
307        const wait = (p.cooldowns.game ?? 0) - now
308        const huntRow = !hunt ? (
309          <Box flexDirection="row" gap={1}>
310            <Button
311              key="hunt-start"
312              label={wait > 0 ? `Treat hunt (rest ${since(wait)})` : 'Start treat hunt'}
313              hotkey="t"
314              onPress={async () => {
315                const q = await read($, petAtom)
316                if (q && (q.cooldowns.game ?? 0) > Date.now()) return
317                await update($, huntAtom, () => ({ round: 1, score: 0, treat: Math.floor(Math.random() * 3), picked: null }))
318              }}
319            />
320            <Text dimColor>{p.name} hides a cookie in one of three bushes. Find it in 2 of 3 rounds to win a prize.</Text>
321          </Box>
322        ) : hunt.picked === null ? (
323          <Box flexDirection="row" gap={1}>
324            <Text>Round {hunt.round}/3 · found {hunt.score} · which bush?</Text>
325            {(['Left', 'Middle', 'Right'] as const).map((label, b) => (
326              <Button key={`bush-${b}`} label={label} hotkey={String(b + 1)} onPress={() => update($, huntAtom, h => (h && h.picked === null ? { ...h, picked: b, score: h.score + (b === h.treat ? 1 : 0) } : h))} />
327            ))}
328          </Box>
329        ) : (
330          <Box flexDirection="row" gap={1}>
331            <Text color={hunt.picked === hunt.treat ? '#A7F070' : '#EF7D57'}>
332              {hunt.picked === hunt.treat ? 'Found it!' : 'Not there…'} Round {hunt.round}/3 · found {hunt.score}
333            </Text>
334            <Button
335              key="hunt-next"
336              label={hunt.round < 3 ? 'Next round' : 'Finish'}
337              variant="primary"
338              hotkey="n"
339              onPress={async () => {
340                const h = await read($, huntAtom)
341                if (!h) return
342                if (h.round < 3) {
343                  await update($, huntAtom, () => ({ round: h.round + 1, score: h.score, treat: Math.floor(Math.random() * 3), picked: null }))
344                  return
345                }
346                const won = h.score >= 2
347                await update($, huntAtom, () => null)
348                await act($, { kind: 'game', won, prize: won ? PRIZES[Math.floor(Math.random() * PRIZES.length)] : undefined, at: Date.now() })
349              }}
350            />
351          </Box>
352        )
353        const trainWait = (p.cooldowns.train ?? 0) - now
354        return (
355          <Box flexDirection="column" gap={1}>
356            {huntRow}
357            <Box flexDirection="row" gap={1}>
358              {(Object.keys(SKILL_NAMES) as Skill[]).map(skill => (
359                <Button key={`train-${skill}`} label={`Train ${SKILL_NAMES[skill]} (${p.skills[skill]})`} onPress={() => act($, { kind: 'train', skill, at: Date.now() })} />
360              ))}
361              <Text dimColor>{trainWait > 0 ? `resting ${since(trainWait)}` : 'costs food and energy'}{p.line ? ` · ${LINE_NAMES[p.line]}s learn ${p.line === 'forge' ? 'Power' : p.line === 'scribe' ? 'Wisdom' : p.line === 'wanderer' ? 'Speed' : 'everything'} faster` : ''}</Text>
362            </Box>
363            <Text dimColor>Treat hunts: {p.stats.wins} won of {p.stats.games} · trained {p.stats.trained} times</Text>
364          </Box>
365        )
366      }
367      if (tab === 'style') {
368        return (
369          <Box flexDirection="column" gap={1}>
370            <Box flexDirection="row" gap={1} flexWrap="wrap">
371              <Button key="hat-none" label="No hat" variant={p.hat === null ? 'primary' : 'secondary'} onPress={() => act($, { kind: 'equip', hat: null, at: Date.now() })} />
372              {p.hats.map(h => (
373                <Button key={`hat-${h}`} label={HATS[h]} variant={p.hat === h ? 'primary' : 'secondary'} onPress={() => act($, { kind: 'equip', hat: h, at: Date.now() })} />
374              ))}
375            </Box>
376            {(Object.keys(HATS) as Hat[])
377              .filter(h => !p.hats.includes(h))
378              .map(h => {
379                const b = BADGES.find(x => x.hat === h)
380                return <Text key={`locked-${h}`} dimColor>🔒 {HATS[h]}: {b ? b.what.toLowerCase() : 'secret'}</Text>
381              })}
382          </Box>
383        )
384      }
385      if (tab === 'badges') {
386        return (
387          <Box flexDirection="column">
388            {BADGES.map(b => (
389              <Text key={`b-${b.id}`} color={p.badges.includes(b.id) ? '#FFCD75' : undefined} dimColor={!p.badges.includes(b.id)}>
390                {p.badges.includes(b.id) ? '🏅' : '○ '} {b.name}: {b.what}{b.hat ? ` (unlocks ${HATS[b.hat].toLowerCase()})` : ''}
391              </Text>
392            ))}
393          </Box>
394        )
395      }
396      // Home.
397      const tucked = (p.asleepUntil ?? 0) > now
398      return (
399        <Box flexDirection="column" gap={1}>
400          <Box flexDirection="row" gap={1} flexWrap="wrap">
401            <Button key="feed" label={(p.cooldowns.feed ?? 0) > now ? 'Feed (full)' : 'Feed'} hotkey="f" onPress={() => act($, { kind: 'feed', at: Date.now() })} />
402            <Button key="play" label={(p.cooldowns.play ?? 0) > now ? 'Play (tired)' : 'Play'} hotkey="p" onPress={() => act($, { kind: 'play', at: Date.now() })} />
403            <Button key="pat" label="Pat ♥" hotkey="h" onPress={() => act($, { kind: 'pet', at: Date.now() })} />
404            <Button key="talk" label="Talk" hotkey="t" onPress={async () => {
405              const q = await read($, petAtom)
406              if (q) await act($, { kind: 'talk', text: talkLine(q, Date.now()), at: Date.now() })
407            }} />
408            {p.mess > 0 && <Button key="clean" label={`Clean (${p.mess})`} hotkey="c" onPress={() => act($, { kind: 'clean', at: Date.now() })} />}
409            <Button key="tuck" label={tucked ? 'Sleeping…' : 'Tuck in'} hotkey="z" onPress={() => act($, { kind: 'tuck', at: Date.now() })} />
410            <Button key="band" label={(await read($, hiddenAtom)) ? 'Show band' : 'Hide band'} onPress={() => update($, hiddenAtom, h => !h)} />
411          </Box>
412          <Box flexDirection="column">
413            {p.log.slice(-5).reverse().map((l, i) => (
414              <Text key={`log-${i}`} color="#94B0C2">· {l.text}</Text>
415            ))}
416          </Box>
417        </Box>
418      )
419    })()
420
421    if (e.surface === 'terminal') {
422      const { Raster } = $.ui.resolve(e)
423      paneFrames = frames.map(f => cells(f).cells)
424      const first = cells(frames[0]!)
425      return (
426        <Box flexDirection="column" gap={1}>
427          <Raster key="habitat" columns={first.columns} rows={first.rows} cells={first.cells} />
428          {header}
429          {tabs}
430          {body}
431        </Box>
432      )
433    }
434    if (e.surface === 'desktop') {
435      const { Svg } = $.ui.resolve(e)
436      return (
437        <Box flexDirection="column" gap={1}>
438          <Svg source={framesSvg(frames, 7)} alt={`${p.name} in its habitat`} />
439          {header}
440          {tabs}
441          {body}
442        </Box>
443      )
444    }
445    return (
446      <Box flexDirection="column" gap={1}>
447        {header}
448        {tabs}
449        {body}
450      </Box>
451    )
452  })
453
454  on('ui.close', { id: PANE }, ($, e, next) => {
455    paneFrames = []
456    return next(e)
457  }).catch(($, e, next) => next(e))
458
459  on('session.end', async ($, e, next) => {
460    ticker?.cancel()
461    animator?.cancel()
462    await flush($)
463    return next(e)
464  }).catch(($, e, next) => next(e))
465}
466
hooks/lines.ts 86 lines
1// What the monster says when you talk to it: picked from its needs, the hour, recent work,
2// its line and its personality. Offline and canned: no model calls.
3
4import { FORM_NAMES, ITEMS, LINE_NAMES, isAsleep, isSick, level, xpFor, type Item, type Pet } from './pet'
5
6type Pool = string[]
7
8const NEEDS = {
9  hungry: ['My tummy is rumbling… got any tokens?', 'Food? Food. Food!', 'I could eat a whole diff right now.', 'Feed me and I’ll debug anything.'],
10  sleepy: ['*yawn* Can we take a nap?', 'My eyes keep closing…', 'A coffee would really help right now.', 'Five more minutes…'],
11  sick: ['I don’t feel so good… too many red errors.', 'Can we try green tests for a while?', 'My head is full of stack traces.'],
12  messy: ['Um… it’s a bit messy in here.', 'Could you clean up? I stepped in something.', 'The habitat needs a tidy-up!'],
13  sad: ['I missed you.', 'It’s been quiet. Want to play?', 'Pat me? Just once?'],
14}
15
16const HOURS: [number, number, Pool][] = [
17  [5, 11, ['Good morning! Fresh day, fresh bugs.', 'Morning! What are we building today?', 'Coffee first, then commits.']],
18  [11, 17, ['Afternoon push! You’ve got this.', 'Halfway through the day already?', 'Lunch break for me too?']],
19  [17, 22, ['Evening coding is the best coding.', 'The sky is so orange right now.', 'One more feature, then rest?']],
20  [22, 29, ['It’s late… don’t forget to sleep.', 'The stars are out. So are the bugs.', 'Night owl mode activated.']],
21]
22
23const CHEERS: Pool = [
24  'I believe in you!', 'Every bug you fix makes me stronger.', 'Let’s ship something great today.',
25  'You type so fast!', 'I like it when the tests turn green.', 'We make a good team.',
26]
27
28const QUIPS: Pool = [
29  'Did you know? Rubber ducks are great listeners. So am I.', 'It works on my machine. I live on your machine!',
30  'A commit a day keeps the merge conflicts away.', 'Naming things is hard. You named me well, though.',
31  'I dreamt about semicolons.', 'Have you tried turning it off and on again?', 'Small PRs are tasty PRs.',
32  'Off-by-one errors are my natural enemy.', 'I read the logs so you don’t have to. Mostly.',
33  'Today’s forecast: 100% chance of refactoring.', 'Caches are just memories with an expiry date.',
34]
35
36const LINE_TALK: Record<string, Pool> = {
37  forge: ['Let’s run something loud in the shell!', 'I love the smell of a fresh build.', 'Pipes! Greps! Fire!'],
38  scribe: ['I read every line you edit.', 'Good code reads like a story.', 'Let me float over that file with you.'],
39  summoner: ['More agents! More friends!', 'I can hear the subagents humming.', 'Delegate, delegate, delegate.'],
40  wanderer: ['What’s out there on the web today?', 'I flew past three docs pages this morning.', 'Let’s go look something up!'],
41}
42
43const FLAVOR: Record<Pet['personality'], (s: string) => string> = {
44  cheerful: s => (s.endsWith('!') || s.endsWith('?') ? s : s.replace(/\.$/, '') + '!'),
45  lazy: s => `*yawn* ${s.charAt(0).toLowerCase()}${s.slice(1)}`,
46  curious: s => (s.endsWith('?') ? s : `${s} …what are you working on?`),
47  grumpy: s => `Hmph. ${s}`,
48}
49
50function pick<T>(pool: readonly T[], rnd: () => number): T {
51  return pool[Math.floor(rnd() * pool.length) % pool.length]!
52}
53
54export function talkLine(p: Pet, now: number, rnd: () => number = Math.random): string {
55  if (p.stage === 'egg') return pick(['*wobble*', '*tap tap*', '…(something stirs inside)', '*the egg feels warm*'], rnd)
56  if (isAsleep(p, now) && (p.asleepUntil ?? 0) > now) return pick(['Zzz…', '*snore*', 'mmh… five more minutes…'], rnd)
57  if (isSick(p)) return pick(NEEDS.sick, rnd)
58  if (p.hunger < 25) return FLAVOR[p.personality](pick(NEEDS.hungry, rnd))
59  if (p.energy < 20) return pick(NEEDS.sleepy, rnd)
60  if (p.mess >= 2) return pick(NEEDS.messy, rnd)
61  if (p.joy < 30) return pick(NEEDS.sad, rnd)
62
63  const options: string[] = []
64  const hour = new Date(now).getHours()
65  const h = hour < 5 ? hour + 24 : hour
66  for (const [from, to, pool] of HOURS) if (h >= from && h < to) options.push(pick(pool, rnd))
67  if (p.streak.days >= 2) options.push(`Day ${p.streak.days} in a row together!`)
68  const lv = level(p.xp)
69  const toNext = Math.ceil(xpFor(lv + 1) - p.xp)
70  if (toNext <= 15) options.push(`Only ${toNext} xp until level ${lv + 1}!`)
71  if (p.line) options.push(pick(LINE_TALK[p.line]!, rnd))
72  if (p.stage === 'child' && p.line) {
73    const v = p.care.n && p.care.sum / p.care.n >= 55 ? 'bright' : 'shadow'
74    options.push(`When I grow up I want to be ${FORM_NAMES[p.line][v][0]}!`)
75  }
76  const stash = (Object.keys(p.items) as Item[]).filter(i => p.items[i] > 0)
77  if (stash.length) {
78    const i = pick(stash, rnd)
79    options.push(`We have ${p.items[i]} ${ITEMS[i].name.toLowerCase()}${p.items[i] === 1 ? '' : 's'} saved up ${ITEMS[i].icon}`)
80  }
81  if (p.stats.tests > 0) options.push(`You’ve passed ${p.stats.tests} test run${p.stats.tests === 1 ? '' : 's'} with me.`)
82  if (p.line) options.push(`I’m a ${LINE_NAMES[p.line]}. It suits us, right?`)
83  options.push(pick(CHEERS, rnd), pick(QUIPS, rnd), pick(QUIPS, rnd))
84  return FLAVOR[p.personality](pick(options, rnd))
85}
86
hooks/pet.ts 473 lines
1// The monster and the rules that raise it. Pure: events in, a new pet out.
2
3import type { Event, Hat, Item, Line, Personality, Pet, Skill, Stage, Variant } from '../types'
4
5export type { Event, Hat, Item, Line, Personality, Pet, Skill, Stage, Variant }
6
7export const HOUR = 3600_000
8const MIN = 60_000
9
10const clamp = (n: number) => Math.max(0, Math.min(100, n))
11
12export const PERSONALITIES: Personality[] = ['cheerful', 'lazy', 'curious', 'grumpy']
13
14export function hatch(now: number, name = 'Byte', personality: Personality = 'cheerful'): Pet {
15  return normalize({ name, born: now, updatedAt: now, lastActive: now, personality, log: [{ at: now, text: 'An egg appeared.' }] })
16}
17
18/** Fills in every field a pet saved by an older version lacks. */
19export function normalize(raw: Partial<Pet>): Pet {
20  const now = raw.updatedAt ?? 0
21  return {
22    name: raw.name ?? 'Byte',
23    born: raw.born ?? now,
24    updatedAt: now,
25    lastActive: raw.lastActive ?? now,
26    personality: raw.personality ?? 'cheerful',
27    stage: raw.stage ?? 'egg',
28    line: raw.line ?? null,
29    variant: raw.variant ?? null,
30    xp: raw.xp ?? 0,
31    hunger: raw.hunger ?? 80,
32    joy: raw.joy ?? 70,
33    stress: raw.stress ?? 0,
34    energy: raw.energy ?? 80,
35    asleepUntil: raw.asleepUntil,
36    mess: raw.mess ?? 0,
37    skills: { power: 0, wisdom: 0, speed: 0, ...raw.skills },
38    items: { cookie: 1, coffee: 0, gem: 0, bug: 0, ...raw.items },
39    hat: raw.hat ?? null,
40    hats: raw.hats ?? [],
41    badges: raw.badges ?? [],
42    streak: raw.streak ?? { days: 0, lastDay: '' },
43    care: raw.care ?? { sum: 0, n: 0 },
44    traits: { shell: 0, code: 0, agents: 0, web: 0, ...raw.traits },
45    stats: {
46      tokens: 0, tests: 0, commits: 0, pushes: 0, tasks: 0, errors: 0, fed: 0, meals: 0, played: 0, pats: 0,
47      talks: 0, games: 0, wins: 0, trained: 0, cleaned: 0, agents: 0, ...raw.stats,
48    },
49    cooldowns: { ...raw.cooldowns },
50    mood: raw.mood,
51    said: raw.said,
52    log: raw.log ?? [],
53  }
54}
55
56export function level(xp: number) {
57  return Math.floor(Math.sqrt(Math.max(0, xp) / 10))
58}
59
60/** XP at which a level begins. */
61export function xpFor(lv: number) {
62  return lv * lv * 10
63}
64
65const STAGE_AT: { stage: Stage; level: number }[] = [
66  { stage: 'baby', level: 1 },
67  { stage: 'child', level: 4 },
68  { stage: 'adult', level: 10 },
69  { stage: 'ultimate', level: 20 },
70]
71
72export const LINE_NAMES: Record<Line, string> = { forge: 'Forge', scribe: 'Scribe', summoner: 'Summoner', wanderer: 'Wanderer' }
73
74/** What each line's adults are called, bright and shadow. */
75export const FORM_NAMES: Record<Line, Record<Variant, [string, string]>> = {
76  forge: { bright: ['Emberkin', 'Solforge'], shadow: ['Cinderhorn', 'Ashtyrant'] },
77  scribe: { bright: ['Quillwisp', 'Lorewraith'], shadow: ['Inkshade', 'Hexscript'] },
78  summoner: { bright: ['Callpup', 'Choirlord'], shadow: ['Hollowear', 'Legionmaw'] },
79  wanderer: { bright: ['Skylark', 'Zephyrus'], shadow: ['Duskwing', 'Stormcrow'] },
80}
81
82export const ITEMS: Record<Item, { name: string; icon: string; what: string }> = {
83  cookie: { name: 'Cookie', icon: '🍪', what: '+15 food, +5 joy. Earned from passing tests and daily gifts.' },
84  coffee: { name: 'Coffee', icon: '☕', what: '+30 energy. Earned every 50k tokens of work.' },
85  gem: { name: 'Gem', icon: '💎', what: '+20 xp, +10 joy. Earned from commits, pushes and streaks.' },
86  bug: { name: 'Bug snack', icon: '🐛', what: '+25 food, +5 xp. Earned when a failing test passes again.' },
87}
88
89export const SKILL_NAMES: Record<Skill, string> = { power: 'Power', wisdom: 'Wisdom', speed: 'Speed' }
90
91/** Each line trains one skill faster. */
92const SKILL_OF: Record<Line, Skill | null> = { forge: 'power', scribe: 'wisdom', wanderer: 'speed', summoner: null }
93
94export const HATS: Record<Hat, string> = {
95  party: 'Party hat', cap: 'Cap', headphones: 'Headphones', wizard: 'Wizard hat',
96  flower: 'Flower', bow: 'Bow', beanie: 'Beanie', halo: 'Halo',
97}
98
99type Badge = { id: string; name: string; what: string; hat?: Hat; earned: (p: Pet) => boolean }
100
101export const BADGES: Badge[] = [
102  { id: 'hatched', name: 'Hello, world', what: 'Hatch your egg', earned: p => p.stage !== 'egg' },
103  { id: 'lv5', name: 'Growing up', what: 'Reach level 5', hat: 'party', earned: p => level(p.xp) >= 5 },
104  { id: 'adult', name: 'All grown up', what: 'Evolve into an adult', earned: p => p.stage === 'adult' || p.stage === 'ultimate' },
105  { id: 'ultimate', name: 'Legend', what: 'Reach the ultimate stage', earned: p => p.stage === 'ultimate' },
106  { id: 'tests10', name: 'Green light', what: 'Pass 10 test runs', earned: p => p.stats.tests >= 10 },
107  { id: 'tests25', name: 'Test whisperer', what: 'Pass 25 test runs', hat: 'halo', earned: p => p.stats.tests >= 25 },
108  { id: 'commits10', name: 'Committed', what: 'Make 10 commits', hat: 'cap', earned: p => p.stats.commits >= 10 },
109  { id: 'push', name: 'Shipped it', what: 'Push to a remote', earned: p => p.stats.pushes >= 1 },
110  { id: 'shell100', name: 'Shell dweller', what: 'Run 100 shell commands', hat: 'headphones', earned: p => p.traits.shell >= 100 },
111  { id: 'wisdom20', name: 'Bookworm', what: 'Train Wisdom to 20', hat: 'wizard', earned: p => p.skills.wisdom >= 20 },
112  { id: 'fed10', name: 'Well fed', what: 'Feed it 10 times', hat: 'flower', earned: p => p.stats.meals >= 10 },
113  { id: 'pats30', name: 'Beloved', what: 'Pat it 30 times', hat: 'bow', earned: p => p.stats.pats >= 30 },
114  { id: 'streak7', name: 'Regular', what: 'Work 7 days in a row', hat: 'beanie', earned: p => p.streak.days >= 7 },
115  { id: 'wins10', name: 'Treat hunter', what: 'Win 10 treat hunts', earned: p => p.stats.wins >= 10 },
116  { id: 'agents10', name: 'Summoner’s call', what: 'Spawn 10 subagents', earned: p => p.stats.agents >= 10 },
117  { id: 'tokens1m', name: 'Big eater', what: 'Eat a million tokens', earned: p => p.stats.tokens >= 1_000_000 },
118  { id: 'clean5', name: 'Tidy', what: 'Clean up 5 times', earned: p => p.stats.cleaned >= 5 },
119  { id: 'talk20', name: 'Chatterbox', what: 'Talk with it 20 times', earned: p => p.stats.talks >= 20 },
120]
121
122function topLine(p: Pet): Line {
123  const t = p.traits
124  const ranked: [Line, number][] = [['forge', t.shell], ['scribe', t.code], ['summoner', t.agents * 3], ['wanderer', t.web * 2]]
125  ranked.sort((a, b) => b[1] - a[1])
126  return ranked[0]![0]
127}
128
129export function formName(p: Pet): string {
130  if (!p.line || !p.variant || (p.stage !== 'adult' && p.stage !== 'ultimate')) return p.stage
131  return FORM_NAMES[p.line][p.variant][p.stage === 'adult' ? 0 : 1]
132}
133
134function note(p: Pet, at: number, text: string) {
135  p.log = [...p.log, { at, text }].slice(-40)
136}
137
138export function isAsleep(p: Pet, now: number) {
139  return (p.asleepUntil ?? 0) > now || now - p.lastActive > 30 * MIN
140}
141
142export function isSick(p: Pet) {
143  return p.stress >= 80
144}
145
146export function dayOf(at: number) {
147  const d = new Date(at)
148  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
149}
150
151/** Time passes: hunger falls, stress eases, energy drains awake and returns asleep. */
152export function decay(p: Pet, now: number): Pet {
153  const hours = Math.max(0, (now - p.updatedAt) / HOUR)
154  if (hours <= 0) return p
155  const next = { ...p, updatedAt: now }
156  const sleeping = isAsleep(p, now)
157  next.hunger = clamp(p.hunger - hours * (sleeping ? 2 : 4))
158  next.stress = clamp(p.stress - hours * (sleeping ? 10 : 6))
159  next.energy = clamp(p.energy + hours * (sleeping ? 15 : -3))
160  const lonely = now - p.lastActive > 24 * HOUR
161  const grubby = p.mess >= 2
162  if (next.hunger < 30 || lonely || grubby) next.joy = clamp(p.joy - hours * (grubby ? 4 : 3))
163  return next
164}
165
166function order(s: Stage) {
167  return ['egg', 'baby', 'child', 'adult', 'ultimate'].indexOf(s)
168}
169
170function grow(p: Pet, at: number) {
171  const lv = level(p.xp)
172  for (const s of STAGE_AT) {
173    if (lv >= s.level && order(p.stage) < order(s.stage)) {
174      if (s.stage === 'child' && !p.line) p.line = topLine(p)
175      if (s.stage === 'adult' && !p.variant) {
176        const avg = p.care.n ? p.care.sum / p.care.n : p.joy
177        p.variant = avg >= 55 && p.stress < 60 ? 'bright' : 'shadow'
178      }
179      p.stage = s.stage
180      const what = s.stage === 'baby' ? `${p.name} hatched!` : `${p.name} evolved into ${article(formName(p))}${p.line && s.stage === 'child' ? ` (${LINE_NAMES[p.line]} line)` : ''}!`
181      note(p, at, what)
182      p.mood = { kind: 'evolve', until: at + 8000 }
183    }
184  }
185}
186
187function article(word: string) {
188  return /^[aeiou]/i.test(word) ? `an ${word}` : `a ${word}`
189}
190
191function give(p: Pet, item: Item, n = 1) {
192  p.items = { ...p.items, [item]: Math.min(99, p.items[item] + n) }
193}
194
195const TEST = /\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b|\bpytest\b|\bjest\b|\bvitest\b|\bcargo\s+test\b|\bgo\s+test\b|\bplugin\s+test\b|\bmake\s+test\b|\brspec\b|\bphpunit\b/
196const COMMIT = /\bgit\s+commit\b/
197const PUSH = /\bgit\s+push\b/
198
199/** A new day of work: extend or restart the streak, and hand out the daily gift. */
200function streak(p: Pet, at: number) {
201  const today = dayOf(at)
202  if (p.streak.lastDay === today) return
203  const yesterday = dayOf(at - 24 * HOUR)
204  const days = p.streak.lastDay === yesterday ? p.streak.days + 1 : 1
205  p.streak = { days, lastDay: today }
206  give(p, 'cookie')
207  if (days === 3 || days === 7 || days % 30 === 0) {
208    give(p, 'gem')
209    note(p, at, `${days}-day streak! A gem for you.`)
210  } else if (days > 1) note(p, at, `Day ${days} together. A cookie for you.`)
211}
212
213function active(p: Pet, at: number) {
214  p.lastActive = at
215  p.care = { sum: p.care.sum + p.joy, n: p.care.n + 1 }
216  streak(p, at)
217}
218
219/** One thing that happened in some session, applied to the pet. */
220export function apply(prev: Pet, e: Event): Pet {
221  const d = decay(prev, e.at)
222  const p: Pet = {
223    ...d, traits: { ...d.traits }, stats: { ...d.stats }, cooldowns: { ...d.cooldowns },
224    items: { ...d.items }, skills: { ...d.skills }, hats: [...d.hats], badges: [...d.badges],
225  }
226  const xp0 = p.xp
227  switch (e.kind) {
228    case 'tokens': {
229      p.hunger = clamp(p.hunger + e.n / 2500)
230      p.xp += e.n / 8000
231      const coffees = Math.floor((p.stats.tokens + e.n) / 50_000) - Math.floor(p.stats.tokens / 50_000)
232      p.stats.tokens += e.n
233      if (coffees > 0) give(p, 'coffee', coffees)
234      active(p, e.at)
235      break
236    }
237    case 'tool': {
238      active(p, e.at)
239      if (e.tool === 'Bash') p.traits.shell += 1
240      else if (['Read', 'Edit', 'Write', 'Grep', 'Glob', 'NotebookEdit', 'MultiEdit'].includes(e.tool)) p.traits.code += 1
241      else if (e.tool === 'Agent') p.traits.agents += 1
242      else if (e.tool === 'WebSearch' || e.tool === 'WebFetch') p.traits.web += 1
243      if (e.failed) {
244        p.stress = clamp(p.stress + 3)
245        p.stats.errors += 1
246        p.mood = { kind: 'ouch', until: e.at + 4000 }
247      } else if ((e.ms ?? 0) > 60_000) {
248        p.mood = { kind: 'sweat', until: e.at + 4000 }
249      }
250      const cmd = e.command ?? ''
251      if (e.tool === 'Bash' && TEST.test(cmd)) {
252        if (e.failed) {
253          p.stress = clamp(p.stress + 5)
254          p.stats.lastTestFailed = true
255        } else {
256          p.joy = clamp(p.joy + 8)
257          p.xp += 5
258          p.stats.tests += 1
259          give(p, 'cookie')
260          if (p.stats.lastTestFailed) {
261            give(p, 'bug')
262            note(p, e.at, 'Fixed it! A bug snack for you.')
263          } else note(p, e.at, 'Tests passed! A cookie for you.')
264          p.stats.lastTestFailed = false
265          p.mood = { kind: 'cheer', until: e.at + 5000 }
266        }
267      }
268      if (e.tool === 'Bash' && COMMIT.test(cmd) && !e.failed) {
269        p.joy = clamp(p.joy + 5)
270        p.xp += 10
271        p.stats.commits += 1
272        give(p, 'gem')
273        p.mood = { kind: 'cheer', until: e.at + 5000 }
274        note(p, e.at, 'A commit! A gem for you.')
275      }
276      if (e.tool === 'Bash' && PUSH.test(cmd) && !e.failed) {
277        p.joy = clamp(p.joy + 8)
278        p.xp += 10
279        p.stats.pushes += 1
280        give(p, 'gem')
281        p.mood = { kind: 'cheer', until: e.at + 6000 }
282        note(p, e.at, 'Shipped! Pushed to the remote.')
283      }
284      break
285    }
286    case 'task': {
287      p.xp += 5 * e.n
288      p.joy = clamp(p.joy + 3 * e.n)
289      p.stats.tasks += e.n
290      active(p, e.at)
291      p.mood = { kind: 'cheer', until: e.at + 4000 }
292      break
293    }
294    case 'agent': {
295      p.stats.agents += 1
296      p.mood = { kind: 'wave', until: e.at + 4000 }
297      break
298    }
299    case 'feed': {
300      if ((p.cooldowns.feed ?? 0) > e.at) {
301        p.mood = { kind: 'full', until: e.at + 3000 }
302        break
303      }
304      p.hunger = clamp(p.hunger + 25)
305      p.cooldowns.feed = e.at + HOUR
306      p.stats.fed += 1
307      meal(p)
308      p.mood = { kind: 'eat', until: e.at + 4000 }
309      note(p, e.at, `You fed ${p.name}.`)
310      break
311    }
312    case 'use': {
313      if (p.items[e.item] <= 0) break
314      p.items[e.item] -= 1
315      if (e.item === 'cookie') {
316        p.hunger = clamp(p.hunger + 15)
317        p.joy = clamp(p.joy + 5)
318        meal(p)
319        p.mood = { kind: 'eat', until: e.at + 4000 }
320      } else if (e.item === 'bug') {
321        p.hunger = clamp(p.hunger + 25)
322        p.xp += 5
323        meal(p)
324        p.mood = { kind: 'eat', until: e.at + 4000 }
325      } else if (e.item === 'coffee') {
326        p.energy = clamp(p.energy + 30)
327        p.asleepUntil = undefined
328        p.mood = { kind: 'cheer', until: e.at + 4000 }
329      } else if (e.item === 'gem') {
330        p.xp += 20
331        p.joy = clamp(p.joy + 10)
332        p.mood = { kind: 'love', until: e.at + 4000 }
333      }
334      note(p, e.at, `${p.name} had a ${ITEMS[e.item].name.toLowerCase()}.`)
335      break
336    }
337    case 'play': {
338      if ((p.cooldowns.play ?? 0) > e.at || p.energy < 10) {
339        p.mood = { kind: 'tired', until: e.at + 3000 }
340        break
341      }
342      p.joy = clamp(p.joy + 15)
343      p.stress = clamp(p.stress - 10)
344      p.energy = clamp(p.energy - 8)
345      p.xp += 2
346      p.cooldowns.play = e.at + 30 * MIN
347      p.stats.played += 1
348      p.mood = { kind: 'cheer', until: e.at + 4000 }
349      note(p, e.at, `You played with ${p.name}.`)
350      break
351    }
352    case 'pet': {
353      p.joy = clamp(p.joy + 2)
354      p.stats.pats += 1
355      p.mood = { kind: 'love', until: e.at + 3000 }
356      break
357    }
358    case 'talk': {
359      p.joy = clamp(p.joy + 1)
360      p.stats.talks += 1
361      p.said = { text: e.text, at: e.at }
362      p.mood = { kind: 'talk', until: e.at + 5000 }
363      break
364    }
365    case 'clean': {
366      if (p.mess === 0) break
367      p.mess = 0
368      p.joy = clamp(p.joy + 5)
369      p.stats.cleaned += 1
370      p.mood = { kind: 'cheer', until: e.at + 3000 }
371      note(p, e.at, `You tidied up after ${p.name}.`)
372      break
373    }
374    case 'tuck': {
375      p.asleepUntil = e.at + 2 * HOUR
376      p.stress = clamp(p.stress - 5)
377      p.mood = undefined
378      note(p, e.at, `${p.name} is tucked in. Good night!`)
379      break
380    }
381    case 'game': {
382      p.stats.games += 1
383      p.energy = clamp(p.energy - 5)
384      p.cooldowns.game = e.at + 5 * MIN
385      if (e.won) {
386        p.stats.wins += 1
387        p.joy = clamp(p.joy + 10)
388        p.xp += 4
389        if (e.prize) give(p, e.prize)
390        p.mood = { kind: 'win', until: e.at + 5000 }
391        note(p, e.at, `${p.name} won the treat hunt${e.prize ? ` and found a ${ITEMS[e.prize].name.toLowerCase()}` : ''}!`)
392      } else {
393        p.joy = clamp(p.joy + 3)
394        p.mood = { kind: 'lose', until: e.at + 4000 }
395      }
396      break
397    }
398    case 'train': {
399      if ((p.cooldowns.train ?? 0) > e.at || p.energy < 10 || p.hunger < 10) {
400        p.mood = { kind: 'tired', until: e.at + 3000 }
401        break
402      }
403      const bonus = p.line && SKILL_OF[p.line] === e.skill ? 2 : 0
404      p.skills[e.skill] += 3 + bonus
405      p.hunger = clamp(p.hunger - 8)
406      p.energy = clamp(p.energy - 10)
407      p.xp += 3
408      p.stats.trained += 1
409      p.cooldowns.train = e.at + 15 * MIN
410      p.mood = { kind: 'sweat', until: e.at + 4000 }
411      note(p, e.at, `${p.name} trained ${SKILL_NAMES[e.skill]} (+${3 + bonus}).`)
412      break
413    }
414    case 'equip': {
415      if (e.hat === null || p.hats.includes(e.hat)) p.hat = e.hat
416      break
417    }
418    case 'rename': {
419      note(p, e.at, `${p.name} is now called ${e.name}.`)
420      p.name = e.name
421      break
422    }
423  }
424  if (p.stress >= 80 && prev.stress < 80) note(p, e.at, `${p.name} feels sick from all the errors.`)
425  if (level(p.xp) > level(xp0)) {
426    note(p, e.at, `${p.name} reached level ${level(p.xp)}.`)
427    p.mood ??= { kind: 'cheer', until: e.at + 4000 }
428  }
429  grow(p, e.at)
430  badges(p, e.at)
431  return p
432}
433
434function meal(p: Pet) {
435  p.stats.meals += 1
436  if (p.stats.meals % 3 === 0) p.mess = Math.min(3, p.mess + 1)
437}
438
439function badges(p: Pet, at: number) {
440  for (const b of BADGES) {
441    if (p.badges.includes(b.id) || !b.earned(p)) continue
442    p.badges.push(b.id)
443    if (b.hat && !p.hats.includes(b.hat)) p.hats.push(b.hat)
444    note(p, at, `Badge: ${b.name}${b.hat ? ` (unlocked the ${HATS[b.hat].toLowerCase()})` : ''}`)
445  }
446}
447
448export function applyAll(p: Pet, events: readonly Event[], now: number): Pet {
449  let next = normalize(p)
450  for (const e of events) next = apply(next, e)
451  return decay(next, now)
452}
453
454export type Face = 'happy' | 'sad' | 'sick' | 'sleep' | 'hungry' | 'sleepy' | NonNullable<Pet['mood']>['kind']
455
456/** What the monster looks like it feels right now. */
457export function face(p: Pet, now: number): Face {
458  if (p.mood && p.mood.until > now) return p.mood.kind
459  if (isSick(p)) return 'sick'
460  if (isAsleep(p, now)) return 'sleep'
461  if (p.hunger < 25) return 'hungry'
462  if (p.energy < 20) return 'sleepy'
463  if (p.joy < 30) return 'sad'
464  return 'happy'
465}
466
467export function age(p: Pet, now: number) {
468  const d = Math.floor((now - p.born) / (24 * HOUR))
469  if (d >= 1) return `${d} day${d === 1 ? '' : 's'}`
470  const h = Math.floor((now - p.born) / HOUR)
471  return h >= 1 ? `${h}h` : `${Math.max(1, Math.floor((now - p.born) / MIN))}m`
472}
473
hooks/render.ts 253 lines
1// Turns the pet into animation frames, and frames into an SVG (desktop) or cells (terminal).
2
3import { SIZE, ball, bowl, bush, cookie, heart, mess, monster, note, star, sweat, zz, type Pose, type Rgb, type Sprite } from './art'
4import { face, type Face, type Pet } from './pet'
5
6export const FRAME_MS = 260
7
8function poses(f: Face): { pose: Pose; dy: number }[] {
9  const p = (eyes: Pose['eyes'], mouth: Pose['mouth'], squash = false, flicker = false, dy = 0) => ({ pose: { eyes, mouth, squash, flicker }, dy })
10  switch (f) {
11    case 'cheer':
12    case 'evolve':
13      return [p('happy', 'open', true), p('happy', 'open', false, true, -3), p('happy', 'open', false, false, -1), p('happy', 'smile', true, true)]
14    case 'ouch':
15      return [p('x', 'frown', true), p('x', 'frown', true, true), p('x', 'flat'), p('x', 'frown', true, true)]
16    case 'eat':
17      return [p('happy', 'open'), p('happy', 'flat', true, true), p('happy', 'open'), p('happy', 'flat', true, true)]
18    case 'full':
19    case 'tired':
20      return [p('closed', 'flat'), p('closed', 'flat', false, true), p('closed', 'flat'), p('open', 'flat', false, true)]
21    case 'love':
22      return [p('happy', 'smile'), p('happy', 'smile', false, true, -1), p('happy', 'smile'), p('happy', 'smile', true, true)]
23    case 'sleep':
24      return [p('closed', 'flat', true), p('closed', 'flat', true, true), p('closed', 'o'), p('closed', 'flat', true, true)]
25    case 'sick':
26      return [p('dizzy', 'frown'), p('dizzy', 'frown', true, true), p('dizzy', 'flat'), p('dizzy', 'frown', true, true)]
27    case 'hungry':
28      return [p('open', 'o'), p('open', 'o', false, true), p('open', 'o'), p('closed', 'o', false, true)]
29    case 'sad':
30      return [p('open', 'frown'), p('open', 'frown', false, true), p('open', 'frown'), p('closed', 'frown', false, true)]
31    case 'happy':
32      return [p('open', 'smile'), p('open', 'smile', false, true, -1), p('open', 'smile'), p('closed', 'smile', false, true)]
33    case 'wave':
34      return [p('happy', 'open', false, false, -1), p('happy', 'smile', false, true), p('happy', 'open', false, false, -1), p('happy', 'smile', false, true)]
35    case 'sweat':
36      return [p('closed', 'flat', true), p('closed', 'open', false, true, -1), p('closed', 'flat', true), p('closed', 'open', false, true, -1)]
37    case 'win':
38      return [p('happy', 'open', true), p('happy', 'open', false, true, -4), p('happy', 'open', false, false, -2), p('happy', 'smile', true, true)]
39    case 'lose':
40      return [p('closed', 'frown'), p('closed', 'frown', true, true), p('open', 'frown'), p('closed', 'frown', true, true)]
41    case 'talk':
42      return [p('open', 'open'), p('open', 'smile', false, true), p('open', 'open'), p('open', 'flat', false, true)]
43    case 'sleepy':
44      return [p('closed', 'flat'), p('closed', 'o', false, true), p('closed', 'flat'), p('open', 'flat', false, true)]
45  }
46}
47
48function blank(w: number, h: number, c: Rgb | null = null): (Rgb | null)[] {
49  return new Array(w * h).fill(c)
50}
51
52function stamp(px: (Rgb | null)[], w: number, h: number, s: Sprite, x: number, y: number) {
53  for (let j = 0; j < s.h; j++) {
54    for (let i = 0; i < s.w; i++) {
55      const c = s.px[j * s.w + i]
56      const X = x + i
57      const Y = y + j
58      if (c !== null && c !== undefined && X >= 0 && Y >= 0 && X < w && Y < h) px[Y * w + X] = c
59    }
60  }
61}
62
63/** The pet alone, one frame per pose, each in the same box (so it can bob without the box moving). */
64export function petFrames(p: Pet, now: number): Sprite[] {
65  const f = face(p, now)
66  const hatching = p.stage === 'egg' && p.xp >= 6
67  const pad = 4
68  const H = SIZE + pad
69  return poses(f).map(({ pose, dy }) => {
70    const px = blank(SIZE, H)
71    stamp(px, SIZE, H, monster(p.stage, p.line, pose, { cracked: hatching, variant: p.variant, hat: p.hat }), 0, pad + dy)
72    return { w: SIZE, h: H, px }
73  })
74}
75
76/** One box around every frame's pixels, so frames crop alike; height even for half blocks. */
77export function cropAll(frames: Sprite[]): Sprite[] {
78  const first = frames[0]
79  if (!first) return frames
80  let x0 = first.w
81  let y0 = first.h
82  let x1 = -1
83  let y1 = -1
84  for (const s of frames) {
85    for (let y = 0; y < s.h; y++) {
86      for (let x = 0; x < s.w; x++) {
87        if (s.px[y * s.w + x] === null) continue
88        x0 = Math.min(x0, x)
89        y0 = Math.min(y0, y)
90        x1 = Math.max(x1, x)
91        y1 = Math.max(y1, y)
92      }
93    }
94  }
95  if (x1 < 0) return frames
96  if ((y1 - y0 + 1) % 2) y0 = Math.max(0, y0 - 1)
97  const w = x1 - x0 + 1
98  const h = y1 - y0 + 1
99  return frames.map(s => {
100    const px: (Rgb | null)[] = []
101    for (let y = y0; y < y0 + h; y++) for (let x = x0; x < x0 + w; x++) px.push(s.px[y * s.w + x] ?? null)
102    return { w, h, px }
103  })
104}
105
106// ── The habitat: sky by the local hour, ground, props, the pet, and what it feels.
107export const HAB_W = 72
108export const HAB_H = 40
109
110function sky(hour: number): [Rgb, Rgb] {
111  if (hour >= 6 && hour < 17) return [0x73c2fb, 0xb5e1ff]
112  if (hour >= 17 && hour < 20) return [0xef7d57, 0xffcd75]
113  return [0x29366f, 0x3b5dc9]
114}
115
116export type Hunt = { round: number; score: number; treat: number; picked: number | null }
117
118export function habitatFrames(p: Pet, now: number, hour: number, hunt?: Hunt | null): Sprite[] {
119  const tucked = (p.asleepUntil ?? 0) > now
120  const [top, bottom] = tucked ? [0x1a1c2c, 0x29366f] as [Rgb, Rgb] : sky(hour)
121  const night = tucked || hour < 6 || hour >= 20
122  const f = face(p, now)
123  const base = blank(HAB_W, HAB_H)
124  for (let y = 0; y < HAB_H; y++) for (let x = 0; x < HAB_W; x++) base[y * HAB_W + x] = y < 14 ? top : y < 28 ? bottom : 0x3e8948
125  // Hills, ground texture.
126  for (let x = 0; x < HAB_W; x++) {
127    const hill = Math.round(26 + Math.sin(x / 9) * 2 + Math.sin(x / 4) * 0.8)
128    for (let y = hill; y < 28; y++) base[y * HAB_W + x] = 0x5aa85e
129    for (let y = 28; y < HAB_H; y++) if ((x * 7 + y * 13) % 17 === 0) base[y * HAB_W + x] = 0x2d6b3a
130    base[28 * HAB_W + x] = 0x2d6b3a
131  }
132  // Sun or moon.
133  const orb = night ? 0xf4f4f4 : 0xffcd75
134  for (let y = -3; y <= 3; y++) for (let x = -3; x <= 3; x++) if (x * x + y * y <= 9) base[(6 + y) * HAB_W + 62 + x] = orb
135  if (night) for (const [x, y] of [[8, 4], [20, 8], [33, 3], [47, 7], [55, 2]] as const) base[y * HAB_W + x] = 0xf4f4f4
136  // Clouds by day.
137  if (!night) for (const [cx, cy] of [[12, 6], [36, 9]] as const) for (let i = -4; i <= 4; i++) for (let j = -1; j <= 1; j++) if (Math.abs(i) + Math.abs(j) * 3 < 6) base[(cy + j) * HAB_W + cx + i] = 0xf4f4f4
138
139  const full = (p.cooldowns.feed ?? 0) > now
140  stamp(base, HAB_W, HAB_H, bowl(full || p.hunger > 70), 10, 31)
141  if ((p.cooldowns.play ?? 0) > now - 10 * 60_000 && p.stats.played > 0) stamp(base, HAB_W, HAB_H, ball(), 56, 32)
142
143  // Messes after meals.
144  for (let i = 0; i < p.mess; i++) stamp(base, HAB_W, HAB_H, mess(), 20 + i * 6, 34 - (i % 2))
145  // The treat hunt: three bushes, one hiding a cookie once a guess is made.
146  if (hunt) {
147    for (let b = 0; b < 3; b++) {
148      const bx = 8 + b * 22
149      stamp(base, HAB_W, HAB_H, bush(), bx, 22)
150      if (hunt.picked !== null && b === hunt.treat) stamp(base, HAB_W, HAB_H, cookie(), bx + 2, 17)
151      if (hunt.picked === b && b !== hunt.treat) for (let i = 0; i < 3; i++) base[(19 + i) * HAB_W + bx + 4 + i] = 0xe24b4a
152    }
153  }
154
155  const frames = petFrames(p, now)
156  const px0 = Math.round(HAB_W / 2 - SIZE / 2)
157  const py0 = HAB_H - frames[0]!.h - 4
158  return frames.map((s, i) => {
159    const px = base.slice()
160    // Shadow under the pet.
161    for (let x = px0 + 6; x < px0 + SIZE - 6; x++) px[(HAB_H - 6) * HAB_W + x] = 0x2d6b3a
162    stamp(px, HAB_W, HAB_H, s, px0, py0)
163    const fx = px0 + SIZE - 2
164    const fy = py0 + 2 - (i % 2)
165    if (f === 'sleep' || f === 'sleepy') stamp(px, HAB_W, HAB_H, zz(), fx + (i % 2), fy - i)
166    else if (f === 'love') stamp(px, HAB_W, HAB_H, heart(), fx, fy - i)
167    else if (f === 'cheer') stamp(px, HAB_W, HAB_H, note(), fx + (i % 2) * 2, fy - i)
168    else if (f === 'evolve') for (const [x, y] of [[px0, py0 + 4 + i], [px0 + SIZE, py0 + 8 - i], [px0 + 4, py0 - i]] as const) stamp(px, HAB_W, HAB_H, star(), x, y)
169    else if (f === 'ouch' || f === 'sick' || f === 'sweat' || f === 'lose') stamp(px, HAB_W, HAB_H, sweat(), px0 + 4, py0 + 6 + (i % 2))
170    else if (f === 'win') for (const [x, y] of [[px0 + 2, py0 + 2 - i], [px0 + SIZE - 4, py0 + 4 - i]] as const) stamp(px, HAB_W, HAB_H, heart(), x, y)
171    else if (f === 'wave') stamp(px, HAB_W, HAB_H, star(), fx + (i % 2), fy)
172    return { w: HAB_W, h: HAB_H, px }
173  })
174}
175
176// ── SVG: each frame defined once, shown in turn with discrete visibility animation.
177const hex = (c: Rgb) => '#' + c.toString(16).padStart(6, '0')
178
179function paths(s: Sprite): string {
180  const byColor = new Map<Rgb, string>()
181  for (let y = 0; y < s.h; y++) {
182    let x = 0
183    while (x < s.w) {
184      const c = s.px[y * s.w + x]
185      if (c === null || c === undefined) {
186        x++
187        continue
188      }
189      let run = 1
190      while (x + run < s.w && s.px[y * s.w + x + run] === c) run++
191      byColor.set(c, (byColor.get(c) ?? '') + `M${x} ${y}h${run}v1h-${run}z`)
192      x += run
193    }
194  }
195  return [...byColor].map(([c, d]) => `<path fill="${hex(c)}" d="${d}"/>`).join('')
196}
197
198export function framesSvg(frames: Sprite[], scale: number, ms = FRAME_MS): string {
199  const s = frames[0]!
200  const n = frames.length
201  const dur = n * ms
202  const times = frames.map((_, i) => (i / n).toFixed(4)).join(';')
203  const layers = frames
204    .map((f, i) => {
205      const values = frames.map((_, j) => (j === i ? 'visible' : 'hidden')).join(';')
206      return `<g visibility="${i === 0 ? 'visible' : 'hidden'}"><animate attributeName="visibility" values="${values}" keyTimes="${times}" dur="${dur}ms" calcMode="discrete" repeatCount="indefinite"/>${paths(f)}</g>`
207    })
208    .join('')
209  return `<svg xmlns="http://www.w3.org/2000/svg" width="${s.w * scale}" height="${s.h * scale}" viewBox="0 0 ${s.w} ${s.h}" shape-rendering="crispEdges">${layers}</svg>`
210}
211
212// ── Terminal cells: two pixels per cell; transparent pixels take the terminal's own colour.
213const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
214const DEFAULT = 0x01000000
215
216function base64(bytes: Uint8Array): string {
217  let out = ''
218  for (let i = 0; i < bytes.length; i += 3) {
219    const n = (bytes[i]! << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
220    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
221    out += i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '='
222    out += i + 2 < bytes.length ? B64[n & 63]! : '='
223  }
224  return out
225}
226
227export function cells(s: Sprite): { columns: number; rows: number; cells: string } {
228  const rows = Math.ceil(s.h / 2)
229  const words = new Uint32Array(s.w * rows * 3)
230  for (let r = 0; r < rows; r++) {
231    for (let x = 0; x < s.w; x++) {
232      const top = s.px[2 * r * s.w + x] ?? null
233      const bottom = 2 * r + 1 < s.h ? (s.px[(2 * r + 1) * s.w + x] ?? null) : null
234      const n = (r * s.w + x) * 3
235      if (top === null && bottom === null) {
236        words[n] = 0x20
237        words[n + 1] = DEFAULT
238        words[n + 2] = DEFAULT
239      } else if (top === null) {
240        words[n] = 0x2584 // lower half block
241        words[n + 1] = bottom!
242        words[n + 2] = DEFAULT
243      } else {
244        words[n] = 0x2580 // upper half block
245        words[n + 1] = top
246        words[n + 2] = bottom ?? DEFAULT
247      }
248    }
249  }
250  return { columns: s.w, rows, cells: base64(new Uint8Array(words.buffer)) }
251}
252
253
hooks/art.ts 396 lines
1// Draws the monster procedurally: a body, its line's features, eyes and mouth for its mood,
2// then an outline and shading. Every stage and line comes out in one consistent style.
3
4import type { Hat, Line, Stage, Variant } from './pet'
5
6export type Rgb = number
7export type Sprite = { readonly w: number; readonly h: number; readonly px: readonly (Rgb | null)[] }
8
9export const INK = 0x1a1c2c
10const WHITE = 0xf4f4f4
11const GOLD = 0xffcd75
12const GOLD_DARK = 0xe8a33d
13const PINK = 0xf4a0b0
14const SILVER = 0x94b0c2
15const RED = 0xe24b4a
16
17type Palette = { base: Rgb; dark: Rgb; light: Rgb }
18
19const SHADOW: Record<Line, Palette> = {
20  forge: { base: 0x8a3a3a, dark: 0x4a1a2a, light: 0xc96a4a },
21  scribe: { base: 0x4a3a6a, dark: 0x2a1a3a, light: 0x7a5aa0 },
22  summoner: { base: 0x2a4a7a, dark: 0x1a2a4a, light: 0x4a7aa0 },
23  wanderer: { base: 0x2a6a4a, dark: 0x1a3a2a, light: 0x5a9a6a },
24}
25
26const PALETTES: Record<Line | 'baby', Palette> = {
27  baby: { base: 0x8fd694, dark: 0x3e8948, light: 0xc9f2b5 },
28  forge: { base: 0xef7d57, dark: 0xb13e53, light: 0xffcd75 },
29  scribe: { base: 0x9b59d0, dark: 0x5d275d, light: 0xd9b8f0 },
30  summoner: { base: 0x41a6f6, dark: 0x3b5dc9, light: 0x73eff7 },
31  wanderer: { base: 0x38b764, dark: 0x257179, light: 0xa7f070 },
32}
33
34export type Eyes = 'open' | 'closed' | 'happy' | 'x' | 'dizzy'
35export type Mouth = 'smile' | 'open' | 'frown' | 'flat' | 'o'
36export type Pose = { eyes: Eyes; mouth: Mouth; squash: boolean; flicker: boolean }
37
38class Grid {
39  px: (Rgb | null)[]
40  constructor(readonly w: number, readonly h: number) {
41    this.px = new Array(w * h).fill(null)
42  }
43  get(x: number, y: number) {
44    return x >= 0 && y >= 0 && x < this.w && y < this.h ? this.px[y * this.w + x]! : null
45  }
46  set(x: number, y: number, c: Rgb | null) {
47    x = Math.round(x)
48    y = Math.round(y)
49    if (x >= 0 && y >= 0 && x < this.w && y < this.h) this.px[y * this.w + x] = c
50  }
51  ellipse(cx: number, cy: number, rx: number, ry: number, c: Rgb) {
52    for (let y = Math.floor(cy - ry); y <= Math.ceil(cy + ry); y++) {
53      for (let x = Math.floor(cx - rx); x <= Math.ceil(cx + rx); x++) {
54        const dx = (x + 0.5 - cx) / rx
55        const dy = (y + 0.5 - cy) / ry
56        if (dx * dx + dy * dy <= 1) this.set(x, y, c)
57      }
58    }
59  }
60  tri(x: number, y: number, h: number, dir: 1 | -1, c: Rgb) {
61    // A small upward spike: h rows, widening by one each row.
62    for (let j = 0; j < h; j++) for (let i = 0; i <= j; i++) this.set(x + dir * i, y + j, c)
63  }
64  outline() {
65    const add: [number, number][] = []
66    for (let y = 0; y < this.h; y++) {
67      for (let x = 0; x < this.w; x++) {
68        if (this.get(x, y) !== null) continue
69        const near = [this.get(x - 1, y), this.get(x + 1, y), this.get(x, y - 1), this.get(x, y + 1)]
70        if (near.some(c => c !== null && c !== INK)) add.push([x, y])
71      }
72    }
73    for (const [x, y] of add) this.set(x, y, INK)
74  }
75  sprite(): Sprite {
76    return { w: this.w, h: this.h, px: this.px }
77  }
78}
79
80export const SIZE = 24
81
82const BODY: Record<Stage, { rx: number; ry: number }> = {
83  egg: { rx: 5, ry: 6.5 },
84  baby: { rx: 4.5, ry: 4 },
85  child: { rx: 5.5, ry: 5 },
86  adult: { rx: 7, ry: 6.5 },
87  ultimate: { rx: 8, ry: 7.5 },
88}
89
90function egg(pose: Pose, cracked: boolean): Sprite {
91  const g = new Grid(SIZE, SIZE)
92  const cx = 12
93  const cy = 14 + (pose.squash ? 0.5 : 0)
94  g.ellipse(cx, cy, 5, 6.5, WHITE)
95  for (let y = 0; y < SIZE; y++) {
96    for (let x = 0; x < SIZE; x++) {
97      if (g.get(x, y) === WHITE && (x - cx) / 5 + (y - cy) / 6.5 > 0.8) g.set(x, y, 0xc2c3c7)
98    }
99  }
100  for (const [x, y] of [[10, 11], [11, 11], [14, 14], [15, 14], [15, 15], [9, 16], [10, 16], [13, 18]] as const) g.set(x, y, GOLD)
101  if (cracked) for (const [x, y] of [[9, 13], [10, 12], [11, 13], [12, 12], [13, 13], [14, 12], [15, 13]] as const) g.set(x, y, INK)
102  g.outline()
103  return g.sprite()
104}
105
106export function monster(stage: Stage, line: Line | null, pose: Pose, opts: { cracked?: boolean; variant?: Variant | null; hat?: Hat | null } = {}): Sprite {
107  if (stage === 'egg') return egg(pose, opts.cracked ?? false)
108  const grown = stage === 'adult' || stage === 'ultimate'
109  const pal = line && grown && opts.variant === 'shadow' ? SHADOW[line] : PALETTES[stage === 'baby' || !line ? 'baby' : line]
110  const g = new Grid(SIZE, SIZE)
111  const { rx, ry: ry0 } = BODY[stage]
112  const ry = pose.squash ? ry0 - 0.6 : ry0
113  const rxs = pose.squash ? rx + 0.5 : rx
114  const floats = line === 'scribe' && stage !== 'baby'
115  const cx = 12
116  const cy = SIZE - 2 - ry - (floats ? 2 : 1)
117  const big = stage === 'adult' || stage === 'ultimate'
118  const top = Math.round(cy - ry)
119
120  // Behind the body.
121  if (line === 'wanderer' && stage !== 'baby') {
122    // Wings: soft white blobs tucked against each side, flapping on the flicker frame.
123    const wy = cy - (pose.flicker ? 1.5 : 0.5)
124    const wr = big ? 2.5 : 2.1
125    g.ellipse(cx - rxs - wr * 0.4, wy, wr, wr * 0.8, WHITE)
126    g.ellipse(cx + rxs + wr * 0.4, wy, wr, wr * 0.8, WHITE)
127    g.set(Math.round(cx - rxs - wr * 0.6), Math.round(wy + 1), SILVER)
128    g.set(Math.round(cx + rxs + wr * 0.6), Math.round(wy + 1), SILVER)
129  }
130  if (line === 'forge' && stage !== 'baby') {
131    const tx = Math.round(cx + rxs)
132    const ty = Math.round(cy + ry * 0.4)
133    g.set(tx + 1, ty, pal.base)
134    g.set(tx + 2, ty - 1, pal.light)
135    g.set(tx + 2, ty - 2 - (pose.flicker ? 1 : 0), GOLD)
136    if (big) {
137      g.set(tx + 3, ty - 2, pal.light)
138      g.set(tx + 3, ty - 3 - (pose.flicker ? 0 : 1), GOLD)
139    }
140  }
141
142  // Body with shading.
143  g.ellipse(cx, cy, rxs, ry, pal.base)
144  for (let y = 0; y < SIZE; y++) {
145    for (let x = 0; x < SIZE; x++) {
146      if (g.get(x, y) !== pal.base) continue
147      const dx = (x + 0.5 - cx) / rxs
148      const dy = (y + 0.5 - cy) / ry
149      if (dx * 0.6 + dy > 0.75) g.set(x, y, pal.dark)
150      else if (dx < -0.25 && dy < -0.35 && dx * dx + dy * dy < 0.75) g.set(x, y, pal.light)
151    }
152  }
153  // Belly.
154  if (stage !== 'baby') g.ellipse(cx, cy + ry * 0.35, rxs * 0.45, ry * 0.35, pal.light)
155
156  // Line features on top.
157  if (line === 'forge' && stage !== 'baby') {
158    const h = big ? 3 : 2
159    g.tri(cx - rxs * 0.5, top - h + 1, h, -1, GOLD)
160    g.tri(cx + rxs * 0.5 - 1, top - h + 1, h, 1, GOLD)
161  } else if (line === 'summoner' && stage !== 'baby') {
162    const h = big ? 4 : 3
163    g.tri(Math.round(cx - rxs + 1), top - h + 2, h, -1, pal.base)
164    g.tri(Math.round(cx + rxs - 2), top - h + 2, h, 1, pal.base)
165  } else if (line === 'scribe' && stage !== 'baby') {
166    // A quill on the head, and a wisp instead of feet.
167    g.set(cx + 2, top - 1, WHITE)
168    g.set(cx + 3, top - 2, WHITE)
169    g.set(cx + 4, top - 3, SILVER)
170    const by = Math.round(cy + ry)
171    for (let i = -2; i <= 2; i++) g.set(cx + i, by + 1 + (Math.abs(i + (pose.flicker ? 1 : 0)) % 2), pal.base)
172  } else if (line === 'wanderer' && stage !== 'baby') {
173    g.set(cx - 1, top - 1, pal.light)
174    g.set(cx, top - 2, pal.light)
175    g.set(cx + 1, top - 1, pal.light)
176  } else if (stage === 'baby') {
177    g.set(cx, top - 1, pal.base)
178    g.set(cx + 1, top - 2, pal.light)
179  }
180
181  // Feet.
182  if (!floats) {
183    const fy = Math.round(cy + ry)
184    for (const fx of [cx - Math.round(rxs * 0.5), cx + Math.round(rxs * 0.5) - 1]) {
185      g.set(fx, fy, pal.dark)
186      g.set(fx + 1, fy, pal.dark)
187    }
188  }
189
190  // Face.
191  const ey = Math.round(cy - ry * 0.2)
192  const ex = Math.max(2, Math.round(rxs * 0.42))
193  for (const side of [-1, 1] as const) {
194    const x = cx + side * ex - (side < 0 ? 1 : 0)
195    eye(g, x, ey, pose.eyes, stage === 'baby' ? 1 : 2)
196    if (pose.eyes === 'happy' || pose.mouth === 'smile') g.set(x + (side < 0 ? -1 : 2), ey + 2, PINK)
197  }
198  mouth(g, cx, Math.round(cy + ry * 0.3), line === 'wanderer' && stage !== 'baby' ? 'beak' : pose.mouth)
199
200  // Ultimate: an aura of sparkles and a crown (unless it wears a hat).
201  if (stage === 'ultimate') {
202    const spots = pose.flicker ? [[2, 6], [21, 9], [4, 18], [20, 19]] : [[3, 10], [20, 5], [2, 15], [21, 16]]
203    for (const [x, y] of spots) {
204      if (g.get(x!, y!) !== null) continue
205      g.set(x!, y!, GOLD)
206      g.set(x! - 1, y!, 0xfff3c4)
207      g.set(x! + 1, y!, 0xfff3c4)
208      g.set(x!, y! - 1, 0xfff3c4)
209      g.set(x!, y! + 1, 0xfff3c4)
210    }
211    if (!opts.hat) {
212      const y = top - 3
213      for (let i = -2; i <= 2; i++) g.set(cx + i, y + 2, GOLD)
214      for (const i of [-2, 0, 2]) g.set(cx + i, y + 1, GOLD)
215      g.set(cx, y, GOLD_DARK)
216    }
217  }
218  if (grown && opts.variant === 'shadow' && (pose.eyes === 'open')) {
219    for (let y = 0; y < SIZE; y++) for (let x = 0; x < SIZE; x++) if (g.get(x, y) === WHITE && y > top && y < cy) g.set(x, y, RED)
220  }
221
222  g.outline()
223  if (opts.hat) {
224    const h = HAT_SPRITES[opts.hat]
225    const off = HAT_OFFSET[opts.hat]
226    const hx = Math.round(cx - h.w / 2) + off[0]
227    const hy = top - h.h + 1 + off[1]
228    for (let j = 0; j < h.h; j++) for (let i = 0; i < h.w; i++) {
229      const c = h.px[j * h.w + i]
230      if (c !== null && c !== undefined) g.set(hx + i, hy + j, c)
231    }
232  }
233  return g.sprite()
234}
235
236function eye(g: Grid, x: number, y: number, kind: Eyes, size: 1 | 2) {
237  switch (kind) {
238    case 'open':
239      if (size === 1) {
240        g.set(x, y, INK)
241        g.set(x, y + 1, INK)
242      } else {
243        g.set(x, y, WHITE)
244        g.set(x + 1, y, INK)
245        g.set(x, y + 1, INK)
246        g.set(x + 1, y + 1, INK)
247      }
248      break
249    case 'closed':
250      g.set(x, y + 1, INK)
251      if (size === 2) g.set(x + 1, y + 1, INK)
252      break
253    case 'happy':
254      g.set(x, y + 1, INK)
255      if (size === 2) {
256        g.set(x + 1, y, INK)
257        g.set(x + 2, y + 1, INK)
258      }
259      break
260    case 'x':
261      g.set(x, y, INK)
262      g.set(x + 1, y + 1, INK)
263      if (size === 2) {
264        g.set(x + 1, y, INK)
265        g.set(x, y + 1, INK)
266      }
267      break
268    case 'dizzy':
269      g.set(x, y, INK)
270      g.set(x + 1, y, INK)
271      g.set(x + 1, y + 1, INK)
272      break
273  }
274}
275
276function mouth(g: Grid, cx: number, y: number, kind: Mouth | 'beak') {
277  switch (kind) {
278    case 'smile':
279      g.set(cx - 1, y, INK)
280      g.set(cx, y + 1, INK)
281      g.set(cx + 1, y, INK)
282      break
283    case 'open':
284      g.set(cx - 1, y, INK)
285      g.set(cx, y, INK)
286      g.set(cx + 1, y, INK)
287      g.set(cx, y + 1, RED)
288      g.set(cx - 1, y + 1, INK)
289      g.set(cx + 1, y + 1, INK)
290      break
291    case 'frown':
292      g.set(cx - 1, y + 1, INK)
293      g.set(cx, y, INK)
294      g.set(cx + 1, y + 1, INK)
295      break
296    case 'flat':
297      g.set(cx, y, INK)
298      g.set(cx + 1, y, INK)
299      break
300    case 'o':
301      g.set(cx, y, INK)
302      g.set(cx, y + 1, INK)
303      break
304    case 'beak':
305      g.set(cx - 1, y, GOLD)
306      g.set(cx, y, GOLD)
307      g.set(cx, y + 1, GOLD_DARK)
308      break
309  }
310}
311
312// ── Small props for the habitat.
313export function heart(): Sprite {
314  const rows = ['.r.r.', 'rrrrr', 'rrrrr', '.rrr.', '..r..']
315  return fromRows(rows, { r: RED })
316}
317export function note(): Sprite {
318  return fromRows(['..kk', '..k.', '..k.', 'kkk.', 'kk..'], { k: WHITE })
319}
320export function zz(): Sprite {
321  return fromRows(['kkk', '..k', '.k.', 'kkk'], { k: WHITE })
322}
323export function sweat(): Sprite {
324  return fromRows(['.b', 'bb', 'bb'], { b: 0x73eff7 })
325}
326export function star(): Sprite {
327  return fromRows(['.y.', 'yyy', '.y.'], { y: GOLD })
328}
329export function bowl(full: boolean): Sprite {
330  return fromRows([full ? '.oyoy.' : '......', 'kkkkkk', 'kbbbbk', '.kkkk.'], { o: 0xef7d57, y: GOLD, k: INK, b: 0x3b5dc9 })
331}
332export function ball(): Sprite {
333  return fromRows(['.kk.', 'krwk', 'kwrk', '.kk.'], { k: INK, r: RED, w: WHITE })
334}
335
336function fromRows(rows: string[], key: Record<string, Rgb>): Sprite {
337  const w = Math.max(...rows.map(r => r.length))
338  const px: (Rgb | null)[] = []
339  for (const r of rows) for (let x = 0; x < w; x++) px.push(key[r[x] ?? '.'] ?? null)
340  return { w, h: rows.length, px }
341}
342
343/** The smallest box holding every drawn pixel, padded to an even height for half-block cells. */
344export function crop(s: Sprite): Sprite {
345  let x0 = s.w
346  let y0 = s.h
347  let x1 = -1
348  let y1 = -1
349  for (let y = 0; y < s.h; y++) {
350    for (let x = 0; x < s.w; x++) {
351      if (s.px[y * s.w + x] === null) continue
352      x0 = Math.min(x0, x)
353      y0 = Math.min(y0, y)
354      x1 = Math.max(x1, x)
355      y1 = Math.max(y1, y)
356    }
357  }
358  if (x1 < 0) return s
359  if ((y1 - y0 + 1) % 2) y0 = Math.max(0, y0 - 1)
360  const w = x1 - x0 + 1
361  const h = y1 - y0 + 1
362  const px: (Rgb | null)[] = []
363  for (let y = y0; y < y0 + h; y++) for (let x = x0; x < x0 + w; x++) px.push(s.px[y * s.w + x] ?? null)
364  return { w, h, px }
365}
366
367// ── Hats, each with its own outline, set on top of the head.
368const HAT_KEY = { k: INK, y: GOLD, p: PINK, r: RED, b: 0x3b5dc9, B: 0x41a6f6, w: WHITE, v: 0x9b59d0, s: SILVER, g: 0x38b764 }
369const HAT_SPRITES: Record<Hat, Sprite> = {
370  party: fromRows(['...k...', '..kyk..', '..kpk..', '.kypyk.', '.kpypk.', 'kkkkkkk'], HAT_KEY),
371  cap: fromRows(['.kkkkk...', 'kBBBBBk..', 'kBBwBBkkk', 'kbbbbbbbk', 'kkkkkkkkk'], HAT_KEY),
372  headphones: fromRows(['..kkkkkkk..', '.kssssssk..', 'kk.......kk', 'krk.....krk', 'krk.....krk', 'kk.......kk'], HAT_KEY),
373  wizard: fromRows(['.....k..', '....kvk.', '...kvvk.', '...kvyk.', '..kvvvvk', '.kvvvvvvk', 'kyyyyyyyyk', 'kkkkkkkkkk'], HAT_KEY),
374  flower: fromRows(['.k.k.', 'kpkpk', '.kyk.', 'kpkpk', '.k.k.'], HAT_KEY),
375  bow: fromRows(['kk...kk', 'krk.krk', 'krrkrrk', 'krk.krk', 'kk...kk'], HAT_KEY),
376  beanie: fromRows(['...kk...', '..kwwk..', '.kbbbbk.', 'kbBbBbbk', 'kwwwwwwk', 'kkkkkkkk'], HAT_KEY),
377  halo: fromRows(['.kkkkk.', 'kyyyyyk', '.kkkkk.', '.......', '.......'], HAT_KEY),
378}
379const HAT_OFFSET: Record<Hat, [number, number]> = {
380  party: [0, 0], cap: [1, 1], headphones: [0, 4], wizard: [0, 1], flower: [3, 1], bow: [3, 1], beanie: [0, 2], halo: [0, -2],
381}
382
383export function hatIcon(hat: Hat): Sprite {
384  return HAT_SPRITES[hat]
385}
386
387export function mess(): Sprite {
388  return fromRows(['..k..', '.kwk.', 'kwwwk', 'kkkkk'], { k: INK, w: 0x8a5a3a })
389}
390export function bush(): Sprite {
391  return fromRows(['..kkkkk...', '.kgggggkk.', 'kggGgggggk', 'kgggggGggk', 'kGgggggggk', '.kkkkkkkk.'], { k: INK, g: 0x38b764, G: 0xa7f070 })
392}
393export function cookie(): Sprite {
394  return fromRows(['.kkk.', 'kyoyk', 'koyok', 'kyoyk', '.kkk.'], { k: INK, y: GOLD, o: 0x8a5a3a })
395}
396
types/index.d.ts 94 lines
1// The monster mod's state contract: the pet, plain JSON, shared by every session on this machine.
2
3export type Stage = 'egg' | 'baby' | 'child' | 'adult' | 'ultimate'
4
5/** The evolution line, chosen by how you work when it grows into a child. */
6export type Line = 'forge' | 'scribe' | 'summoner' | 'wanderer'
7
8/** Chosen by how well it was cared for when it grows into an adult. */
9export type Variant = 'bright' | 'shadow'
10
11export type Personality = 'cheerful' | 'lazy' | 'curious' | 'grumpy'
12
13/** Treats earned from your work. */
14export type Item = 'cookie' | 'coffee' | 'gem' | 'bug'
15
16export type Hat = 'party' | 'cap' | 'headphones' | 'wizard' | 'flower' | 'bow' | 'beanie' | 'halo'
17
18export type Skill = 'power' | 'wisdom' | 'speed'
19
20export type Mood = {
21  kind: 'cheer' | 'ouch' | 'eat' | 'full' | 'tired' | 'love' | 'evolve' | 'wave' | 'sweat' | 'win' | 'lose' | 'talk'
22  until: number
23}
24
25export type Pet = {
26  name: string
27  born: number
28  updatedAt: number
29  /** The last time anything happened in any session. */
30  lastActive: number
31  personality: Personality
32  stage: Stage
33  line: Line | null
34  variant: Variant | null
35  xp: number
36  /** 0–100: 100 is full. */
37  hunger: number
38  joy: number
39  stress: number
40  energy: number
41  /** Tucked in: asleep until then. */
42  asleepUntil?: number
43  /** Droppings after meals, 0–3. */
44  mess: number
45  skills: Record<Skill, number>
46  items: Record<Item, number>
47  hat: Hat | null
48  hats: Hat[]
49  badges: string[]
50  streak: { days: number; lastDay: string }
51  /** Joy sampled while active, for the adult variant. */
52  care: { sum: number; n: number }
53  traits: { shell: number; code: number; agents: number; web: number }
54  stats: {
55    tokens: number; tests: number; commits: number; pushes: number; tasks: number; errors: number
56    fed: number; meals: number; played: number; pats: number; talks: number
57    games: number; wins: number; trained: number; cleaned: number; agents: number
58    lastTestFailed?: boolean
59  }
60  cooldowns: { feed?: number; play?: number; train?: number; game?: number }
61  mood?: Mood
62  said?: { text: string; at: number }
63  log: { at: number; text: string }[]
64}
65
66/** Something that happened in a session, waiting to be applied to the shared pet. */
67export type Event =
68  | { kind: 'tokens'; n: number; at: number }
69  | { kind: 'tool'; tool: string; command?: string; failed: boolean; ms?: number; at: number }
70  | { kind: 'task'; n: number; at: number }
71  | { kind: 'agent'; at: number }
72  | { kind: 'feed'; at: number }
73  | { kind: 'play'; at: number }
74  | { kind: 'pet'; at: number }
75  | { kind: 'talk'; text: string; at: number }
76  | { kind: 'clean'; at: number }
77  | { kind: 'tuck'; at: number }
78  | { kind: 'use'; item: Item; at: number }
79  | { kind: 'game'; won: boolean; prize?: Item; at: number }
80  | { kind: 'train'; skill: Skill; at: number }
81  | { kind: 'equip'; hat: Hat | null; at: number }
82  | { kind: 'rename'; name: string; at: number }
83
84declare module 'claude-code' {
85  interface PluginState {
86    monster: {
87      pet: Pet | null
88      isHidden: boolean
89      tab: 'home' | 'items' | 'games' | 'style' | 'badges'
90      hunt: { round: number; score: number; treat: number; picked: number | null } | null
91    }
92  }
93}
94