Raise a digital monster that grows from your Claude Code work

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.
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.
/pet opens its habitat, with five tabs:/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| Your work | Effect |
|---|---|
| Tokens Claude processes | Food and XP; a ☕ coffee every 50k tokens |
| Passing test runs | Joy, XP and a 🍪 cookie; a 🐛 bug snack when a failing test passes again |
| Git commits and pushes | Joy, XP and a 💎 gem |
| Finished todo items | Joy and XP |
| New subagents | It waves at them |
| Failed or denied commands | Stress; too much makes it sick, and it recovers over time |
| A new day of work | A daily gift and a growing streak, with gems at 3, 7 and 30 days |
| Hours away | It 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:
| Line | When | Looks | Adult (bright / shadow) | Ultimate (bright / shadow) |
|---|---|---|---|---|
| Forge | mostly shell commands | horns, flame tail | Emberkin / Cinderhorn | Solforge / Ashtyrant |
| Scribe | mostly reading and editing code | a floating ghost with a quill | Quillwisp / Inkshade | Lorewraith / Hexscript |
| Summoner | many subagents | big ears | Callpup / Hollowear | Choirlord / Legionmaw |
| Wanderer | lots of web research | wings and a beak | Skylark / Duskwing | Zephyrus / 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.
claude --plugin-dir .
claude plugin validate .
claude plugin test .hooks/register.tsx 466 lines1import { 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}
466hooks/lines.ts 86 lines1// 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}
86hooks/pet.ts 473 lines1// 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}
473hooks/render.ts 253 lines1// 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
253hooks/art.ts 396 lines1// 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}
396types/index.d.ts 94 lines1// 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