A Tamagotchi-style coding companion: it earns XP from edits, reads, passing tests and commits, levels up and evolves, and needs feeding, play and rest. /codepet

A Tamagotchi-style companion for Claude Code, after the VS Code extension Coding Pet. It hatches from an egg, earns XP while you and Claude code, levels up and grows, and needs feeding, play and rest.
/\_/\ Byte · Lv 4 baby · coding with you ⌨ .. [ feed ]
( ò_ó ) XP ██████████░░░░░░░░░░ 212/400 [ play ]
(")(") ♥ ██████░░ ⚡ █████░░░ 🍖 ███░░░░░ [ rest ]
f p r once the band has focus). On a narrow window it shrinks to one line./codepet (or the band's panel button, o) opens its side panel: level progress, stats, today's XP per source, lifetime totals, achievements, and a button per food. In the desktop app the pet there is an animated picture that bounces, blinks and dozes; in a terminal it is the text sprite. Every session starts with the pet put away (no panel, strip, toasts or status line; it still earns XP) until you call it with /codepet or /codepet show.| Command | Does | |||
|---|---|---|---|---|
/codepet | open the panel (Esc or its close button closes it) | |||
/codepet close, /codepet show | put the pet away (panel, strip, toasts and status line), and bring it back | |||
| `/codepet feed [apple\ | pizza\ | coffee\ | cookie]` | feed it (apple when you don't say) |
/codepet foods | what each food does | |||
/codepet play | play ball: happier, but it costs energy | |||
/codepet rest | a 20 minute nap that restores energy; again to wake it | |||
/codepet stats | everything about it, as text | |||
/codepet name <name>, `/codepet species <blob\ | cat\ | crab>` | rename it, or change what it is | |
/codepet hide | hide it completely, silent too: no strip, panel, toasts or status line (it still earns XP); /codepet show brings it back | |||
/codepet reset confirm | say goodbye and start over with a new egg |
| Claude does | XP |
|---|---|
a passing test run (npm test, pytest, cargo test, go test, …) | +15 |
a git commit | +20 |
| an edit (Edit, Write, NotebookEdit) | +5 |
| a file read | +2 |
| a turn finished | +3 |
| you send a prompt | +1 |
| a tool fails | no XP, and it gets a bit sad |
Each kind has a short cooldown so a burst of reads doesn't farm XP. A hungry or worn-out pet learns at half speed, so look after it. Levels take 50 × level^1.5 XP; it hatches at level 3, is a teen at 10 and an adult at 25, each stage looking different.
While you are away it gets hungry and a little sad, and gets its energy back; after a week alone it stops decaying. It sulks, it never dies. It tells you (a toast) when it is hungry, tired or sad, and celebrates level-ups, growing up and achievements.
In the plugin's config: Pet name, Species (blob, cat, crab), Difficulty (chill: half the decay, hardcore: double), Pet above the prompt, Status line entry (🐣 Byte Lv4 ♥82), Say when it needs you, XP for reading files, and Let Claude check on the pet (a pet_status tool, so you can ask Claude how it is).
Coding Pet adds $.codingPet to the engine, so other plugins can reward and extend it. List coding-pet under dependencies in your plugin.json and its types appear in your .claude-plugin/types/.
on('session.start', async ($, e, next) => {
const started = await next(e)
// A new food, an XP rule of your own and an achievement for it.
await $.codingPet.addFood({ id: 'taco', emoji: '🌮', effect: { fullness: 30, happiness: 5 }, cooldownMs: 0 })
await $.codingPet.addActivity({ kind: 'lint', label: 'Lint runs', xp: 4, on: 'tool', tools: ['Bash'], command: '\\beslint\\b', cooldownMs: 10_000 })
await $.codingPet.addAchievement({ id: 'tidy', title: 'Tidy', emoji: '🧹', description: '50 lint runs', when: { total: { kind: 'lint', atLeast: 50 } } })
return started
})
// Grant XP for something only your plugin sees (capped at 50 per grant).
await $.codingPet.award({ kind: 'music', xp: 5, reason: 'coded to music' })
// Read the pet: name, species, stage, level, XP, stats, mood, achievements.
const pet = await $.codingPet.get()
| Method | Does |
|---|---|
get() | the pet now (CodingPetView) |
award({ kind, xp?, reason? }) | grant XP for an activity |
act(action) | feed, play, rest, rename, change species |
addSpecies(species) | a new species: per stage two frames of rows, {f} where the face goes |
addFood(food) | a new food and what it does to the stats |
addActivity(rule) | a new XP rule: a tool call (by tool and shell-command pattern), a failed one, a prompt, a turn, or a named award; added rules match before the built-in ones |
addAchievement(achievement) | earned at a level, a count of an activity, or a feeding streak |
Rows added this way last for the session, so add them from your session.start.
hooks/core.ts: the rules, as one pure reducer reduce(pet, action, now, registry)hooks/registry.ts: species art, foods, XP rules and achievements, as plain tableshooks/activity.ts: which tool calls, prompts and turns count as whathooks/draw.ts: sprites, bars and texthooks/register.tsx: the band, the pane, /codepet, toasts and $.codingPetclaude plugin test plugins/coding-pethooks/register.tsx 622 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type {
5 CodingPet,
6 CodingPetAction,
7 CodingPetEffect,
8 CodingPetMood,
9 CodingPetRegistry,
10 CodingPetState,
11} from '../types'
12import { classify, commandOf } from './activity'
13import { SCENE, petScene } from './art'
14import type { Signal } from './activity'
15import { decay, migrate, moodOf, needsOf, newPet, reduce, stageOf, viewOf, xpToNext } from './core'
16import type { Difficulty, Settings } from './core'
17import { HELP, MOOD_LABEL, bar, bubbleOf, describe, iconOf, isSameView, paneViewOf, speciesOf, spriteRows, statColor, statusLine } from './draw'
18import { BUILT_IN, addTo, emptyRegistry, mergeRegistry } from './registry'
19
20const PLUGIN = 'coding-pet'
21const PANE = 'coding-pet'
22const PET_STATUS = `mcp__${PLUGIN}__pet_status`
23/** Milliseconds per animation frame. */
24const FRAME_MS = 500
25const MINUTE = 60_000
26
27const petAtom = atom({ plugin: 'coding-pet', key: 'pet' } as const, null)
28const extensionsAtom = atom({ plugin: 'coding-pet', key: 'extensions' } as const, emptyRegistry())
29const frameAtom = atom({ plugin: 'coding-pet', key: 'frame' } as const, 0)
30const hiddenAtom = atom({ plugin: 'coding-pet', key: 'isHidden' } as const, false)
31const busyAtom = atom({ plugin: 'coding-pet', key: 'isBusy' } as const, false)
32const paneOpenAtom = atom({ plugin: 'coding-pet', key: 'isPaneOpen' } as const, false)
33const stripClosedAtom = atom({ plugin: 'coding-pet', key: 'isStripClosed' } as const, true)
34const paneViewAtom = atom({ plugin: 'coding-pet', key: 'paneView' } as const, null)
35
36/** What this load of the module holds; a reload starts it over. */
37const live = {
38 settings: { difficulty: 'normal' } as Settings,
39 petName: '',
40 species: 'blob',
41 showBand: true,
42 statusLine: false,
43 xpForReads: true,
44 notifyNeeds: true,
45 modelTool: false,
46 /** The needs already toasted, so each is said once until it is met. */
47 notified: new Set<string>(),
48 /** The window's height in rows, as the band last saw it; the panel asks for half. */
49 windowRows: 0,
50 /** The animation timer; it only runs while the pet shows. */
51 animation: undefined as ReturnType<EngineInterface['clock']['every']> | undefined,
52}
53
54const DIFFICULTIES: readonly Difficulty[] = ['chill', 'normal', 'hardcore']
55
56async function registryOf($: EngineInterface): Promise<CodingPetRegistry> {
57 const base = live.xpForReads
58 ? BUILT_IN
59 : { ...BUILT_IN, activities: BUILT_IN.activities.map(rule => (rule.kind === 'read' ? { ...rule, xp: 0 } : rule)) }
60 return mergeRegistry(base, await read($, extensionsAtom))
61}
62
63/** The words for what an action caused, one line each; XP alone goes unsaid. */
64function linesOf(effects: readonly CodingPetEffect[], name: string): string[] {
65 return effects.flatMap(effect => {
66 switch (effect.kind) {
67 case 'said':
68 case 'refused':
69 return [effect.text]
70 case 'levelUp':
71 return [`🎉 ${name} reached level ${effect.level}!`]
72 case 'evolved':
73 return [effect.stage === 'baby' ? `🐣 ${name} hatched!` : `✨ ${name} grew into ${effect.stage === 'adult' ? 'an adult' : 'a teen'}!`]
74 case 'achievement':
75 return [`🏆 Achievement: ${effect.emoji} ${effect.title}`]
76 case 'xp':
77 return []
78 }
79 })
80}
81
82/** Hidden, or put away (as every session starts): no strip, panel, toasts or status line. */
83async function isQuiet($: EngineInterface): Promise<boolean> {
84 return (await read($, hiddenAtom)) || (await read($, stripClosedAtom))
85}
86
87async function showStatus($: EngineInterface, pet: CodingPetState, mood: CodingPetMood, registry: CodingPetRegistry): Promise<void> {
88 const isShown = live.statusLine && !(await isQuiet($))
89 $.ui.status(isShown ? statusLine(pet, speciesOf(registry, pet.species), mood) : undefined)
90}
91
92/**
93 * Toasts the milestones of an action, and each new need once. A hidden or put
94 * away pet is silent: it still notes its needs, so showing it does not replay them.
95 */
96async function announce($: EngineInterface, pet: CodingPetState, effects: readonly CodingPetEffect[], now: number): Promise<void> {
97 const registry = await registryOf($)
98 const isSilent = await isQuiet($)
99 const milestones = linesOf(effects.filter(one => one.kind === 'levelUp' || one.kind === 'evolved' || one.kind === 'achievement'), pet.name)
100 if (!isSilent) for (const line of milestones) $.ui.toast(line)
101
102 const needs = needsOf(pet)
103 for (const need of [...live.notified]) if (!needs.includes(need as never)) live.notified.delete(need)
104 if (live.notifyNeeds && pet.sleepingUntil <= now) {
105 for (const need of needs) {
106 if (live.notified.has(need)) continue
107 live.notified.add(need)
108 if (isSilent) continue
109 const hint = need === 'hungry' ? '/codepet feed' : need === 'tired' ? '/codepet rest' : '/codepet play'
110 $.ui.toast(`${iconOf(pet, speciesOf(registry, pet.species))} ${pet.name} is ${need}. Try ${hint}.`)
111 }
112 }
113 await showStatus($, pet, moodOf(pet, now, await read($, busyAtom)), registry)
114}
115
116/**
117 * Brings the panel's view up to date, writing it only when what it shows
118 * changed: a write redraws the panel, and a redraw reloads its picture.
119 */
120async function refreshPane($: EngineInterface): Promise<void> {
121 const pet = await read($, petAtom)
122 const view = pet === null ? null : paneViewOf(pet, await registryOf($), moodOf(pet, await $.clock.now(), await read($, busyAtom)))
123 if (!isSameView(await read($, paneViewAtom), view)) await update($, paneViewAtom, () => view)
124}
125
126/** Applies an action to the pet, keeps it, and says what it caused. */
127async function dispatch($: EngineInterface, action: CodingPetAction): Promise<CodingPetEffect[]> {
128 const now = await $.clock.now()
129 const registry = await registryOf($)
130 let effects: CodingPetEffect[] = []
131 let after: CodingPetState | null = null
132 await update($, petAtom, pet => {
133 if (pet === null) return pet
134 const reduced = reduce(pet, action, now, registry, live.settings)
135 effects = reduced.effects
136 after = reduced.pet
137 return reduced.pet
138 })
139 const pet = after as CodingPetState | null
140 if (pet === null) return []
141 await $.store.set('pet', pet)
142 await refreshPane($)
143 await announce($, pet, effects, now)
144
145 return effects
146}
147
148async function signal($: EngineInterface, happened: Signal): Promise<void> {
149 const kind = classify(happened, (await registryOf($)).activities)
150 if (kind !== undefined) await dispatch($, { kind: 'activity', activity: kind })
151}
152
153/** Starts or stops the animation frames. */
154function animate($: EngineInterface, isOn: boolean): void {
155 if (!isOn) {
156 live.animation?.cancel()
157 live.animation = undefined
158 return
159 }
160 live.animation ??= $.clock.every(FRAME_MS, () => void update($, frameAtom, frame => (frame + 1) % 1_000_000).catch(() => undefined))
161}
162
163/** Closes the panel, and lets the strip above the prompt step back in. */
164async function closePane($: EngineInterface): Promise<void> {
165 await $.ui.close({ id: PANE })
166 await update($, paneOpenAtom, () => false)
167}
168
169/**
170 * Puts the pet away (panel, strip, toasts and status line) or brings it back.
171 * Every session starts with it put away, until /codepet calls it.
172 */
173async function setStripClosed($: EngineInterface, isClosed: boolean): Promise<void> {
174 await update($, stripClosedAtom, () => isClosed)
175 if (isClosed) await closePane($)
176 const pet = await read($, petAtom)
177 if (pet !== null) await showStatus($, pet, moodOf(pet, await $.clock.now(), await read($, busyAtom)), await registryOf($))
178}
179
180/** Hides the pet entirely (strip, panel, toasts, status line) or brings it back. */
181async function setHidden($: EngineInterface, isHidden: boolean): Promise<void> {
182 await update($, hiddenAtom, () => isHidden)
183 await $.store.set('isHidden', isHidden)
184 animate($, !isHidden)
185 if (isHidden) await closePane($)
186 const pet = await read($, petAtom)
187 if (pet !== null) await showStatus($, pet, moodOf(pet, await $.clock.now(), await read($, busyAtom)), await registryOf($))
188}
189
190async function openPane($: EngineInterface, focus = true): Promise<void> {
191 const pet = await read($, petAtom)
192 const rows = live.windowRows > 0 ? Math.max(10, Math.floor(live.windowRows / 2)) : 16
193 await $.ui.open({ id: PANE, title: pet === null ? 'Coding pet' : `${pet.name} the coding pet`, ...(focus ? { focus: true } : {}), closeOnEscape: true, rows })
194 // The panel stands in for the strip above the prompt while it is open.
195 await update($, paneOpenAtom, () => true)
196}
197
198/** Presses a care button: the action, and its words as a toast. */
199async function care($: EngineInterface, action: CodingPetAction): Promise<void> {
200 const effects = await dispatch($, action)
201 const pet = await read($, petAtom)
202 const said = linesOf(effects.filter(one => one.kind === 'said' || one.kind === 'refused'), pet?.name ?? '')
203 for (const line of said) $.ui.toast(line)
204}
205
206async function freshPet($: EngineInterface): Promise<CodingPetState> {
207 const registry = await registryOf($)
208 const species = speciesOf(registry, live.species)
209 const name = live.petName !== '' ? live.petName : (species.names[0] ?? 'Byte')
210 return { ...newPet(name, species.id, await $.clock.now()), configured: { name: live.petName, species: live.species } }
211}
212
213async function run($: EngineInterface, args: string): Promise<string> {
214 const [first = '', ...rest] = args.trim().split(/\s+/)
215 const word = first.toLowerCase()
216 const tail = rest.join(' ').trim()
217 const pet = await read($, petAtom)
218 if (pet === null) return 'Your pet is not here yet; try again in a moment.'
219 const say = async (action: CodingPetAction): Promise<string> => linesOf(await dispatch($, action), pet.name).join('\n') || 'Done.'
220
221 switch (word) {
222 case '':
223 await setHidden($, false)
224 await setStripClosed($, false)
225 await openPane($)
226 return describe(pet, await registryOf($), moodOf(pet, await $.clock.now(), await read($, busyAtom)), await $.clock.now())
227 case 'stats':
228 case 'status':
229 case 'info': {
230 const now = await $.clock.now()
231 const fresh = decay(pet, now, live.settings)
232 return describe(fresh, await registryOf($), moodOf(fresh, now, await read($, busyAtom)), now)
233 }
234 case 'feed':
235 case 'eat':
236 return say(tail === '' ? { kind: 'feed' } : { kind: 'feed', food: tail })
237 case 'play':
238 return say({ kind: 'play' })
239 case 'rest':
240 case 'sleep':
241 case 'nap':
242 case 'wake':
243 return say({ kind: 'rest' })
244 case 'name':
245 case 'rename':
246 return say({ kind: 'rename', name: tail })
247 case 'species':
248 return say({ kind: 'species', species: tail })
249 case 'foods':
250 case 'menu':
251 return (await registryOf($)).foods
252 .map(food => `${food.emoji} ${food.id}: ${Object.entries(food.effect).map(([stat, n]) => `${stat} ${n > 0 ? '+' : ''}${n}`).join(', ')}`)
253 .join('\n')
254 case 'close':
255 await setStripClosed($, true)
256 return `${pet.name} is put away: no panel, strip or toasts. It still earns XP. /codepet brings it back.`
257 case 'hide':
258 await setHidden($, true)
259 return `${pet.name} is hidden and silent: no strip, panel, toasts or status line. It still earns XP. /codepet show brings it back.`
260 case 'show': {
261 await setHidden($, false)
262 await setStripClosed($, false)
263 const now = await $.clock.now()
264 const needs = needsOf(decay(pet, now, live.settings))
265 if ((await $.env.get('CLAUDE_CODE_ENTRYPOINT')) === 'claude-desktop') await openPane($, false)
266 return `${pet.name} is back.${needs.length === 0 ? '' : ` It is ${needs.join(' and ')}.`}`
267 }
268 case 'reset': {
269 if (tail.toLowerCase() !== 'confirm') return `This says goodbye to ${pet.name} (level ${pet.level}) and starts over with an egg. Sure? /codepet reset confirm`
270 const egg = await freshPet($)
271 await update($, petAtom, () => egg)
272 await $.store.set('pet', egg)
273 await refreshPane($)
274 return `Goodbye, ${pet.name}. A new egg appears… meet ${egg.name}.`
275 }
276 case 'help':
277 return HELP
278 default:
279 return `Unknown "${first}". ${HELP}`
280 }
281}
282
283/**
284 * `$.codingPet`'s methods. Each call is an event (`codingPet.award`, ...) that
285 * this plugin's own hooks answer with the session's `$`; these bottoms only
286 * answer when no hook of this plugin is loaded.
287 */
288const unavailable = (): Promise<never> => Promise.reject(new Error('coding-pet is not loaded'))
289const api: CodingPet = {
290 get: unavailable,
291 award: unavailable,
292 act: unavailable,
293 addSpecies: unavailable,
294 addFood: unavailable,
295 addActivity: unavailable,
296 addAchievement: unavailable,
297}
298
299export const register: Register = (on, options) => {
300 live.petName = typeof options.petName === 'string' ? options.petName.trim().slice(0, 24) : ''
301 live.species = typeof options.species === 'string' && options.species !== '' ? options.species : 'blob'
302 live.showBand = options.showBand !== false
303 live.statusLine = options.statusLine === true
304 live.xpForReads = options.xpForReads !== false
305 live.notifyNeeds = options.notifyNeeds !== false
306 live.modelTool = options.modelTool === true
307 const difficulty = DIFFICULTIES.find(one => one === options.difficulty)
308 live.settings = { difficulty: difficulty ?? 'normal' }
309
310 on('engine.create', async (_$, e, next) => ({ ...(await next(e)), codingPet: api }))
311
312 on('session.start', async ($, e, next) => {
313 const started = await next(e)
314 await $.command.register({
315 name: 'codepet',
316 description: 'Your coding pet: /codepet opens it, feed, play, rest, stats, name',
317 argumentHint: '[feed [food] | play | rest | stats | name <name> | species <kind> | foods | close | show | hide | reset]',
318 immediate: true,
319 })
320 if (live.modelTool) {
321 await $.tool.register({
322 name: 'pet_status',
323 description:
324 "Says how the user's coding pet is doing: its name, level, XP, mood, happiness, energy, fullness, today's XP and achievements. Use it when the user asks about their pet.",
325 inputSchema: { type: 'object', properties: {} },
326 })
327 }
328
329 const now = await $.clock.now()
330 const registry = await registryOf($)
331 let pet = migrate(await $.store.get('pet'), now) ?? (await freshPet($))
332 // A name or species changed in the settings applies once; /codepet name wins after that.
333 if (live.petName !== '' && live.petName !== pet.configured.name) pet = { ...pet, name: live.petName }
334 if (live.species !== pet.configured.species && registry.species.some(one => one.id === live.species)) pet = { ...pet, species: live.species }
335 pet = { ...pet, configured: { name: live.petName, species: live.species } }
336 pet = reduce(pet, { kind: 'tick' }, now, registry, live.settings).pet
337 await update($, petAtom, () => pet)
338 await $.store.set('pet', pet)
339 await refreshPane($)
340 const isHidden = (await $.store.get('isHidden')) === true
341 await update($, hiddenAtom, () => isHidden)
342 // Every load starts put away, panel closed, even one left open before a
343 // reload: the pet shows only once /codepet calls it.
344 await setStripClosed($, true)
345 await announce($, pet, [], now)
346
347 animate($, !isHidden)
348 $.clock.every(MINUTE, () => void dispatch($, { kind: 'tick' }).catch(() => undefined))
349
350 return started
351 })
352
353 on('codingPet.get', async $ => {
354 const pet = await read($, petAtom)
355 if (pet === null) return { deny: 'coding-pet: no pet yet' }
356 const now = await $.clock.now()
357 return { value: viewOf(decay(pet, now, live.settings), now, await read($, busyAtom)) }
358 })
359 on('codingPet.award', async ($, e) => ({
360 value: await dispatch($, { kind: 'activity', activity: e.kind, xp: e.xp ?? 5, ...(e.reason === undefined ? {} : { reason: e.reason }) }),
361 }))
362 on('codingPet.act', async ($, e) => ({ value: await dispatch($, e) }))
363 on('codingPet.addSpecies', async ($, e) => {
364 await update($, extensionsAtom, ext => addTo(ext, 'species', e))
365 await refreshPane($)
366 return { value: undefined }
367 })
368 on('codingPet.addFood', async ($, e) => {
369 await update($, extensionsAtom, ext => addTo(ext, 'foods', e))
370 await refreshPane($)
371 return { value: undefined }
372 })
373 on('codingPet.addActivity', async ($, e) => {
374 await update($, extensionsAtom, ext => addTo(ext, 'activities', e))
375 await refreshPane($)
376 return { value: undefined }
377 })
378 on('codingPet.addAchievement', async ($, e) => {
379 await update($, extensionsAtom, ext => addTo(ext, 'achievements', e))
380 await refreshPane($)
381 return { value: undefined }
382 })
383
384 on('ui.close', async ($, e, next) => {
385 const closed = await next(e)
386 if (e.id === PANE) await update($, paneOpenAtom, () => false)
387 return closed
388 })
389
390 on('command.run', { command: 'codepet' }, async ($, e) => ({ text: await run($, e.args) }))
391
392 on('prompt.submit', async ($, e, next) => {
393 await update($, busyAtom, () => true)
394 await signal($, { on: 'prompt' }).catch(() => undefined)
395 await refreshPane($)
396 return next(e)
397 })
398
399 on('turn.complete', async ($, e, next) => {
400 const done = await next(e)
401 await update($, busyAtom, () => false)
402 await signal($, { on: 'turn' }).catch(() => undefined)
403 await refreshPane($)
404 return done
405 })
406
407 on('tool.call', { tool: PET_STATUS }, async $ => {
408 const pet = await read($, petAtom)
409 if (pet === null) return { result: 'The pet has not hatched yet.' }
410 const now = await $.clock.now()
411 const fresh = decay(pet, now, live.settings)
412 return { result: describe(fresh, await registryOf($), moodOf(fresh, now, true), now) }
413 })
414
415 on('tool.call', async ($, e, next) => {
416 const ran = await next(e)
417 const tool = String(e.tool)
418 if ('deny' in ran && ran.deny !== undefined) return ran
419 if (tool.startsWith(`mcp__${PLUGIN}__`)) return ran
420 await signal($, { on: 'tool', tool, command: commandOf(e), isError: ran.isError === true }).catch(() => undefined)
421 return ran
422 })
423
424 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
425 live.windowRows = e.viewport?.rows ?? e.props.maxRows * 2
426 if (!live.showBand || e.props.hasSurvey || (await read($, hiddenAtom)) || (await read($, paneOpenAtom)) || (await read($, stripClosedAtom))) return next(e)
427 const pet = await read($, petAtom)
428 if (pet === null) return next(e)
429 const frame = await read($, frameAtom)
430 const isBusy = e.props.isWorking || (await read($, busyAtom))
431 const registry = await registryOf($)
432 const now = await $.clock.now()
433 const mood = moodOf(pet, now, isBusy)
434 const species = speciesOf(registry, pet.species)
435 const below = await next(e)
436 const { Box, Button, Text } = $.ui.resolve(e)
437 const width = e.props.bodyColumns
438 const bubble = bubbleOf(mood, frame)
439 const s = pet.stats
440 const buttons = (
441 <Box flexDirection={width >= 60 ? 'column' : 'row'} marginLeft={1}>
442 <Button key="feed" label="feed" hotkey="f" dimColor onPress={() => care($, { kind: 'feed' })} />
443 <Button key="play" label="play" hotkey="p" dimColor onPress={() => care($, { kind: 'play' })} />
444 <Button key="rest" label={mood === 'sleeping' ? 'wake' : 'rest'} hotkey="r" dimColor onPress={() => care($, { kind: 'rest' })} />
445 <Button key="panel" label="panel" hotkey="o" dimColor onPress={() => openPane($)} />
446 </Box>
447 )
448
449 if (width < 60) {
450 return (
451 <Box flexDirection="column">
452 <Box flexDirection="row">
453 <Text wrap="truncate">
454 <Text color={species.color}>{iconOf(pet, species)} {pet.name}</Text>
455 <Text dimColor> Lv{pet.level} </Text>
456 <Text color={statColor(s.happiness)}>♥{Math.round(s.happiness)} </Text>
457 <Text color={statColor(s.energy)}>⚡{Math.round(s.energy)} </Text>
458 <Text color={statColor(s.fullness)}>🍖{Math.round(s.fullness)} </Text>
459 <Text color="#F28CB1">{bubble}</Text>
460 </Text>
461 {buttons}
462 </Box>
463 {below}
464 </Box>
465 )
466 }
467
468 const sprite = spriteRows(pet, species, mood, frame)
469 const mini = width >= 90 ? 8 : 5
470 const xpBar = Math.max(8, Math.min(30, width - 50))
471 return (
472 <Box flexDirection="column">
473 <Box flexDirection="row">
474 <Box flexDirection="column">
475 {sprite.map(row => (
476 <Text color={species.color}>{row}</Text>
477 ))}
478 </Box>
479 <Box flexDirection="column" marginLeft={2} flexGrow={1}>
480 <Text wrap="truncate">
481 <Text bold>{pet.name}</Text>
482 <Text dimColor> · Lv {pet.level} {stageOf(pet.level)} · {MOOD_LABEL[mood]} </Text>
483 <Text color="#F28CB1">{bubble}</Text>
484 </Text>
485 <Text wrap="truncate">
486 <Text dimColor>XP </Text>
487 <Text color="#9B87F5">{bar(pet.xp, xpToNext(pet.level), xpBar)}</Text>
488 <Text dimColor> {Math.floor(pet.xp)}/{xpToNext(pet.level)}</Text>
489 </Text>
490 <Text wrap="truncate">
491 <Text>♥ </Text>
492 <Text color={statColor(s.happiness)}>{bar(s.happiness, 100, mini)}</Text>
493 <Text> ⚡ </Text>
494 <Text color={statColor(s.energy)}>{bar(s.energy, 100, mini)}</Text>
495 <Text> 🍖 </Text>
496 <Text color={statColor(s.fullness)}>{bar(s.fullness, 100, mini)}</Text>
497 </Text>
498 </Box>
499 {buttons}
500 </Box>
501 {below}
502 </Box>
503 )
504 })
505
506 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
507 const ui = $.ui.resolve(e)
508 const { Box, Button, Text } = ui
509 // The view alone: the pet itself changes on every tool call, the view only
510 // when what it shows does (see refreshPane).
511 const view = await read($, paneViewAtom)
512 if (view === null) return <Text dimColor>Your pet has not hatched yet.</Text>
513 // A surface that draws SVG gets the animated picture, which moves by itself;
514 // only the terminal's text sprite follows the frame counter.
515 const isPicture = e.surface !== 'terminal' && 'Svg' in ui
516 const frame = isPicture ? 0 : await read($, frameAtom)
517 const species = speciesOf(await registryOf($), view.species)
518 const { mood } = view
519 const width = Math.max(30, e.props.bodyColumns)
520 // Beside the picture (about 28 cells) or the sprite there is less room.
521 const long = Math.max(8, Math.min(24, width - (isPicture ? 50 : 36)))
522 const s = view.stats
523 const earned = view.achievements.filter(one => one.isEarned).length
524 const stat = (label: string, value: number) => (
525 <Text wrap="truncate">
526 <Text>{label.padEnd(10)}</Text>
527 <Text color={statColor(value)}>{bar(value, 100, long)}</Text>
528 <Text dimColor> {value}</Text>
529 </Text>
530 )
531
532 return (
533 <Box flexDirection="column">
534 <Box flexDirection="row">
535 {isPicture && 'Svg' in ui ? (
536 // The picture moves by itself and changes only with the mood, stage or
537 // species, so XP and stats never reload it; they are text beside it.
538 <ui.Svg
539 key="pet-scene"
540 source={petScene(species, view.stage, mood)}
541 alt={`${view.name} the ${species.id}, ${MOOD_LABEL[mood]}`}
542 width={SCENE.width}
543 height={SCENE.height}
544 isInteractive
545 />
546 ) : (
547 <Box flexDirection="column">
548 {spriteRows(view, species, mood, frame).map(row => (
549 <Text color={species.color}>{row}</Text>
550 ))}
551 </Box>
552 )}
553 <Box flexDirection="column" marginLeft={2} flexGrow={1}>
554 <Text bold>{view.name}</Text>
555 <Text dimColor>
556 {species.id} · {view.stage} · {MOOD_LABEL[mood]}
557 </Text>
558 {!isPicture && <Text color="#F28CB1">{bubbleOf(mood, frame) || ' '}</Text>}
559 <Text> </Text>
560 <Text wrap="truncate">
561 <Text>{`Level ${view.level}`.padEnd(10)}</Text>
562 <Text color="#9B87F5">{bar(view.xp, view.xpToNext, long)}</Text>
563 <Text dimColor> {view.xp}/{view.xpToNext}</Text>
564 </Text>
565 {stat('Happiness', s.happiness)}
566 {stat('Energy', s.energy)}
567 {stat('Fullness', s.fullness)}
568 </Box>
569 </Box>
570 <Text> </Text>
571 <Text bold>Today</Text>
572 {view.today.length === 0 && <Text dimColor>No XP yet today.</Text>}
573 {view.today.map(one => (
574 <Text wrap="truncate">
575 {one.label.padEnd(16)}
576 <Text color="#6CC24A">+{one.xp} XP</Text>
577 </Text>
578 ))}
579 <Text> </Text>
580 <Text bold>Lifetime · {view.totalXp} XP</Text>
581 {view.totals.length === 0 && <Text dimColor>Nothing yet.</Text>}
582 {view.totals.map(one => (
583 <Text wrap="truncate" dimColor>
584 {one.label.padEnd(16)}
585 {one.count}
586 </Text>
587 ))}
588 <Text> </Text>
589 <Text bold>
590 Achievements {earned}/{view.achievements.length}
591 </Text>
592 {view.achievements.map(one =>
593 one.isEarned ? (
594 <Text wrap="truncate">
595 {one.emoji} {one.title}
596 <Text dimColor> · {one.description}</Text>
597 </Text>
598 ) : (
599 <Text wrap="truncate" dimColor>
600 · {one.title} · {one.description}
601 </Text>
602 ),
603 )}
604 <Text> </Text>
605 <Box flexDirection="row" flexWrap="wrap">
606 {view.foods.map((food, i) => (
607 <Button
608 key={`food:${food.id}`}
609 label={`${food.emoji} ${food.id}`}
610 hotkey={i < 9 ? String(i + 1) : undefined}
611 onPress={() => care($, { kind: 'feed', food: food.id })}
612 />
613 ))}
614 <Button key="play" label="play" hotkey="p" onPress={() => care($, { kind: 'play' })} />
615 <Button key="rest" label={mood === 'sleeping' ? 'wake' : 'rest'} hotkey="r" onPress={() => care($, { kind: 'rest' })} />
616 <Button key="close" label="close" hotkey="x" role="dismiss" dimColor onPress={() => setStripClosed($, true)} />
617 </Box>
618 </Box>
619 )
620 })
621}
622hooks/activity.ts 51 lines1import type { CodingPetActivity } from '../types'
2
3/** Something that happened in the session that may earn XP. */
4export type Signal =
5 | { on: 'tool'; tool: string; command?: string; isError: boolean }
6 | { on: 'prompt' }
7 | { on: 'turn' }
8
9const patterns = new Map<string, RegExp | null>()
10
11/** A rule's command pattern, compiled once; a pattern that does not compile matches nothing. */
12function patternOf(source: string): RegExp | null {
13 let pattern = patterns.get(source)
14 if (pattern === undefined) {
15 try {
16 pattern = new RegExp(source, 'i')
17 } catch {
18 pattern = null
19 }
20 patterns.set(source, pattern)
21 }
22 return pattern
23}
24
25function matches(rule: CodingPetActivity, signal: Signal): boolean {
26 if (signal.on !== 'tool') return rule.on === signal.on
27 if (rule.on !== (signal.isError ? 'toolError' : 'tool')) return false
28 const tools = rule.tools ?? ['*']
29 if (!tools.includes('*') && !tools.includes(signal.tool)) return false
30 if (rule.command === undefined) return true
31 const command = signal.command
32 return command !== undefined && patternOf(rule.command)?.test(command) === true
33}
34
35/**
36 * The activity a signal counts as: the first rule that matches, rules with a
37 * command pattern before those without, so `git commit` in Bash is a commit
38 * however the rules are ordered. Undefined when it earns nothing.
39 */
40export function classify(signal: Signal, rules: readonly CodingPetActivity[]): string | undefined {
41 const ordered = [...rules.filter(rule => rule.command !== undefined), ...rules.filter(rule => rule.command === undefined)]
42 return ordered.find(rule => matches(rule, signal))?.kind
43}
44
45/** The command a shell tool call ran, if it was one. */
46export function commandOf(input: unknown): string | undefined {
47 if (typeof input !== 'object' || input === null) return undefined
48 const command = (input as { command?: unknown }).command
49 return typeof command === 'string' ? command : undefined
50}
51hooks/art.ts 154 lines1import type { CodingPetMood, CodingPetSpecies, CodingPetStage } from '../types'
2import { MOOD_LABEL } from './draw'
3
4/** Escapes text for SVG. */
5const esc = (text: string): string =>
6 text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
7
8const SIZE: Record<CodingPetStage, number> = { egg: 0.8, baby: 0.75, teen: 0.9, adult: 1 }
9
10
11/** Eyes for a mood, centred on (0, 0) of the face; `blink` animates the open ones. */
12function eyes(mood: CodingPetMood, spread: number, ink: string): string {
13 const pair = (draw: (x: number) => string): string => draw(-spread) + draw(spread)
14 const blink = '<animate attributeName="ry" values="5;5;0.6;5" keyTimes="0;0.92;0.96;1" dur="4s" repeatCount="indefinite"/>'
15 switch (mood) {
16 case 'happy':
17 return pair(x => `<path d="M${x - 5} 2 Q${x} -6 ${x + 5} 2" stroke="${ink}" stroke-width="2.5" fill="none" stroke-linecap="round"/>`)
18 case 'sleeping':
19 case 'tired':
20 return pair(x => `<path d="M${x - 5} ${mood === 'tired' ? 1 : 0} L${x + 5} ${mood === 'tired' ? 1 : 0}" stroke="${ink}" stroke-width="2.5" stroke-linecap="round"/>`)
21 case 'sad':
22 return (
23 pair(x => `<ellipse cx="${x}" cy="0" rx="3.5" ry="4.5" fill="${ink}"/>`) +
24 `<path d="M${spread + 2} 6 q2 5 0 8 q-2 -3 0 -8" fill="#6EC6FF"><animate attributeName="opacity" values="1;0;1" dur="2.4s" repeatCount="indefinite"/></path>`
25 )
26 case 'hungry':
27 return `<ellipse cx="${-spread}" cy="0" rx="3.5" ry="4.5" fill="${ink}"/><ellipse cx="${spread}" cy="0" rx="5" ry="6" fill="${ink}"/>`
28 case 'coding':
29 return pair(x => `<ellipse cx="${x}" cy="1" rx="4" ry="3" fill="${ink}"/>`)
30 default:
31 return pair(x => `<ellipse cx="${x}" cy="0" rx="4" ry="5" fill="${ink}">${blink}</ellipse><circle cx="${x + 1.5}" cy="-2" r="1.3" fill="#fff"/>`)
32 }
33}
34
35function mouth(mood: CodingPetMood, ink: string): string {
36 switch (mood) {
37 case 'happy':
38 return `<path d="M-7 10 Q0 18 7 10" stroke="${ink}" stroke-width="2.5" fill="none" stroke-linecap="round"/>`
39 case 'sad':
40 case 'hungry':
41 return `<path d="M-6 15 Q0 9 6 15" stroke="${ink}" stroke-width="2.5" fill="none" stroke-linecap="round"/>`
42 case 'sleeping':
43 return `<ellipse cx="0" cy="12" rx="3" ry="2" fill="${ink}"/>`
44 default:
45 return `<path d="M-5 11 Q0 15 5 11" stroke="${ink}" stroke-width="2.5" fill="none" stroke-linecap="round"/>`
46 }
47}
48
49/** The pet's body for its species, centred on (0, 0), about 120 wide at full size. */
50function body(species: CodingPetSpecies, stage: CodingPetStage, mood: CodingPetMood): string {
51 const ink = '#2B2B2B'
52 const c = species.color
53 const face = (y: number, spread: number): string => `<g transform="translate(0 ${y})">${eyes(mood, spread, ink)}${mouth(mood, ink)}</g>`
54
55 if (stage === 'egg') {
56 return (
57 `<path d="M0 -62 C34 -62 48 -10 48 18 C48 48 26 62 0 62 C-26 62 -48 48 -48 18 C-48 -10 -34 -62 0 -62 Z" fill="#F4EBDD" stroke="#D9C9B0" stroke-width="3"/>` +
58 `<circle cx="-22" cy="30" r="7" fill="${c}" opacity="0.45"/><circle cx="20" cy="-18" r="5" fill="${c}" opacity="0.45"/><circle cx="24" cy="36" r="4" fill="${c}" opacity="0.45"/>` +
59 `<path d="M-30 -2 L-18 -12 L-6 -2 L6 -12 L18 -2 L30 -12" stroke="#BFAE92" stroke-width="2.5" fill="none" stroke-linejoin="round"/>` +
60 `<g transform="translate(0 6)">${eyes(mood, 11, ink)}</g>`
61 )
62 }
63
64 switch (species.id) {
65 case 'cat':
66 return (
67 `<path d="M58 30 q30 -6 26 -40" stroke="${c}" stroke-width="10" fill="none" stroke-linecap="round"><animateTransform attributeName="transform" type="rotate" values="-6 58 30;8 58 30;-6 58 30" dur="1.8s" repeatCount="indefinite"/></path>` +
68 `<ellipse cx="0" cy="26" rx="52" ry="38" fill="${c}"/>` +
69 `<path d="M-46 -40 L-38 -78 L-14 -52 Z M46 -40 L38 -78 L14 -52 Z" fill="${c}"/>` +
70 `<path d="M-38 -48 L-36 -66 L-24 -52 Z M38 -48 L36 -66 L24 -52 Z" fill="#F7B2B7"/>` +
71 `<circle cx="0" cy="-22" r="46" fill="${c}"/>` +
72 `<path d="M-30 -10 L-52 -14 M-30 -4 L-52 -2 M30 -10 L52 -14 M30 -4 L52 -2" stroke="${ink}" stroke-width="1.5" opacity="0.5"/>` +
73 `<ellipse cx="-20" cy="60" rx="12" ry="7" fill="${c}"/><ellipse cx="20" cy="60" rx="12" ry="7" fill="${c}"/>` +
74 face(-24, 16)
75 )
76 case 'crab': {
77 const claw = (side: 1 | -1): string =>
78 `<g transform="translate(${side * 62} -20)"><path d="M0 0 C${side * 10} -14 ${side * 26} -16 ${side * 26} -2 L${side * 12} -2 L${side * 22} 10 C${side * 12} 20 0 14 0 0 Z" fill="${c}">` +
79 `<animateTransform attributeName="transform" type="rotate" values="0;${side * -12};0" dur="1.2s" repeatCount="indefinite"/></path></g>`
80 return (
81 claw(-1) +
82 claw(1) +
83 `<path d="M-48 -4 L-62 -20 M48 -4 L62 -20" stroke="${c}" stroke-width="8" stroke-linecap="round"/>` +
84 `<path d="M-40 30 L-58 52 M-28 36 L-40 60 M40 30 L58 52 M28 36 L40 60" stroke="${c}" stroke-width="7" stroke-linecap="round"/>` +
85 `<ellipse cx="0" cy="10" rx="56" ry="36" fill="${c}"/>` +
86 `<path d="M-12 -24 L-14 -44 M12 -24 L14 -44" stroke="${c}" stroke-width="5" stroke-linecap="round"/>` +
87 `<circle cx="-14" cy="-46" r="9" fill="#fff"/><circle cx="14" cy="-46" r="9" fill="#fff"/>` +
88 `<g transform="translate(0 -46)">${eyes(mood, 14, ink)}</g>` +
89 `<g transform="translate(0 2)">${mouth(mood, ink)}</g>`
90 )
91 }
92 default:
93 return (
94 `<path d="M0 -58 C40 -58 60 -20 60 20 C60 50 40 62 0 62 C-40 62 -60 50 -60 20 C-60 -20 -40 -58 0 -58 Z" fill="${c}">` +
95 `<animate attributeName="d" dur="2.4s" repeatCount="indefinite" values="M0 -58 C40 -58 60 -20 60 20 C60 50 40 62 0 62 C-40 62 -60 50 -60 20 C-60 -20 -40 -58 0 -58 Z;M0 -52 C44 -52 64 -16 64 22 C64 50 42 62 0 62 C-42 62 -64 50 -64 22 C-64 -16 -44 -52 0 -52 Z;M0 -58 C40 -58 60 -20 60 20 C60 50 40 62 0 62 C-40 62 -60 50 -60 20 C-60 -20 -40 -58 0 -58 Z"/></path>` +
96 `<ellipse cx="-24" cy="-30" rx="10" ry="6" fill="#fff" opacity="0.35"/>` +
97 (stage === 'adult' ? `<path d="M-22 -56 L-14 -76 L0 -62 L14 -76 L22 -56 Z" fill="#F5C542" stroke="#C99A1E" stroke-width="2"/>` : '') +
98 face(0, 18)
99 )
100 }
101}
102
103/** A word or glyph beside the pet's head, drifting up. */
104function bubble(mood: CodingPetMood): string {
105 const float = '<animateTransform attributeName="transform" type="translate" values="0 0;0 -8;0 0" dur="2.6s" repeatCount="indefinite"/>'
106 switch (mood) {
107 case 'sleeping':
108 return `<g font-weight="700" fill="#8EA4D2"><text x="64" y="-50" font-size="18">z${float}</text><text x="80" y="-70" font-size="24">Z${float}</text></g>`
109 case 'happy':
110 return `<text x="66" y="-56" font-size="26" fill="#F06292">♥${float}</text>`
111 case 'hungry':
112 return `<g>${float}<rect x="50" y="-90" width="66" height="28" rx="14" fill="#FFF3E0" stroke="#E8A33A"/><text x="83" y="-71" font-size="14" text-anchor="middle" fill="#9A5B00">food?</text></g>`
113 case 'tired':
114 return `<text x="64" y="-56" font-size="16" fill="#8EA4D2">yawn${float}</text>`
115 case 'coding':
116 return `<g transform="translate(-26 44)"><rect x="0" y="0" width="52" height="30" rx="3" fill="#3A3F4B"/><rect x="4" y="4" width="44" height="22" fill="#9FE6A0" opacity="0.85"><animate attributeName="opacity" values="0.85;0.5;0.85" dur="0.9s" repeatCount="indefinite"/></rect><rect x="-6" y="30" width="64" height="5" rx="2" fill="#2B2F38"/></g>`
117 default:
118 return ''
119 }
120}
121
122
123const STYLE =
124 '<style>.sky{fill:#F3F7FB}.ground{fill:#E4EEDD}' +
125 '@media (prefers-color-scheme: dark){.sky{fill:#2E3238}.ground{fill:#2F3A2C}}</style>'
126
127/** The picture's size in CSS pixels. */
128export const SCENE = { width: 200, height: 180 }
129
130/**
131 * The pet itself, animated: it bounces, blinks and dozes by itself. It depends
132 * on the species, the stage and the mood alone, so XP and stats changing never
133 * redraw it (a new source reloads the animation).
134 */
135export function petScene(species: CodingPetSpecies, stage: CodingPetStage, mood: CodingPetMood): string {
136 const { width, height } = SCENE
137 const scale = SIZE[stage] * 0.82
138 const bounce = mood === 'sleeping' ? '0 0;0 2;0 0' : mood === 'coding' ? '0 0;0 -4;0 0' : '0 0;0 -8;0 0'
139 const pace = mood === 'sleeping' ? '3.2s' : mood === 'coding' ? '0.5s' : '1.4s'
140
141 return [
142 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif">`,
143 STYLE,
144 `<rect x="1" y="1" width="${width - 2}" height="${height - 2}" rx="14" class="sky"/>`,
145 `<ellipse cx="${width / 2}" cy="${height - 22}" rx="${width / 2 - 16}" ry="12" class="ground"/>`,
146 `<ellipse cx="${width / 2}" cy="${height - 28}" rx="${50 * scale}" ry="7" fill="#000" opacity="0.12"/>`,
147 `<g transform="translate(${width / 2 - 8} ${height - 32 - 62 * scale})"><g>`,
148 `<animateTransform attributeName="transform" type="translate" values="${bounce}" dur="${pace}" repeatCount="indefinite"/>`,
149 `<g transform="scale(${scale})">${body(species, stage, mood)}${bubble(mood)}</g>`,
150 '</g></g>',
151 '</svg>',
152 ].join('')
153}
154hooks/core.ts 340 lines1import type {
2 CodingPetAction,
3 CodingPetEffect,
4 CodingPetMood,
5 CodingPetRegistry,
6 CodingPetStage,
7 CodingPetStatDelta,
8 CodingPetState,
9 CodingPetStats,
10 CodingPetView,
11} from '../types'
12import { STAGES } from './registry'
13
14export const SCHEMA_VERSION = 1
15const HOUR = 3_600_000
16/** A pet left alone longer than this decays no further. */
17const MAX_DECAY_MS = 7 * 24 * HOUR
18const REST_MS = 20 * 60_000
19/** The most XP one grant from another plugin may give. */
20export const MAX_GRANT = 50
21/** The default cooldown of an activity only another plugin knows. */
22const AWARD_COOLDOWN_MS = 1_000
23
24export type Difficulty = 'chill' | 'normal' | 'hardcore'
25const DECAY: Record<Difficulty, number> = { chill: 0.5, normal: 1, hardcore: 2 }
26
27export type Settings = { difficulty: Difficulty }
28
29// ---------------------------------------------------------------- levels and stages
30
31/** XP it takes to go from `level` to the next. */
32export const xpToNext = (level: number): number => Math.round(50 * Math.pow(level, 1.5))
33
34export function stageOf(level: number): CodingPetStage {
35 let stage: CodingPetStage = 'egg'
36 for (const one of STAGES) if (level >= one.level) stage = one.stage
37 return stage
38}
39
40// ---------------------------------------------------------------- dates
41
42const pad = (n: number): string => String(n).padStart(2, '0')
43
44/** The local date of `ms`, as YYYY-MM-DD. */
45export function dayOf(ms: number): string {
46 const d = new Date(ms)
47 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
48}
49
50/** Days in a row, ending today or yesterday, the pet was fed. */
51export function fedStreak(fedDays: readonly string[], now: number): number {
52 const fed = new Set(fedDays)
53 let day = now
54 if (!fed.has(dayOf(day))) day -= 24 * HOUR
55 let streak = 0
56 while (fed.has(dayOf(day))) {
57 streak += 1
58 day -= 24 * HOUR
59 }
60 return streak
61}
62
63// ---------------------------------------------------------------- the pet
64
65const clamp = (n: number, low = 0, high = 100): number => Math.max(low, Math.min(high, Math.round(n * 10) / 10))
66
67export function newPet(name: string, species: string, now: number): CodingPetState {
68 return {
69 schemaVersion: SCHEMA_VERSION,
70 name,
71 species,
72 level: 1,
73 xp: 0,
74 totalXp: 0,
75 stats: { happiness: 70, energy: 80, fullness: 70 },
76 born: now,
77 lastUpdated: now,
78 sleepingUntil: 0,
79 totals: {},
80 today: { date: dayOf(now), xp: {} },
81 lastGrant: {},
82 fedDays: [],
83 achievements: [],
84 configured: { name, species },
85 }
86}
87
88/** A stored pet brought to this version, or null when it is not a pet. */
89export function migrate(stored: unknown, now: number): CodingPetState | null {
90 if (typeof stored !== 'object' || stored === null) return null
91 const old = stored as Partial<CodingPetState>
92 if (typeof old.name !== 'string' || typeof old.species !== 'string') return null
93 const fresh = newPet(old.name, old.species, now)
94
95 return {
96 ...fresh,
97 ...old,
98 stats: { ...fresh.stats, ...old.stats },
99 today: old.today ?? fresh.today,
100 configured: old.configured ?? fresh.configured,
101 schemaVersion: SCHEMA_VERSION,
102 }
103}
104
105export function applyDelta(stats: CodingPetStats, delta: CodingPetStatDelta | undefined): CodingPetStats {
106 if (delta === undefined) return stats
107 return {
108 happiness: clamp(stats.happiness + (delta.happiness ?? 0)),
109 energy: clamp(stats.energy + (delta.energy ?? 0)),
110 fullness: clamp(stats.fullness + (delta.fullness ?? 0)),
111 }
112}
113
114/**
115 * The pet with the time since `lastUpdated` taken into account: it gets
116 * hungrier, gets its energy back (faster asleep), and its happiness sinks
117 * while it is hungry. It never drops below a floor: a neglected pet sulks,
118 * it does not die.
119 */
120export function decay(pet: CodingPetState, now: number, settings: Settings): CodingPetState {
121 const elapsed = Math.min(MAX_DECAY_MS, Math.max(0, now - pet.lastUpdated))
122 if (elapsed === 0) return pet
123 const hours = elapsed / HOUR
124 const rate = DECAY[settings.difficulty]
125 const asleepMs = Math.max(0, Math.min(now, pet.sleepingUntil) - pet.lastUpdated)
126 const asleep = Math.min(hours, asleepMs / HOUR)
127 const isStarving = pet.stats.fullness < 20
128
129 const stats: CodingPetStats = {
130 fullness: Math.max(0, clamp(pet.stats.fullness - 6 * rate * hours)),
131 energy: clamp(pet.stats.energy + 6 * (hours - asleep) + 60 * asleep),
132 happiness: Math.max(5, clamp(pet.stats.happiness - (isStarving ? 6 : 2) * rate * hours)),
133 }
134
135 return { ...pet, stats, lastUpdated: now, sleepingUntil: pet.sleepingUntil > now ? pet.sleepingUntil : 0 }
136}
137
138export function moodOf(pet: CodingPetState, now: number, isBusy: boolean): CodingPetMood {
139 if (pet.sleepingUntil > now) return 'sleeping'
140 if (isBusy) return 'coding'
141 if (pet.stats.fullness < 20) return 'hungry'
142 if (pet.stats.energy < 25) return 'tired'
143 if (pet.stats.happiness < 25) return 'sad'
144 if (pet.stats.happiness >= 80) return 'happy'
145 return 'ok'
146}
147
148/** What the pet needs, most pressing first. */
149export function needsOf(pet: CodingPetState): ('hungry' | 'tired' | 'sad')[] {
150 const needs: ('hungry' | 'tired' | 'sad')[] = []
151 if (pet.stats.fullness < 20) needs.push('hungry')
152 if (pet.stats.energy < 20) needs.push('tired')
153 if (pet.stats.happiness < 25) needs.push('sad')
154 return needs
155}
156
157export function viewOf(pet: CodingPetState, now: number, isBusy: boolean): CodingPetView {
158 return {
159 name: pet.name,
160 species: pet.species,
161 stage: stageOf(pet.level),
162 level: pet.level,
163 xp: pet.xp,
164 xpToNext: xpToNext(pet.level),
165 totalXp: pet.totalXp,
166 stats: pet.stats,
167 mood: moodOf(pet, now, isBusy),
168 achievements: pet.achievements,
169 }
170}
171
172// ---------------------------------------------------------------- the reducer
173
174export type Reduced = { pet: CodingPetState; effects: CodingPetEffect[] }
175
176const count = (totals: Record<string, number>, kind: string): Record<string, number> => ({ ...totals, [kind]: (totals[kind] ?? 0) + 1 })
177
178/** Adds XP, levelling up (and growing) as often as it takes. */
179function gainXp(pet: CodingPetState, activity: string, xp: number, now: number, effects: CodingPetEffect[]): CodingPetState {
180 if (xp <= 0) return pet
181 const date = dayOf(now)
182 const today = pet.today.date === date ? pet.today : { date, xp: {} }
183 let { level, xp: inLevel } = pet
184 const wasStage = stageOf(level)
185 inLevel += xp
186 while (inLevel >= xpToNext(level)) {
187 inLevel -= xpToNext(level)
188 level += 1
189 effects.push({ kind: 'levelUp', level })
190 }
191 const stage = stageOf(level)
192 if (stage !== wasStage) effects.push({ kind: 'evolved', stage })
193 effects.unshift({ kind: 'xp', activity, xp })
194
195 return {
196 ...pet,
197 level,
198 xp: inLevel,
199 totalXp: pet.totalXp + xp,
200 today: { date, xp: { ...today.xp, [activity]: (today.xp[activity] ?? 0) + xp } },
201 }
202}
203
204/** The achievements newly earned, added to the pet and to `effects`. */
205function award(pet: CodingPetState, registry: CodingPetRegistry, now: number, effects: CodingPetEffect[]): CodingPetState {
206 const earned = registry.achievements.filter(one => {
207 if (pet.achievements.includes(one.id)) return false
208 const { level, total, fedStreak: streak } = one.when
209 if (level !== undefined && pet.level < level) return false
210 if (total !== undefined && (pet.totals[total.kind] ?? 0) < total.atLeast) return false
211 if (streak !== undefined && fedStreak(pet.fedDays, now) < streak) return false
212 return level !== undefined || total !== undefined || streak !== undefined
213 })
214 for (const one of earned) effects.push({ kind: 'achievement', id: one.id, title: one.title, emoji: one.emoji })
215
216 return earned.length === 0 ? pet : { ...pet, achievements: [...pet.achievements, ...earned.map(one => one.id)] }
217}
218
219/** Counts an activity and grants its XP, unless it is cooling down. */
220function activity(
221 pet: CodingPetState,
222 kind: string,
223 given: number | undefined,
224 registry: CodingPetRegistry,
225 now: number,
226 effects: CodingPetEffect[],
227): CodingPetState {
228 const rule = registry.activities.find(one => one.kind === kind)
229 const xp = Math.max(0, Math.min(MAX_GRANT, Math.round(given ?? rule?.xp ?? 0)))
230 const cooldown = rule?.cooldownMs ?? (rule === undefined ? AWARD_COOLDOWN_MS : 0)
231 const isCooling = now - (pet.lastGrant[kind] ?? -Infinity) < cooldown
232 let next: CodingPetState = { ...pet, totals: count(pet.totals, kind), stats: applyDelta(pet.stats, rule?.cost) }
233 if (isCooling || xp === 0) return next
234
235 // A hungry or worn-out pet learns at half speed: look after it.
236 const isNeedy = pet.stats.fullness < 20 || pet.stats.energy < 15
237 next = { ...next, lastGrant: { ...pet.lastGrant, [kind]: now } }
238 return gainXp(next, kind, isNeedy ? Math.max(1, Math.floor(xp / 2)) : xp, now, effects)
239}
240
241/**
242 * One action applied to a pet at `now`: the pet after it and what it caused.
243 * Pure, so every rule of the game is tested without an engine.
244 */
245export function reduce(
246 before: CodingPetState,
247 action: CodingPetAction,
248 now: number,
249 registry: CodingPetRegistry,
250 settings: Settings,
251): Reduced {
252 const effects: CodingPetEffect[] = []
253 let pet = decay(before, now, settings)
254 const isAsleep = pet.sleepingUntil > now
255
256 switch (action.kind) {
257 case 'tick':
258 break
259 case 'activity':
260 pet = activity(pet, action.activity, action.xp, registry, now, effects)
261 break
262 case 'feed': {
263 const food = action.food === undefined ? registry.foods[0] : registry.foods.find(one => one.id === action.food?.toLowerCase())
264 if (food === undefined) {
265 effects.push({ kind: 'refused', text: `There is no "${action.food ?? ''}" in the fridge. Try ${registry.foods.map(one => one.id).join(', ')}.` })
266 break
267 }
268 if (isAsleep) {
269 effects.push({ kind: 'refused', text: `${pet.name} is asleep. Let it rest, or wake it with /codepet rest.` })
270 break
271 }
272 const last = pet.lastGrant[`food:${food.id}`] ?? -Infinity
273 if (now - last < food.cooldownMs) {
274 const minutes = Math.ceil((food.cooldownMs - (now - last)) / 60_000)
275 effects.push({ kind: 'refused', text: `${pet.name} had ${food.emoji} ${food.id} not long ago. Try again in ${minutes} min.` })
276 break
277 }
278 if (pet.stats.fullness >= 95 && (food.effect.fullness ?? 0) > 0) {
279 pet = { ...pet, stats: applyDelta(pet.stats, { happiness: -2 }) }
280 effects.push({ kind: 'refused', text: `${pet.name} is full and pushes the ${food.id} away.` })
281 break
282 }
283 const date = dayOf(now)
284 pet = {
285 ...pet,
286 stats: applyDelta(pet.stats, food.effect),
287 lastGrant: { ...pet.lastGrant, [`food:${food.id}`]: now },
288 fedDays: pet.fedDays.includes(date) ? pet.fedDays : [...pet.fedDays, date].slice(-30),
289 }
290 effects.push({ kind: 'said', text: `${pet.name} munches the ${food.emoji} ${food.id}. Nom!` })
291 pet = activity(pet, 'feed', undefined, registry, now, effects)
292 break
293 }
294 case 'play':
295 if (isAsleep) {
296 effects.push({ kind: 'refused', text: `${pet.name} is asleep. Let it rest, or wake it with /codepet rest.` })
297 break
298 }
299 if (pet.stats.energy < 15) {
300 effects.push({ kind: 'refused', text: `${pet.name} is too tired to play. Let it rest: /codepet rest.` })
301 break
302 }
303 pet = { ...pet, stats: applyDelta(pet.stats, { happiness: 15, energy: -10, fullness: -5 }) }
304 effects.push({ kind: 'said', text: `${pet.name} chases a ball around. ♥` })
305 pet = activity(pet, 'play', undefined, registry, now, effects)
306 break
307 case 'rest':
308 if (isAsleep) {
309 pet = { ...pet, sleepingUntil: 0 }
310 effects.push({ kind: 'said', text: `${pet.name} wakes up and stretches.` })
311 } else {
312 pet = { ...pet, sleepingUntil: now + REST_MS }
313 effects.push({ kind: 'said', text: `${pet.name} curls up for a ${REST_MS / 60_000} minute nap. zZ` })
314 }
315 break
316 case 'rename': {
317 const name = action.name.trim().slice(0, 24)
318 if (name === '') {
319 effects.push({ kind: 'refused', text: 'Give it a name: /codepet name <name>.' })
320 break
321 }
322 effects.push({ kind: 'said', text: `${pet.name} is now called ${name}.` })
323 pet = { ...pet, name }
324 break
325 }
326 case 'species': {
327 const species = registry.species.find(one => one.id === action.species.trim().toLowerCase())
328 if (species === undefined) {
329 effects.push({ kind: 'refused', text: `Pick one of: ${registry.species.map(one => one.id).join(', ')}.` })
330 break
331 }
332 pet = { ...pet, species: species.id }
333 effects.push({ kind: 'said', text: `${pet.name} is a ${species.id} now. ${species.emoji}` })
334 break
335 }
336 }
337
338 return { pet: award(pet, registry, now, effects), effects }
339}
340hooks/draw.ts 184 lines1import type { CodingPetMood, CodingPetPaneView, CodingPetRegistry, CodingPetSpecies, CodingPetStage, CodingPetState } from '../types'
2import { fedStreak, stageOf, xpToNext } from './core'
3import { EGG } from './registry'
4
5const FACES: Record<CodingPetMood, string> = {
6 ok: 'o.o',
7 happy: '^.^',
8 coding: 'ò_ó',
9 sleeping: '-.-',
10 hungry: 'o.O',
11 tired: 'u.u',
12 sad: ';.;',
13}
14/** The egg shows two eyes peeking out. */
15const EGG_FACES: Record<CodingPetMood, string> = {
16 ok: 'oo',
17 happy: '^^',
18 coding: '><',
19 sleeping: '--',
20 hungry: 'oO',
21 tired: 'uu',
22 sad: ';;',
23}
24
25/** A word or glyph beside the pet's head. */
26export const BUBBLES: Record<CodingPetMood, string[]> = {
27 ok: [''],
28 happy: ['♥', ' ♥'],
29 coding: ['⌨', '⌨ .', '⌨ ..', '⌨ ...'],
30 sleeping: ['z', 'zZ', 'zZz'],
31 hungry: ['food?', 'food?!'],
32 tired: ['yawn', ''],
33 sad: ['...', ''],
34}
35
36export const MOOD_LABEL: Record<CodingPetMood, string> = {
37 ok: 'content',
38 happy: 'happy',
39 coding: 'coding with you',
40 sleeping: 'asleep',
41 hungry: 'hungry',
42 tired: 'tired',
43 sad: 'sad',
44}
45
46const STAGE_EMOJI: Record<CodingPetStage, string> = { egg: '🥚', baby: '🐣', teen: '', adult: '' }
47
48const cells = (text: string): number => Array.from(text).length
49
50export function speciesOf(registry: CodingPetRegistry, id: string): CodingPetSpecies {
51 return registry.species.find(one => one.id === id) ?? registry.species[0] ?? { id, emoji: '?', color: '#999999', names: [id], art: {} }
52}
53
54/** The art for a stage, falling back to the nearest younger stage the species has. */
55function artOf(species: CodingPetSpecies, stage: CodingPetStage): [string[], string[]] {
56 if (stage === 'egg') return EGG
57 const order: CodingPetStage[] = ['adult', 'teen', 'baby']
58 for (const one of order.slice(order.indexOf(stage))) {
59 const art = species.art[one]
60 if (art !== undefined) return art
61 }
62 return EGG
63}
64
65/**
66 * The pet's rows for this frame, all one width. It blinks now and then, and
67 * bobs between its two frames: fast while coding, not at all asleep.
68 */
69export function spriteRows(pet: Pick<CodingPetState, 'level'>, species: CodingPetSpecies, mood: CodingPetMood, frame: number): string[] {
70 const stage = stageOf(pet.level)
71 const frames = artOf(species, stage)
72 const isBlink = mood !== 'sleeping' && frame % 13 === 12
73 const faces = stage === 'egg' ? EGG_FACES : FACES
74 const face = isBlink ? (stage === 'egg' ? '--' : '-.-') : faces[mood]
75 const pace = mood === 'coding' ? 1 : 2
76 const which = mood === 'sleeping' ? 0 : Math.floor(frame / pace) % 2
77 const rows = (which === 0 ? frames[0] : frames[1]).map(row => row.replace('{f}', face))
78 const width = Math.max(...frames.flatMap(one => one.map(row => cells(row.replace('{f}', faces.ok)))))
79
80 return rows.map(row => row + ' '.repeat(Math.max(0, width - cells(row))))
81}
82
83export function bubbleOf(mood: CodingPetMood, frame: number): string {
84 const bubbles = BUBBLES[mood]
85 return bubbles[Math.floor(frame / 2) % bubbles.length] ?? ''
86}
87
88/** A bar of `width` cells, filled to `value` of `max`. */
89export function bar(value: number, max: number, width: number): string {
90 const filled = max <= 0 ? 0 : Math.max(0, Math.min(width, Math.round((value / max) * width)))
91 return '█'.repeat(filled) + '░'.repeat(width - filled)
92}
93
94/** Green, amber or red for a stat. */
95export function statColor(value: number): string {
96 if (value >= 60) return '#6CC24A'
97 if (value >= 25) return '#E8B04A'
98 return '#E5533D'
99}
100
101export function iconOf(pet: CodingPetState, species: CodingPetSpecies): string {
102 const stage = stageOf(pet.level)
103 return STAGE_EMOJI[stage] === '' ? species.emoji : STAGE_EMOJI[stage]
104}
105
106export function statusLine(pet: CodingPetState, species: CodingPetSpecies, mood: CodingPetMood): string {
107 const need = mood === 'hungry' ? ' 🍖?' : mood === 'tired' ? ' 💤?' : mood === 'sad' ? ' 💧' : ''
108 return `${iconOf(pet, species)} ${pet.name} Lv${pet.level} ♥${Math.round(pet.stats.happiness)}${need}`
109}
110
111const ageOf = (pet: CodingPetState, now: number): string => {
112 const days = Math.floor((now - pet.born) / 86_400_000)
113 return days === 0 ? 'hatched today' : days === 1 ? '1 day old' : `${days} days old`
114}
115
116/** The whole pet as text, for `/codepet stats` and the model's tool. */
117export function describe(pet: CodingPetState, registry: CodingPetRegistry, mood: CodingPetMood, now: number): string {
118 const species = speciesOf(registry, pet.species)
119 const stage = stageOf(pet.level)
120 const today = Object.entries(pet.today.xp)
121 .filter(([, xp]) => xp > 0)
122 .map(([kind, xp]) => `${labelOf(registry, kind)} +${xp}`)
123 .join(', ')
124 const earned = registry.achievements.filter(one => pet.achievements.includes(one.id))
125 const s = pet.stats
126
127 return [
128 `${iconOf(pet, species)} ${pet.name} the ${species.id} (${stage}), level ${pet.level}, ${ageOf(pet, now)}, ${MOOD_LABEL[mood]}.`,
129 `XP ${Math.floor(pet.xp)}/${xpToNext(pet.level)} to level ${pet.level + 1} (${Math.floor(pet.totalXp)} total).`,
130 `Happiness ${Math.round(s.happiness)}, energy ${Math.round(s.energy)}, fullness ${Math.round(s.fullness)}.`,
131 `Today: ${today === '' ? 'no XP yet' : today}.`,
132 `Achievements: ${earned.length === 0 ? 'none yet' : earned.map(one => `${one.emoji} ${one.title}`).join(', ')}.`,
133 fedStreak(pet.fedDays, now) > 1 ? `Fed ${fedStreak(pet.fedDays, now)} days in a row.` : '',
134 ]
135 .filter(line => line !== '')
136 .join('\n')
137}
138
139export function labelOf(registry: CodingPetRegistry, kind: string): string {
140 return registry.activities.find(one => one.kind === kind)?.label ?? kind
141}
142
143export const HELP =
144 'Try /codepet (the pane), /codepet feed [apple|pizza|coffee|cookie], /codepet play, /codepet rest, /codepet stats, /codepet name <name>, /codepet species <blob|cat|crab>, /codepet close (puts it away), /codepet show, /codepet hide (silent too), /codepet reset.'
145
146/** What the panel shows for a pet in a mood: numbers rounded as drawn, lists as listed. */
147export function paneViewOf(pet: CodingPetState, registry: CodingPetRegistry, mood: CodingPetMood): CodingPetPaneView {
148 return {
149 name: pet.name,
150 species: speciesOf(registry, pet.species).id,
151 stage: stageOf(pet.level),
152 mood,
153 level: pet.level,
154 xp: Math.floor(pet.xp),
155 xpToNext: xpToNext(pet.level),
156 totalXp: Math.floor(pet.totalXp),
157 stats: {
158 happiness: Math.round(pet.stats.happiness),
159 energy: Math.round(pet.stats.energy),
160 fullness: Math.round(pet.stats.fullness),
161 },
162 today: Object.entries(pet.today.xp)
163 .filter(([, xp]) => xp > 0)
164 .map(([kind, xp]) => ({ label: labelOf(registry, kind), xp })),
165 totals: Object.entries(pet.totals)
166 .filter(([kind]) => kind !== 'toolFail')
167 .sort(([, a], [, b]) => b - a)
168 .slice(0, 8)
169 .map(([kind, count]) => ({ label: labelOf(registry, kind), count })),
170 achievements: registry.achievements.map(one => ({
171 id: one.id,
172 title: one.title,
173 emoji: one.emoji,
174 description: one.description,
175 isEarned: pet.achievements.includes(one.id),
176 })),
177 foods: registry.foods.map(food => ({ id: food.id, emoji: food.emoji })),
178 }
179}
180
181/** Whether two panel views draw the same. */
182export const isSameView = (a: CodingPetPaneView | null, b: CodingPetPaneView | null): boolean =>
183 JSON.stringify(a) === JSON.stringify(b)
184hooks/registry.ts 159 lines1import type {
2 CodingPetAchievement,
3 CodingPetActivity,
4 CodingPetFood,
5 CodingPetRegistry,
6 CodingPetSpecies,
7 CodingPetStage,
8} from '../types'
9
10/** Every pet starts as the same egg; `{f}` is its face. */
11export const EGG: [string[], string[]] = [
12 [' .--. ', ' / {f}\\ ', ' \\____/ '],
13 [' .--. ', ' /{f} \\ ', ' \\____/ '],
14]
15
16export const SPECIES: CodingPetSpecies[] = [
17 {
18 id: 'blob',
19 emoji: '🫧',
20 color: '#5BC0EB',
21 names: ['Byte', 'Blip', 'Gloop', 'Pixel'],
22 art: {
23 baby: [
24 [' .-. ', ' ({f}) ', " '-' "],
25 [' ', ' .-. ', ' ({f}) '],
26 ],
27 teen: [
28 [' .---. ', ' ( {f} ) ', " '---' "],
29 [' .---. ', ' ( {f} ) ', " '^-^' "],
30 ],
31 adult: [
32 [' _/\\/\\_ ', ' ( {f} ) ', " '-----' "],
33 [' _/\\/\\_ ', ' ( {f} ) ', " '-----' "],
34 ],
35 },
36 },
37 {
38 id: 'cat',
39 emoji: '🐈',
40 color: '#E8B04A',
41 names: ['Whiskers', 'Miso', 'Luna', 'Mittens'],
42 art: {
43 baby: [
44 [' /\\_/\\ ', '( {f} )', ' (")(")'],
45 [' /\\_/\\ ', '( {f} )', ' (") (")'],
46 ],
47 teen: [
48 [' /\\_/\\ ', '( {f} ) ', ' (")(")~'],
49 [' /\\_/\\ ', '( {f} ) ', ' (")(")_'],
50 ],
51 adult: [
52 [' /\\_/\\ ', '( {f} )___', ' (")(")__)~'],
53 [' /\\_/\\ ', '( {f} )___', ' (")(")__)_'],
54 ],
55 },
56 },
57 {
58 id: 'crab',
59 emoji: '🦀',
60 color: '#E5533D',
61 names: ['Ferris', 'Pinchy', 'Clacks', 'Clawd'],
62 art: {
63 baby: [
64 [' , , ', '({f})', ' / \\ '],
65 [' , , ', '({f})', ' \\ / '],
66 ],
67 teen: [
68 ['(\\/) (\\/)', ' \\({f})/ ', ' / \\ '],
69 ['(\\/) (\\/)', ' \\({f})/ ', ' \\ / '],
70 ],
71 adult: [
72 ['(\\/)_____(\\/)', ' \\( {f} )/ ', ' // || \\\\'],
73 ['(\\/)_____(\\/)', ' \\( {f} )/ ', ' \\\\ || //'],
74 ],
75 },
76 },
77]
78
79/** The level each stage starts at, in order. */
80export const STAGES: { stage: CodingPetStage; level: number }[] = [
81 { stage: 'egg', level: 1 },
82 { stage: 'baby', level: 3 },
83 { stage: 'teen', level: 10 },
84 { stage: 'adult', level: 25 },
85]
86
87const MINUTE = 60_000
88
89export const FOODS: CodingPetFood[] = [
90 { id: 'apple', emoji: '🍎', effect: { fullness: 25, happiness: 3 }, cooldownMs: 0 },
91 { id: 'pizza', emoji: '🍕', effect: { fullness: 45, happiness: 8, energy: -5 }, cooldownMs: 30 * MINUTE },
92 { id: 'coffee', emoji: '☕', effect: { energy: 30, fullness: 5, happiness: 2 }, cooldownMs: 20 * MINUTE },
93 { id: 'cookie', emoji: '🍪', effect: { happiness: 15, fullness: 10 }, cooldownMs: 10 * MINUTE },
94]
95
96/** Shell commands that run a test suite. */
97export const TEST_COMMAND =
98 '(^|[\\s;&|(])((npm|pnpm|yarn|bun)( run)? test|npx (jest|vitest|mocha|playwright test)|jest|vitest|mocha|pytest|python -m (pytest|unittest)|go test|cargo test|dotnet test|mvn( -\\S+)* test|gradlew? test|phpunit|rspec|mix test|deno test|ctest|claude plugin test)\\b'
99/** Shell commands that make a git commit. */
100export const COMMIT_COMMAND = '(^|[\\s;&|(])git(\\s+-\\S+(\\s+[^-\\s]\\S*)?)*\\s+commit\\b'
101
102export const ACTIVITIES: CodingPetActivity[] = [
103 { kind: 'commit', label: 'Commits', xp: 20, on: 'tool', tools: ['Bash', 'PowerShell'], command: COMMIT_COMMAND, cost: { energy: -2 }, cooldownMs: 10_000 },
104 { kind: 'test', label: 'Passing tests', xp: 15, on: 'tool', tools: ['Bash', 'PowerShell'], command: TEST_COMMAND, cost: { energy: -2, happiness: 2 }, cooldownMs: 10_000 },
105 { kind: 'edit', label: 'Edits', xp: 5, on: 'tool', tools: ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'], cost: { energy: -1, fullness: -1 }, cooldownMs: 2_000 },
106 { kind: 'read', label: 'Files read', xp: 2, on: 'tool', tools: ['Read'], cost: {}, cooldownMs: 5_000 },
107 { kind: 'prompt', label: 'Prompts', xp: 1, on: 'prompt', cost: {} },
108 { kind: 'turn', label: 'Turns', xp: 3, on: 'turn', cost: { fullness: -1 } },
109 { kind: 'toolFail', label: 'Failed tools', xp: 0, on: 'toolError', tools: ['*'], cost: { happiness: -3 } },
110 { kind: 'play', label: 'Play', xp: 2, on: 'award', cost: {} },
111 { kind: 'feed', label: 'Meals', xp: 1, on: 'award', cost: {} },
112]
113
114export const ACHIEVEMENTS: CodingPetAchievement[] = [
115 { id: 'first-edit', title: 'First steps', emoji: '👣', description: 'Saw its first edit', when: { total: { kind: 'edit', atLeast: 1 } } },
116 { id: 'first-commit', title: 'Committed', emoji: '📦', description: 'Saw its first commit', when: { total: { kind: 'commit', atLeast: 1 } } },
117 { id: 'green-bar', title: 'Green bar', emoji: '✅', description: '10 passing test runs', when: { total: { kind: 'test', atLeast: 10 } } },
118 { id: 'test-centurion', title: 'Test centurion', emoji: '🛡', description: '100 passing test runs', when: { total: { kind: 'test', atLeast: 100 } } },
119 { id: 'bookworm', title: 'Bookworm', emoji: '📚', description: '500 files read', when: { total: { kind: 'read', atLeast: 500 } } },
120 { id: 'chatterbox', title: 'Chatterbox', emoji: '💬', description: '100 prompts', when: { total: { kind: 'prompt', atLeast: 100 } } },
121 { id: 'hatched', title: 'Hatched', emoji: '🐣', description: 'Reached level 3', when: { level: 3 } },
122 { id: 'grown-up', title: 'All grown up', emoji: '🎓', description: 'Reached level 25', when: { level: 25 } },
123 { id: 'well-fed', title: 'Well fed', emoji: '🍱', description: 'Fed 7 days in a row', when: { fedStreak: 7 } },
124]
125
126export const BUILT_IN: CodingPetRegistry = { species: SPECIES, foods: FOODS, activities: ACTIVITIES, achievements: ACHIEVEMENTS }
127
128export const emptyRegistry = (): CodingPetRegistry => ({ species: [], foods: [], activities: [], achievements: [] })
129
130/** Puts `row` in `rows`, replacing one with the same key. */
131function upsert<T>(rows: readonly T[], row: T, key: (one: T) => string): T[] {
132 const id = key(row)
133 return rows.some(one => key(one) === id) ? rows.map(one => (key(one) === id ? row : one)) : [...rows, row]
134}
135
136/** The built-in tables with `extra`'s rows over them. */
137export function mergeRegistry(base: CodingPetRegistry, extra: CodingPetRegistry): CodingPetRegistry {
138 let { species, foods, activities, achievements } = base
139 for (const one of extra.species) species = upsert(species, one, row => row.id)
140 for (const one of extra.foods) foods = upsert(foods, one, row => row.id)
141 // An added activity goes first, so it can claim a signal before the built-in rules.
142 for (const one of extra.activities) activities = [one, ...activities.filter(row => row.kind !== one.kind)]
143 for (const one of extra.achievements) achievements = upsert(achievements, one, row => row.id)
144
145 return { species, foods, activities, achievements }
146}
147
148export function addTo<K extends keyof CodingPetRegistry>(
149 registry: CodingPetRegistry,
150 table: K,
151 row: CodingPetRegistry[K][number],
152): CodingPetRegistry {
153 const key = (one: unknown): string => {
154 const r = one as { id?: string; kind?: string }
155 return r.id ?? r.kind ?? ''
156 }
157 return { ...registry, [table]: upsert(registry[table] as unknown[], row, key) }
158}
159types/index.d.ts 201 lines1/** The three needs a pet has, each 0 to 100; higher is better. */
2export type CodingPetStats = {
3 happiness: number
4 energy: number
5 /** 100 is a full belly, 0 is starving. */
6 fullness: number
7}
8
9/** The growth stages, in order; each looks different. */
10export type CodingPetStage = 'egg' | 'baby' | 'teen' | 'adult'
11
12/** What a pet is doing or feeling, derived from its stats; never stored. */
13export type CodingPetMood = 'sleeping' | 'coding' | 'hungry' | 'tired' | 'sad' | 'happy' | 'ok'
14
15/** The pet as kept across sessions in `$.store`. */
16export type CodingPetState = {
17 schemaVersion: number
18 name: string
19 species: string
20 level: number
21 /** XP inside the current level. */
22 xp: number
23 /** XP ever earned. */
24 totalXp: number
25 stats: CodingPetStats
26 /** Epoch ms of hatching. */
27 born: number
28 /** Epoch ms the stats were last brought up to date. */
29 lastUpdated: number
30 /** Epoch ms the pet wakes from a rest; 0 when awake. */
31 sleepingUntil: number
32 /** How many times each activity or care action happened, ever. */
33 totals: Record<string, number>
34 /** XP earned today, per activity; starts over when the local date changes. */
35 today: { date: string; xp: Record<string, number> }
36 /** Epoch ms each activity last granted XP, for its cooldown. */
37 lastGrant: Record<string, number>
38 /** The local dates (YYYY-MM-DD) the pet was fed, newest last, at most 30. */
39 fedDays: string[]
40 /** Ids of the achievements earned. */
41 achievements: string[]
42 /** The name and species the settings last gave, so a change there applies once. */
43 configured: { name: string; species: string }
44}
45
46/** What happens to a pet. Everything that changes a pet is one of these. */
47export type CodingPetAction =
48 | { kind: 'activity'; activity: string; xp?: number; reason?: string }
49 | { kind: 'feed'; food?: string }
50 | { kind: 'play' }
51 | { kind: 'rest' }
52 | { kind: 'tick' }
53 | { kind: 'rename'; name: string }
54 | { kind: 'species'; species: string }
55
56/** What an action caused, for the person to hear about. */
57export type CodingPetEffect =
58 | { kind: 'xp'; activity: string; xp: number }
59 | { kind: 'levelUp'; level: number }
60 | { kind: 'evolved'; stage: CodingPetStage }
61 | { kind: 'achievement'; id: string; title: string; emoji: string }
62 | { kind: 'said'; text: string }
63 | { kind: 'refused'; text: string }
64
65/** A pet as other plugins see it. */
66export type CodingPetView = {
67 name: string
68 species: string
69 stage: CodingPetStage
70 level: number
71 xp: number
72 xpToNext: number
73 totalXp: number
74 stats: CodingPetStats
75 mood: CodingPetMood
76 achievements: string[]
77}
78
79/**
80 * What the panel shows, and nothing more: numbers as drawn, lists as listed.
81 * The panel reads this alone, and it is written only when it differs, so the
82 * panel redraws (and its picture reloads) only when something visible changes.
83 */
84export type CodingPetPaneView = {
85 name: string
86 species: string
87 stage: CodingPetStage
88 mood: CodingPetMood
89 level: number
90 xp: number
91 xpToNext: number
92 totalXp: number
93 stats: CodingPetStats
94 today: { label: string; xp: number }[]
95 totals: { label: string; count: number }[]
96 achievements: { id: string; title: string; emoji: string; description: string; isEarned: boolean }[]
97 foods: { id: string; emoji: string }[]
98}
99
100/** Changes to the stats, applied as given (negative lowers). */
101export type CodingPetStatDelta = Partial<CodingPetStats>
102
103/** Art for one species: per stage, two frames of rows; `{f}` is the face. */
104export type CodingPetSpecies = {
105 id: string
106 /** Shown in lists and the status line. */
107 emoji: string
108 color: string
109 names: string[]
110 art: Partial<Record<CodingPetStage, [string[], string[]]>>
111}
112
113export type CodingPetFood = {
114 id: string
115 emoji: string
116 /** What it does to the stats. */
117 effect: CodingPetStatDelta
118 /** How long before this food can be given again. */
119 cooldownMs: number
120}
121
122/**
123 * An XP rule: which signal earns it, how much, and what it costs.
124 *
125 * `on` picks the signal: a tool call that succeeded or failed (narrowed by
126 * `tools`, `*` for any, and `command`, a regular expression over a shell
127 * command), a prompt sent, a turn finished, or `award` for XP granted by
128 * name (the care actions and other plugins).
129 */
130export type CodingPetActivity = {
131 kind: string
132 label: string
133 xp: number
134 on: 'tool' | 'toolError' | 'prompt' | 'turn' | 'award'
135 tools?: string[]
136 command?: string
137 cost?: CodingPetStatDelta
138 /** Within this long of its last grant an activity counts but earns nothing. */
139 cooldownMs?: number
140}
141
142/** An achievement, earned the first time its condition holds. */
143export type CodingPetAchievement = {
144 id: string
145 title: string
146 emoji: string
147 description: string
148 when: { level?: number; total?: { kind: string; atLeast: number }; fedStreak?: number }
149}
150
151/** The tables a pet is made from: the built-in rows plus what other plugins added. */
152export type CodingPetRegistry = {
153 species: CodingPetSpecies[]
154 foods: CodingPetFood[]
155 activities: CodingPetActivity[]
156 achievements: CodingPetAchievement[]
157}
158
159/** XP another plugin grants: an activity by kind, with its own amount (capped at 50). */
160export type CodingPetGrant = { kind: string; xp?: number; reason?: string }
161
162/** `$.codingPet`: the pet for other plugins to read, reward and extend. */
163export type CodingPet = {
164 /** The pet now. */
165 get: () => Promise<CodingPetView>
166 /** Grants XP for an activity; an unknown kind earns `xp` under its own name. */
167 award: (grant: CodingPetGrant) => Promise<CodingPetEffect[]>
168 /** Feeds, plays, rests, renames: any action. */
169 act: (action: CodingPetAction) => Promise<CodingPetEffect[]>
170 /** Adds rows to the tables for this session; a row with an id already there replaces it. */
171 addSpecies: (species: CodingPetSpecies) => Promise<void>
172 addFood: (food: CodingPetFood) => Promise<void>
173 addActivity: (activity: CodingPetActivity) => Promise<void>
174 addAchievement: (achievement: CodingPetAchievement) => Promise<void>
175}
176
177declare module 'claude-code' {
178 interface EngineInterface {
179 codingPet: CodingPet
180 }
181
182 interface PluginState {
183 'coding-pet': {
184 pet: CodingPetState | null
185 /** Rows other plugins added this session. */
186 extensions: CodingPetRegistry
187 /** The animation frame counter. */
188 frame: number
189 isHidden: boolean
190 /** A turn is running. */
191 isBusy: boolean
192 /** The panel is open; the strip above the prompt steps aside for it. */
193 isPaneOpen: boolean
194 /** `/codepet close` put the strip above the prompt away too; `/codepet show` brings it back. */
195 isStripClosed: boolean
196 /** What the panel shows; see CodingPetPaneView. */
197 paneView: CodingPetPaneView | null
198 }
199 }
200}
201