SLOPSHOPPER

coding-pet

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

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · coding-pet
│ ┃ Byte the coding pet ✕ › fix the failing auth test and add an audit log call │ ┃ .--. Byte │ ┃ / oo\ blob · egg · content ⏺ Read(src/auth.ts) │ ┃ \____/ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Level 1 ████░░░░░░░░░░░░░░░░ 11/ ⎿ Added 2 lines, removed 1 line │ ┃ Happiness █████████████░░░░░░░ 67 ⏺ Bash(bun test) │ ┃ Energy ███████████████░░░░░ 77 ⎿ 3 pass, 1 fail │ ┃ Fullness █████████████░░░░░░░ 66 │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Today │ ┃ Prompts +1 XP ✻ Worked for 42s · done 4:20 PM │ ┃ Files read +2 XP │ ┃ Edits +5 XP › /codepet │ ┃ Turns +3 XP ⎿ coding-pet: 🥚 Byte the blob (egg), level 1, hatched today, conte │ ┃ ⎿ coding-pet: XP 11/50 to level 2 (11 total). │ ┃ Lifetime · 11 XP ⎿ coding-pet: Happiness 67, energy 77, fullness 66. │ ┃ Edits 3 ⎿ coding-pet: Today: Prompts +1, Files read +2, Edits +5, Turns +3 │ ┃ Prompts 1 ⎿ coding-pet: Achievements: 👣 First steps. │ ┃ Files read 1 │ ┃ Turns 1 │ ┃ │ ┃ Achievements 1/9 │ ┃ 👣 First steps · Saw its first edit │ ┃ · Committed · Saw its first commit │ ┃ · Green bar · 10 passing test runs ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Byte the coding pet
.--. Byte / oo\ blob · egg · content \____/ Level 1 ████░░░░░░░░░░░░░░░░ 11/50 Happiness █████████████░░░░░░░ 67 Energy ███████████████░░░░░ 77 Fullness █████████████░░░░░░░ 66 Today Prompts +1 XP Files read +2 XP Edits +5 XP Turns +3 XP Lifetime · 11 XP Edits 3 Prompts 1 Files read 1 Turns 1 Achievements 1/9 👣 First steps · Saw its first edit · Committed · Saw its first commit · Green bar · 10 passing test runs · Test centurion · 100 passing test runs · Bookworm · 500 files read · Chatterbox · 100 prompts · Hatched · Reached level 3 · All grown up · Reached level 25 · Well fed · Fed 7 days in a row [ 🍎 apple ][ 🍕 pizza ][ ☕ coffee ][ 🍪 cookie ][ play ][ r
README

Coding Pet 🥚

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 ]

Use

  • The pet above the prompt: its sprite, level, mood, XP bar and happiness ♥, energy ⚡ and fullness 🍖, with feed, play and rest buttons (hotkeys 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.
CommandDoes
/codepetopen the panel (Esc or its close button closes it)
/codepet close, /codepet showput 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 foodswhat each food does
/codepet playplay ball: happier, but it costs energy
/codepet resta 20 minute nap that restores energy; again to wake it
/codepet statseverything about it, as text
/codepet name <name>, `/codepet species <blob\cat\crab>`rename it, or change what it is
/codepet hidehide it completely, silent too: no strip, panel, toasts or status line (it still earns XP); /codepet show brings it back
/codepet reset confirmsay goodbye and start over with a new egg

How it grows

Claude doesXP
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 failsno 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.

Settings

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

Build on it

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

Layout

  • 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 tables
  • hooks/activity.ts: which tool calls, prompts and turns count as what
  • hooks/draw.ts: sprites, bars and text
  • hooks/register.tsx: the band, the pane, /codepet, toasts and $.codingPet
claude plugin test plugins/coding-pet
Source 7 files
hooks/register.tsx 622 lines
1import { 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}
622
hooks/activity.ts 51 lines
1import 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}
51
hooks/art.ts 154 lines
1import 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
154
hooks/core.ts 340 lines
1import 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}
340
hooks/draw.ts 184 lines
1import 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)
184
hooks/registry.ts 159 lines
1import 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}
159
types/index.d.ts 201 lines
1/** 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