A pet that hatches from an egg and grows on the achievements you unlock while coding with Claude Code

Claude Code でコーディングするほど育つペット。卵から生まれ、作業で解除した実績を経験値にして成長し、できることが増えていきます。
A pet for Claude Code that hatches from an egg and grows on the achievements you unlock while coding. Open it with /pet. The UI is in Japanese.
[ホーム] [実績] [できること] [せってい]
╭──────────────────────────────────────╮
│ たま 猫・子ども │
│ (ドット絵) 年齢 3日目 │
│ 誕生日 10/5 │
│ XP 120 / 250 │
│ ──────────────────────────────────── │
│ おなか ■■■□□ きげん ■■■■□ │
│ なかよし ■■□□□ 成長 ■□□□□ →大人 │
│ [ごはん 2/3] [なでる 5/5] [あそぶ 3/3]│
│ ──────────────────────────────────── │
│ いま: ごきげん! │
│ きょうのできごと │
│ 12:03 コミットしてよろこんだ │
╰──────────────────────────────────────╯
Claude Code の中で:
/plugin marketplace add ytskmt14/coding-pet
/plugin install coding-pet@coding-pet
インストール後、新しいセッションで /pet を開いてください。使い方は /pet help で見られます。
| コマンド | 内容 | |
|---|---|---|
/pet | ペインを開く(数字キー 1〜3 でお世話、4〜7 でタブ切り替え) | |
/pet name <名前> | 名前を変える | |
/pet switch <種族> | 種族を変える(成長はそのまま) | |
| `/pet sound on\ | off` | 効果音の切り替え |
| `/pet chatter on\ | off` | AI おしゃべりの切り替え |
/pet preview <段階> <種族> | 見た目だけ試す(/pet preview off で戻る) | |
/pet help | ヘルプ |
plugins/store/ にある coding-pet で始まる JSON ファイルに保存されます。外部には送りません/cost と同じ値)などの件数だけです。プロンプトの本文、コマンド、ファイルのパスは保存も送信もしません(「ありがとう」を言ったかどうかの判定にだけプロンプトを見ます)/pet chatter off で止められます/plugin uninstall coding-pet@coding-pet
/plugin marketplace remove coding-pet
育成データも消したい場合は、設定ディレクトリの plugins/store/ にある coding-pet で始まる JSON ファイルを削除してください。
claude plugin validate plugins/coding-pet
claude plugin test plugins/coding-pet
claude --plugin-dir plugins/coding-pet # ソースから読み込んで試す
plugins/coding-pet/tsconfig.json は、Claude Code が mod を読み込んだときに .claude-plugin/types/ に置く型定義を参照します。一度読み込んだあとに npx tsc -p plugins/coding-pet で型チェックできますnode scripts/gen-sounds.mjs plugins/coding-pet/sounds で作り直せます(依存なし、毎回同じファイルになります)MIT
hooks/register.tsx 875 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CareKind, PaneTab, PetSave } from '../types'
5import { ACHIEVEMENTS, XP_PER_TURN } from './achievements'
6import type { Achievement } from './achievements'
7import {
8 ABILITIES,
9 addDiary,
10 afterMeal,
11 ageDays,
12 CARE,
13 careLeft,
14 clamp,
15 cleanName,
16 currentFullness,
17 describe,
18 drawPet,
19 giveCare,
20 hasAbility,
21 isReadable,
22 HUNGRY,
23 lineFor,
24 loadSave,
25 localDay,
26 moodOf,
27 nextStageXp,
28 rollDay,
29 snacks,
30 SPECIES,
31 SPECIES_IDS,
32 stageIndex,
33 stageOf,
34 STAGES,
35} from './pet'
36import type { LineKind } from './pet'
37import { award, isCommit, isTestRun, isThanks } from './rules'
38import { compose, toCells } from './sprites'
39
40const PANE = 'pet'
41const STORE_KEY = 'save'
42const SPEECH_MS = 60_000
43const CHAT_GAP_MS = 60_000
44// /pet preview may show the chatter sooner, but never more than once in this
45const PREVIEW_CHAT_GAP_MS = 10_000
46// the diary lines the home tab shows, always this many rows tall
47const DIARY_ROWS = 5
48// the home tab's rows besides the picture, and one spare so a wrapped line clips the diary, not the buttons
49const HOME_ROWS = 13 + 1
50// what the store is re-read for, so other sessions' progress shows: every this many ticks (1.5 s each)
51const REFRESH_TICKS = 7
52
53// `shape` names the save's form: a hot reload keeps atom values, so a value written by an
54// older form of PetSave must not reach this code. Change the tag whenever PetSave changes.
55const saveAtom = atom({ plugin: 'coding-pet', key: 'save' } as const, null, { shape: 'save-v3' })
56const unreadable = atom({ plugin: 'coding-pet', key: 'unreadable' } as const, false)
57const notice = atom({ plugin: 'coding-pet', key: 'notice' } as const, null)
58const speech = atom({ plugin: 'coding-pet', key: 'speech' } as const, null)
59const frame = atom({ plugin: 'coding-pet', key: 'frame' } as const, 0)
60const sessionCounted = atom({ plugin: 'coding-pet', key: 'sessionCounted' } as const, false)
61const lastChatAt = atom({ plugin: 'coding-pet', key: 'lastChatAt' } as const, 0)
62const preview = atom({ plugin: 'coding-pet', key: 'preview' } as const, null)
63const tab = atom({ plugin: 'coding-pet', key: 'tab' } as const, 'home' as PaneTab)
64// whether the pet's pane is open: the animation and the store refresh run only then
65const paneOpen = atom({ plugin: 'coding-pet', key: 'paneOpen' } as const, false)
66const seenSessionUsd = atom({ plugin: 'coding-pet', key: 'seenSessionUsd' } as const, null)
67
68const SUBAGENT_TOOLS = new Set(['Agent', 'Task'])
69
70type Change = (save: PetSave, now: number) => PetSave
71
72// Work a hook hands off, as data: run by runJob on the session's $.
73type Job =
74 | { kind: 'change'; change: Change; line?: LineKind; diary?: string }
75 | { kind: 'care'; care: CareKind }
76 | { kind: 'edit'; change: Change }
77 | { kind: 'turn'; isCounted: boolean; isAborted: boolean; eaten: number; durationMs: number }
78
79// Every write to the save goes through this one chain, so writes never land out of order.
80let chain: Promise<void> = Promise.resolve()
81let running = 0
82let ticker: { cancel: () => void } | undefined
83// Jobs waiting for the session's runner, and the runner's wake-up: set by session.start,
84// whose $ the jobs run on (the $ of the dispatch that queued one ends with its hook).
85const pending: Job[] = []
86let wake: (() => void) | undefined
87
88// A job that has not settled in this long lets the queue go on (a $ call that never settles
89// must not stall every later job, nor the commands awaiting theirs).
90const JOB_TIMEOUT_MS = 30_000
91
92const serial = (job: Job, $: EngineInterface) => {
93 const run = chain.then(() => withTimeout($, runJob($, job)))
94 chain = run.catch(() => {})
95 return run
96}
97
98async function withTimeout($: EngineInterface, work: Promise<void>) {
99 let timer: { cancel: () => void } | undefined
100 const expired = new Promise<void>((_, reject) => {
101 timer = $.clock.after(JOB_TIMEOUT_MS, () => reject(new Error('the job took too long')))
102 })
103 try {
104 await Promise.race([work, expired])
105 } finally {
106 timer?.cancel()
107 }
108}
109
110// Work a hook hands off so it can return its result at once.
111// At most this many jobs wait for the runner; past it the oldest go (a runner that never
112// started must not let the queue grow for the module's whole life).
113const PENDING_MAX = 500
114
115const later = (job: Job) => {
116 pending.push(job)
117 if (pending.length > PENDING_MAX) pending.splice(0, pending.length - PENDING_MAX)
118 wake?.()
119}
120
121// For tests: resolves once the queued jobs have run.
122export const settled = () => chain
123
124// The store is the one source of truth, since every session writes it: read it, apply the
125// change, write it back, then mirror it for drawing. A save this version cannot read is never
126// written over, and a failed write throws: the view and the reactions follow only what was saved.
127const commit = async ($: EngineInterface, change: (save: PetSave, now: number) => PetSave) => {
128 const now = await $.clock.now()
129 const raw = await $.store.get(STORE_KEY)
130 if (!isReadable(raw)) {
131 await update($, unreadable, () => true)
132 throw new Error('the stored save is of a form this version cannot read')
133 }
134 const before = loadSave(raw, now)
135 const after = change(before, now)
136 await $.store.set(STORE_KEY, after)
137 await update($, saveAtom, () => after)
138 return { before, after, now }
139}
140
141// Reads the store again into the mirror, so the pane shows other sessions' progress too.
142const refresh = async ($: EngineInterface) => {
143 const now = await $.clock.now()
144 const raw = await $.store.get(STORE_KEY)
145 await update($, unreadable, () => !isReadable(raw))
146 const loaded = loadSave(raw, now)
147 await update($, saveAtom, () => loaded)
148 return loaded
149}
150
151// What the pane draws: the save, or the save seen at the previewed stage.
152const shownSave = async ($: EngineInterface) => {
153 const held = await read($, saveAtom)
154 // checked on every read, so no value of an unexpected form ever reaches a drawing
155 const save = held === null ? null : loadSave(held, await $.clock.now())
156 const p = await read($, preview)
157 return save && p ? { ...save, pet: { ...save.pet, xp: p.xp, species: p.species } } : save
158}
159
160const say = async ($: EngineInterface, text: string) => {
161 const at = await $.clock.now()
162 await update($, speech, () => ({ text, at }))
163}
164
165// The mod's own message (a refusal, a hint), shown without the pet's quotes.
166const tell = async ($: EngineInterface, text: string) => {
167 const at = await $.clock.now()
168 await update($, notice, () => ({ text, at }))
169}
170
171// Not awaited: a sound plays out on its own (audio.play resolves once played).
172const play = ($: EngineInterface, save: PetSave, asset: string) => {
173 if (hasAbility(save, 'sound') && save.settings.sound) void $.audio.play({ asset }).catch(() => {})
174}
175
176// The snack line counts this session's own spend, never another session's.
177const showStatus = async ($: EngineInterface, save: PetSave, isPreview = false) => {
178 const spent = (await read($, seenSessionUsd)) ?? 0
179 $.ui.status(hasAbility(save, 'snack') ? `🍫 うまい棒${snacks(spent)}本分${isPreview ? '(プレビュー)' : ''}` : undefined)
180}
181
182// The pet's reaction to a committed change: toasts for achievements and growth, a line, a sound.
183const react = async ($: EngineInterface, before: PetSave, after: PetSave, earned: Achievement[], line?: LineKind) => {
184 for (const a of earned) $.ui.toast(`🏆 実績解除: ${a.title} (+${a.xp} XP)`)
185 if (stageIndex(after.pet.xp) > stageIndex(before.pet.xp)) {
186 // out of the egg stage, whether the species was picked at random or chosen beforehand
187 const hatched = stageIndex(before.pet.xp) === 0
188 $.ui.toast(
189 hatched
190 ? `🐣 卵がかえった! ${SPECIES[after.pet.species!].label}の${after.pet.name}が生まれた`
191 : `✨ ${after.pet.name}が${stageOf(after.pet.xp).label}に成長した! /pet でできることを見てね`,
192 )
193 await say($, lineFor(hatched ? 'hatch' : 'grow', Math.random()))
194 play($, after, hatched ? 'sounds/hatch.wav' : 'sounds/levelup.wav')
195 } else if (earned.length > 0) {
196 if (hasAbility(after, 'celebrate')) await say($, `「${earned[0]!.title}」おめでとう!`)
197 play($, after, 'sounds/fanfare.wav')
198 } else if (line && hasAbility(after, 'speech')) {
199 await say($, lineFor(line, Math.random()))
200 }
201 if ((await read($, preview)) === null) await showStatus($, after)
202}
203
204// A change that can earn something: commit, award, write the diary, react.
205async function runChange($: EngineInterface, change: Change, line?: LineKind, diary?: string) {
206 let earned: Achievement[] = []
207 const pick = Math.random()
208 const done = await commit($, (s, now) => {
209 const r = award(change(s, now), new Date(now).toISOString(), pick)
210 earned = r.earned
211 const grew = stageIndex(r.save.pet.xp) > stageIndex(s.pet.xp)
212 return addDiary(r.save, [
213 ...(diary ? [{ at: now, text: diary }] : []),
214 ...r.earned.map(a => ({ at: now, text: `実績「${a.title}」を解除した` })),
215 ...(grew
216 ? [{ at: now, text: stageIndex(s.pet.xp) === 0 ? `卵がかえって${SPECIES[r.save.pet.species!].label}が生まれた` : `${stageOf(r.save.pet.xp).label}に成長した` }]
217 : []),
218 ])
219 })
220 await react($, done.before, done.after, earned, line)
221 return done
222}
223
224// Care from the pane's buttons: given, written in the diary, or refused with the reason.
225async function runCare($: EngineInterface, kind: CareKind) {
226 let refused = ''
227 const done = await commit($, (s, now) => {
228 const r = giveCare(s, kind, now)
229 if ('refused' in r) {
230 refused = r.refused
231 return s
232 }
233 return addDiary(r.save, [{ at: now, text: r.text }])
234 })
235 if (refused !== '') await tell($, refused)
236 else if (hasAbility(done.after, 'speech')) await say($, lineFor(kind === 'feed' ? 'success' : 'thanks', Math.random()))
237}
238
239// A finished turn: the meal, the XP, the day, and the spend /cost totals (its growth since
240// last seen is new spend; a resumed session's earlier spend was counted when it was spent).
241async function runTurn($: EngineInterface, job: Extract<Job, { kind: 'turn' }>) {
242 const spent = (await $.session.usage()).cost?.usd
243 const seen = await read($, seenSessionUsd)
244 const added = spent !== undefined && seen !== null ? Math.max(0, spent - seen) : 0
245 const done = await runChange($, (s, now) => {
246 const c = rollDay(s.counters, now)
247 return {
248 ...s,
249 // an interrupted or empty turn earns nothing (but is still counted as interrupted)
250 pet: job.isCounted ? { ...afterMeal(s.pet, now, job.eaten), xp: s.pet.xp + XP_PER_TURN } : s.pet,
251 counters: {
252 ...c,
253 turns: c.turns + (job.isCounted ? 1 : 0),
254 aborted: c.aborted + (job.isAborted ? 1 : 0),
255 longestTurnMs: job.isCounted ? Math.max(c.longestTurnMs, job.durationMs) : c.longestTurnMs,
256 costUsd: c.costUsd + added,
257 maxSessionCostUsd: Math.max(c.maxSessionCostUsd, spent ?? 0),
258 },
259 }
260 })
261 // only once saved, so a failed write does not lose the spend
262 if (spent !== undefined) await update($, seenSessionUsd, () => spent)
263 if (currentFullness(done.after.pet, done.now) < HUNGRY && hasAbility(done.after, 'speech')) await say($, lineFor('hungry', Math.random()))
264 // the model call runs beside the queue, not in it
265 void chat($, done.after)
266}
267
268async function runJob($: EngineInterface, job: Job) {
269 if (job.kind === 'change') await runChange($, job.change, job.line, job.diary)
270 else if (job.kind === 'care') await runCare($, job.care)
271 else if (job.kind === 'edit') await commit($, job.change)
272 else await runTurn($, job)
273}
274
275// Settings, the name, the species: saved as is, no achievements. Commands await it.
276const edit = ($: EngineInterface, change: (save: PetSave) => PetSave) => serial({ kind: 'edit', change: s => change(s) }, $)
277
278// The save for a command or a drawing: this session's mirror, read from the store the first time.
279const load = async ($: EngineInterface) => {
280 const held = await read($, saveAtom)
281 if (held) return loadSave(held, await $.clock.now())
282 return refresh($)
283}
284
285// The pet's own words from a small model, once the pet can chat; a set line when it cannot answer.
286// Only the pet's species and the name the person gave it, its mood and counts go to the model:
287// never code, paths, commands or the person's prompts.
288const chat = async ($: EngineInterface, save: PetSave, gapMs = CHAT_GAP_MS) => {
289 try {
290 if (!hasAbility(save, 'chatter') || !save.settings.chatter || save.pet.species === null) return
291 const now = await $.clock.now()
292 if (now - (await read($, lastChatAt)) < gapMs) return
293 await update($, lastChatAt, () => now)
294 const c = save.counters
295 const prompt = [
296 `あなたは${SPECIES[save.pet.species].label}のペット「${save.pet.name}」です。`,
297 '飼い主はいまAIと一緒にプログラミングをしています。',
298 `いまの気分: ${moodOf(save.pet, now)}。これまで: ${c.turns}ターン、エラー${c.errors}回、コミット${c.commits}回、実績${Object.keys(save.unlocked).length}個。`,
299 '飼い主にかけるひとことを、ペットらしい口調で20文字以内で1つだけ書いてください。かぎかっこは付けないでください。',
300 ].join('\n')
301 const r = await $.model.complete({ model: 'haiku', prompt, maxTokens: 60, effort: 'low', timeoutMs: 8000 })
302 const text = r.isAnswered
303 ? Array.from(r.text.replace(/\s+/g, ' ').trim().replace(/^「|」$/g, ''))
304 .slice(0, 30)
305 .join('')
306 : ''
307 await say($, text || lineFor('idle', Math.random()))
308 } catch {
309 try {
310 await say($, lineFor('idle', Math.random()))
311 } catch {}
312 }
313}
314
315// Opens the pane tall enough for the whole screen (tabs, border, name row, content) and
316// focused, so the number keys work at once.
317const openPane = async ($: EngineInterface) => {
318 const isPreview = (await read($, preview)) !== null
319 await update($, paneOpen, () => true)
320 return $.ui.open({ id: PANE, title: 'Pet', focus: true, rows: contentRows('terminal') + 4 + (isPreview ? 1 : 0) })
321}
322
323// Every tab gets the height of the tallest, so switching never makes the screen jump:
324// home is the picture (9 rows on the terminal, the 4 info rows elsewhere) + HOME_ROWS;
325// the achievements tab is one row each and a title.
326const contentRows = (surface: string) => Math.max((surface === 'terminal' ? 9 : 4) + HOME_ROWS, ACHIEVEMENTS.length + 1)
327
328const parseSpecies = (word: string | undefined) => SPECIES_IDS.find(id => id === word || SPECIES[id].label === word)
329const parseStage = (word: string | undefined) => STAGES.find(st => st.id === word || st.label === word)
330const SPECIES_LIST = SPECIES_IDS.map(id => `${id}(${SPECIES[id].label})`).join(' ')
331// /pet help: built from the stage and ability tables, so it follows them. Achievement
332// conditions stay hidden: finding them is the game.
333export const helpText = () =>
334 [
335 'ペットの育て方',
336 '',
337 'コマンド',
338 ' /pet ペインを開く(お世話・ようす・日記・実績・できること・せってい)',
339 ' /pet name <名前> 名前を変える(12文字まで)',
340 ' /pet sound on|off 効果音を切り替える',
341 ' /pet chatter on|off AIおしゃべりを切り替える',
342 ' /pet switch <種族> 種族を切り替える(成長・実績・名前はそのまま。卵なら生まれる種族を決める)',
343 ' /pet preview <段階> <種族> 見た目だけ試す(どちらか片方でもよい。保存データは変わらない)',
344 ' /pet preview off プレビューを終える',
345 ' /pet help このヘルプ',
346 ` 段階: ${STAGES.map(st => `${st.id}(${st.label})`).join(' ')}`,
347 ` 種族: ${SPECIES_LIST}`,
348 '',
349 '育ち方',
350 ` 実績を解除するとXPが入る。1ターン終えるごとにも${XP_PER_TURN}XP。卵は${STAGES[1]!.xp}XPで孵化し、種族はそのときランダムに決まる(/pet switch で決めておくこともできる)。`,
351 ...STAGES.map(st => {
352 const opened = ABILITIES.filter(ab => ab.stage === st.id).map(ab => ab.title)
353 return ` ${st.label.padEnd(5, ' ')} ${String(st.xp).padStart(3)}XP〜 ${opened.length > 0 ? `${opened.join('、')}が使えるようになる` : ''}`
354 }),
355 '',
356 'お世話(ペインのボタン。XPは入らない)',
357 ...(Object.keys(CARE) as CareKind[]).map(k => ` ${CARE[k].label}: 1日${CARE[k].perDay}回まで`),
358 ' ごはん: おなか+30、きげん少し上がる(おなかいっぱいのときは食べない)',
359 ' なでる: きげんとなかよし度が上がる。卵のうちはこれだけ(あたためる)',
360 ' あそぶ: きげんとなかよし度が大きく上がり、おなかが少し減る',
361 ' ペインにフォーカスがあるとき、数字キー 1〜3 でお世話、4〜7 でタブを切り替えられる。',
362 '',
363 '気分とおなか',
364 ' 気分: ツールが成功すると上がり、エラーや拒否で下がる。実績解除、「ありがとう」、お世話で上がる。',
365 ' おなか: 時間とともに減り、Claudeの出力(トークン)を食べて回復する。',
366 ` おなかが${HUNGRY}未満のままターンを迎えると拗ねて気分が下がる。たくさん食べると機嫌が直る。`,
367 ' いなくなることはない。',
368 '',
369 '実績',
370 ` 全${ACHIEVEMENTS.length}個。名前は /pet で見られるが、解除の条件は解除するまで秘密。名前から推理してみよう。`,
371 ].join('\n')
372
373export const register: Register = on => {
374 on('session.start', async ($, e, next) => {
375 // the runner for queued jobs: a timer on this session's $, so the jobs outlive the hooks that
376 // queue them. Set before anything that can fail, so queued work is never stranded.
377 wake = () =>
378 void $.clock.after(0, () => {
379 for (const job of pending.splice(0)) void serial(job, $).catch(() => {})
380 })
381 const result = await next(e)
382 wake()
383 try {
384 await $.command.register({ name: 'pet', description: 'Your pet (/pet help for everything it does)' })
385 } catch {}
386 try {
387 await load($)
388 } catch {}
389 try {
390 // a hot reload fires session.start again: count the session once, and only one a person sits at
391 // a resumed session's earlier spend was counted when it was spent
392 if ((await read($, seenSessionUsd)) === null) {
393 const usd = (await $.session.usage()).cost?.usd ?? 0
394 await update($, seenSessionUsd, () => usd)
395 }
396 if (e.isInteractive && !(await read($, sessionCounted))) {
397 await update($, sessionCounted, () => true)
398 later({
399 kind: 'change',
400 diary: 'あいにきてくれた',
401 change: (s, now) => ({
402 ...s,
403 counters: {
404 ...rollDay(s.counters, now),
405 sessions: s.counters.sessions + 1,
406 nightOwl: s.counters.nightOwl || new Date(now).getHours() < 4,
407 },
408 }),
409 })
410 }
411 } catch {}
412 try {
413 ticker?.cancel()
414 ticker = e.isInteractive
415 ? $.clock.every(1500, () => {
416 void (async () => {
417 if (!(await read($, paneOpen))) return
418 const f = await update($, frame, n => (n + 1) % (4 * REFRESH_TICKS))
419 if (f % REFRESH_TICKS === 0) await refresh($)
420 })().catch(() => {})
421 })
422 : undefined
423 } catch {}
424
425 return result
426 })
427
428 on('command.run', { command: 'pet' }, async ($, e) => {
429 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
430 const value = rest.join(' ')
431 const toggle = (v: string) => (v === 'on' ? true : v === 'off' ? false : null)
432
433 if (verb === 'help') return { text: helpText() }
434 if (verb === '') {
435 // read again, so the pane opens on what every session has done
436 await refresh($)
437 await openPane($)
438 return { text: 'ペットのペインを開きました。数字キーでお世話とタブの切り替えができます。' }
439 }
440 if (verb === 'name') {
441 const name = cleanName(value)
442 if (name === '') return { text: '使い方: /pet name <名前>(12文字まで)' }
443 await edit($, s => ({ ...s, pet: { ...s.pet, name } }))
444 if (hasAbility(await load($), 'speech')) await say($, `${name}…いい名前!`)
445 return { text: `ペットの名前を「${name}」にしました。` }
446 }
447 if (verb === 'sound' || verb === 'chatter') {
448 const isOn = toggle(value)
449 if (isOn === null) return { text: `使い方: /pet ${verb} on|off` }
450 await edit($, s => ({ ...s, settings: { ...s.settings, [verb]: isOn } }))
451 return { text: `${verb === 'sound' ? '効果音' : 'AIおしゃべり'}を${isOn ? 'オン' : 'オフ'}にしました。` }
452 }
453 if (verb === 'switch') {
454 const species = parseSpecies(value)
455 if (!species) return { text: `使い方: /pet switch <種族>。種族: ${SPECIES_LIST}` }
456 // the store's save, not this session's mirror: another session may have moved on
457 const save = await refresh($)
458 if (save.pet.species === species) return { text: `${save.pet.name}はもう${SPECIES[species].label}です。` }
459 // only the species changes: XP, stage, achievements, mood and name stay
460 await edit($, s => ({ ...s, pet: { ...s.pet, species } }))
461 // a preview would hide the change
462 await update($, preview, () => null)
463 const isEgg = stageIndex(save.pet.xp) === 0
464 if (!isEgg && hasAbility(save, 'speech')) await say($, lineFor('grow', Math.random()))
465 return {
466 text: isEgg
467 ? `卵がかえると${SPECIES[species].label}が生まれます。`
468 : `${save.pet.name}が${SPECIES[species].label}になりました(${stageOf(save.pet.xp).label}・XP ${save.pet.xp}のまま)。`,
469 }
470 }
471 if (verb === 'preview') {
472 if (value === 'off') {
473 await update($, preview, () => null)
474 await showStatus($, await load($))
475 return { text: 'プレビューを終えました。' }
476 }
477 // a stage, a species, or both, in either order: `adult dragon`, `ドラゴン`, `完全体 猫`
478 const stageWord = rest.find(w => parseStage(w))
479 const speciesWord = rest.find(w => parseSpecies(w))
480 if (rest.length === 0 || rest.length > 2 || rest.some(w => w !== stageWord && w !== speciesWord)) {
481 return { text: `使い方: /pet preview <段階> <種族> か /pet preview off。段階: ${STAGES.map(st => st.id).join('|')}、種族: ${SPECIES_IDS.join('|')}` }
482 }
483 const save = await load($)
484 const current = await read($, preview)
485 const species = parseSpecies(speciesWord) ?? current?.species ?? save.pet.species ?? 'cat'
486 // a species alone keeps the stage in view, hatched at least so it shows
487 const stage = parseStage(stageWord) ?? STAGES[Math.max(1, stageIndex(current?.xp ?? save.pet.xp))]!
488 await update($, preview, () => ({ xp: stage.xp, species }))
489 const shown = (await shownSave($))!
490 // show each ability the stage has, once
491 if (stage.id !== 'egg') await say($, lineFor('grow', Math.random()))
492 if (hasAbility(shown, 'snack')) await showStatus($, shown, true)
493 else await showStatus($, save)
494 play($, shown, 'sounds/levelup.wav')
495 void chat($, shown, PREVIEW_CHAT_GAP_MS)
496 await openPane($)
497 const abilities = ABILITIES.filter(ab => hasAbility(shown, ab.id)).map(ab => ab.title)
498 return { text: `${SPECIES[species].label}の${stage.label}をプレビュー中(保存データは変わりません)。できること: ${abilities.join('、')}。/pet preview off で戻ります。` }
499 }
500 return { text: `「${verb}」は知らないコマンドです。/pet help で使い方を見られます。` }
501 })
502
503 on('ui.close', async ($, e, next) => {
504 const result = await next(e)
505 if (e.id === PANE) await update($, paneOpen, () => false)
506 return result
507 })
508
509 // The hooks below observe only: each passes the event on unchanged, hands its
510 // bookkeeping to a job, and returns the result without waiting for it.
511 on('prompt.submit', async ($, e, next) => {
512 const result = await next(e)
513 try {
514 // the prompt's text is only tested here, never kept
515 if (isThanks(e.origin.kind, e.text)) {
516 later({
517 kind: 'change',
518 change: s => ({ ...s, pet: { ...s.pet, mood: clamp(s.pet.mood + 10) }, counters: { ...s.counters, thanks: s.counters.thanks + 1 } }),
519 line: 'thanks',
520 diary: 'ありがとうと言われてうれしかった',
521 })
522 }
523 } catch {}
524
525 return result
526 })
527
528 on('tool.call', async ($, e, next) => {
529 // parallel calls of the main thread only: a subagent's tools run inside its Agent call
530 const isMain = e.agentId === undefined
531 if (isMain) running += 1
532 const peak = isMain ? running : 0
533 let result
534 try {
535 result = await next(e)
536 } finally {
537 if (isMain) running -= 1
538 }
539 try {
540 const isDenied = 'deny' in result && result.deny !== undefined
541 const isError = isDenied || result.isError === true
542 const command = e.tool === 'Bash' && typeof e.command === 'string' ? e.command : ''
543 const isCommitted = !isError && isCommit(command)
544 const isTested = !isError && isTestRun(command)
545 const roll = Math.random()
546 const line: LineKind | undefined = isDenied
547 ? 'deny'
548 : isError
549 ? roll < 0.5
550 ? 'error'
551 : undefined
552 : isCommitted
553 ? 'commit'
554 : isTested
555 ? 'test'
556 : roll < 0.15
557 ? 'success'
558 : undefined
559 const tool = e.tool
560 // canned words only: never the command or the file
561 const diary = isDenied
562 ? 'ツールを止められてびっくりした'
563 : isCommitted
564 ? 'コミットしてよろこんだ'
565 : isTested
566 ? 'テストが通ってよろこんだ'
567 : isError
568 ? 'エラーでしょんぼりした'
569 : undefined
570 later({
571 kind: 'change',
572 line,
573 diary,
574 change: s => {
575 const c = s.counters
576 const hadFailed = c.failedTools.includes(tool)
577 return {
578 ...s,
579 pet: { ...s.pet, mood: clamp(s.pet.mood + (isDenied ? -3 : isError ? -5 : 1)) },
580 counters: {
581 ...c,
582 // only calls that ran count toward the tool achievements
583 tools: isError ? c.tools : { ...c.tools, [tool]: (c.tools[tool] ?? 0) + 1 },
584 errors: c.errors + (isError && !isDenied ? 1 : 0),
585 denies: c.denies + (isDenied ? 1 : 0),
586 // a recovery is the same tool succeeding after its own failure
587 recoveries: c.recoveries + (!isError && hadFailed ? 1 : 0),
588 failedTools:
589 isError && !isDenied ? [...new Set([...c.failedTools, tool])] : !isError ? c.failedTools.filter(t => t !== tool) : c.failedTools,
590 commits: c.commits + (isCommitted ? 1 : 0),
591 testPasses: c.testPasses + (isTested ? 1 : 0),
592 subagents: c.subagents + (!isError && SUBAGENT_TOOLS.has(tool) ? 1 : 0),
593 maxParallel: Math.max(c.maxParallel, peak),
594 },
595 }
596 },
597 })
598 } catch {}
599
600 return result
601 })
602
603 on('turn.complete', async ($, e, next) => {
604 const result = await next(e)
605 // a subagent's turn is part of the main turn
606 if (e.agentId !== undefined) return result
607 try {
608 const usage = e.usage ?? result.usage
609 // an interrupted or empty turn earns nothing (but is still counted as interrupted)
610 const isCounted = !e.isAborted && usage !== undefined
611 const eaten = usage?.output_tokens ?? 0
612 later({ kind: 'turn', isCounted, isAborted: e.isAborted, eaten, durationMs: e.durationMs })
613 } catch {}
614
615 return result
616 })
617
618 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
619 const { Box, Text, Button } = $.ui.resolve(e)
620 // a pane already open when the module reloaded is open too: drawing it says so
621 if (!(await read($, paneOpen))) void update($, paneOpen, () => true).catch(() => {})
622 const save = await shownSave($)
623 if (save === null) return <Text dimColor>Loading…</Text>
624 const now = await $.clock.now()
625 const { pet, settings } = save
626 const stage = stageIndex(pet.xp)
627 const goal = nextStageXp(pet.xp)
628 const from = STAGES[stage]!.xp
629 const isPreview = (await read($, preview)) !== null
630 const tick = await read($, frame)
631 const shown = await read($, tab)
632 const said = await read($, speech)
633 const told = await read($, notice)
634 const isUnreadable = await read($, unreadable)
635 const width = Math.max(20, e.props.bodyColumns - 4)
636 const rule = <Text dimColor>{'─'.repeat(width)}</Text>
637 const gauge = (value: number, cells = 5) => {
638 const full = Math.round((clamp(value) / 100) * cells)
639 return '■'.repeat(full) + '□'.repeat(cells - full)
640 }
641 const growth = goal === null ? 100 : ((pet.xp - from) / (goal - from)) * 100
642 const born = new Date(pet.bornAt)
643
644 const picture =
645 e.surface === 'terminal'
646 ? (() => {
647 const { Raster } = $.ui.resolve(e)
648 return <Raster key="pet-pane" {...toCells(compose(pet.species, stageOf(pet.xp).id, moodOf(pet, now), tick))} />
649 })()
650 : <Text bold color="yellow">{drawPet(pet, now, tick)}</Text>
651
652 const stat = (label: string, value: number, color: string, tail = '') => (
653 <Box flexDirection="row" gap={1} width={Math.floor(width / 2)}>
654 <Box flexShrink={0} width={8}>
655 <Text dimColor>{label}</Text>
656 </Box>
657 <Box flexShrink={0}>
658 <Text color={color}>{gauge(value)}</Text>
659 </Box>
660 {tail !== '' && (
661 <Box flexShrink={1} minWidth={0}>
662 <Text dimColor wrap="truncate-end">
663 {tail}
664 </Text>
665 </Box>
666 )}
667 </Box>
668 )
669 const isEgg = stage === 0 || pet.species === null
670 // the newer of the pet's words and the mod's own message; neither, how the pet seems
671 const fresh = (m: { at: number } | null) => m !== null && now - m.at < SPEECH_MS
672 const nowLine =
673 fresh(told) && (!fresh(said) || told!.at >= said!.at)
674 ? told!.text
675 : fresh(said)
676 ? `「${said!.text}」`
677 : describe(save, now)
678
679 const today = localDay(now)
680 const diary = save.diary.filter(d => localDay(d.at) === today).slice(-DIARY_ROWS).reverse()
681
682 const home = (
683 <Box flexDirection="column">
684 <Box flexDirection="row" gap={2}>
685 <Box flexShrink={0}>{picture}</Box>
686 <Box flexDirection="column">
687 <Text>{`年齢 ${ageDays(save, now)}日目`}</Text>
688 <Text>{`誕生日 ${born.getMonth() + 1}/${born.getDate()}`}</Text>
689 <Text>{goal === null ? `XP ${pet.xp}` : `XP ${pet.xp} / ${goal}`}</Text>
690 <Text dimColor>{`実績 ${Object.keys(save.unlocked).length}/${ACHIEVEMENTS.length}`}</Text>
691 </Box>
692 </Box>
693 {rule}
694 <Box flexDirection="row">
695 {stat('おなか', currentFullness(pet, now), 'yellow')}
696 {stat('きげん', pet.mood, 'red')}
697 </Box>
698 <Box flexDirection="row">
699 {stat('なかよし', pet.affection, 'magenta')}
700 {stat('成長', growth, 'green', goal === null ? 'MAX' : `→${STAGES[stage + 1]!.label}`)}
701 </Box>
702 <Box flexDirection="row" columnGap={1}>
703 {(Object.keys(CARE) as CareKind[]).map((kind, i) => {
704 const left = careLeft(save, kind, now)
705 const isOpen = !isPreview && !(isEgg && kind !== 'pat') && left > 0
706 return (
707 <Button
708 key={`care-${kind}`}
709 hotkey={String(i + 1)}
710 dimColor={!isOpen}
711 onPress={() => {
712 if (isPreview) void tell($, 'プレビュー中はお世話できません(/pet preview off で戻る)').catch(() => {})
713 else void serial({ kind: 'care', care: kind }, $).catch(() => {})
714 }}
715 >
716 {isEgg && kind !== 'pat' ? `${CARE[kind].label} -` : `${CARE[kind].label} ${left}/${CARE[kind].perDay}`}
717 </Button>
718 )
719 })}
720 </Box>
721 {rule}
722 <Text wrap="truncate-end">{`いま: ${nowLine}`}</Text>
723 <Text dimColor>きょうのできごと</Text>
724 {diary.length === 0 && <Text dimColor>{' まだなにもない'}</Text>}
725 {diary.map((d, i) => (
726 <Box key={`diary-${i}`} flexDirection="row" gap={1}>
727 <Box flexShrink={0} width={7}>
728 <Text dimColor>{` ${new Date(d.at).toTimeString().slice(0, 5)}`}</Text>
729 </Box>
730 <Text wrap="truncate-end">{d.text}</Text>
731 </Box>
732 ))}
733 {Array.from({ length: Math.max(0, DIARY_ROWS - Math.max(1, diary.length)) }, (_, i) => (
734 <Box key={`diary-pad-${i}`}>
735 <Text>{' '}</Text>
736 </Box>
737 ))}
738 </Box>
739 )
740
741 const achievements = (
742 <Box flexDirection="column">
743 <Text bold>{`実績 ${Object.keys(save.unlocked).length}/${ACHIEVEMENTS.length}`}</Text>
744 {ACHIEVEMENTS.map(a => {
745 const when = save.unlocked[a.id]
746 return (
747 <Box key={`ach-${a.id}`} flexDirection="row" gap={1}>
748 <Box flexShrink={0} width={2}>
749 <Text color={when ? 'green' : undefined} dimColor={!when}>
750 {when ? '✓' : '-'}
751 </Text>
752 </Box>
753 <Box flexShrink={0} width={18}>
754 <Text bold={!!when} dimColor={!when} wrap="truncate-end">
755 {a.title}
756 </Text>
757 </Box>
758 <Box flexShrink={0} width={7} justifyContent="flex-end">
759 <Text dimColor>{`+${a.xp}XP`}</Text>
760 </Box>
761 <Box flexGrow={1} flexShrink={1} minWidth={0}>
762 <Text dimColor wrap="truncate-end">
763 {when ? `${a.hint}(${new Date(when).toLocaleDateString()})` : '???'}
764 </Text>
765 </Box>
766 </Box>
767 )
768 })}
769 </Box>
770 )
771
772 const abilities = (
773 <Box flexDirection="column">
774 <Text bold>できること</Text>
775 {ABILITIES.map(ab => {
776 const isOpen = hasAbility(save, ab.id)
777 const needs = STAGES.find(st => st.id === ab.stage)!
778 return (
779 <Box key={`ability-${ab.id}`} flexDirection="row" gap={1}>
780 <Box flexShrink={0} width={2}>
781 <Text color={isOpen ? 'green' : undefined} dimColor={!isOpen}>
782 {isOpen ? '✓' : '-'}
783 </Text>
784 </Box>
785 <Box flexShrink={0} width={18}>
786 <Text bold={isOpen} dimColor={!isOpen} wrap="truncate-end">
787 {ab.title}
788 </Text>
789 </Box>
790 <Box flexGrow={1} flexShrink={1} minWidth={0}>
791 <Text dimColor wrap="truncate-end">
792 {isOpen ? ab.detail : `${needs.label}になると解禁`}
793 </Text>
794 </Box>
795 </Box>
796 )
797 })}
798 </Box>
799 )
800
801 const c = save.counters
802 const toolTotal = Object.values(c.tools).reduce((a, b) => a + b, 0)
803 const settingsTab = (
804 <Box flexDirection="column">
805 <Text bold>せってい</Text>
806 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
807 <Button
808 key="toggle-sound"
809 hotkey="s"
810 dimColor={!settings.sound}
811 onPress={() => void edit($, s => ({ ...s, settings: { ...s.settings, sound: !s.settings.sound } })).catch(() => {})}
812 >
813 {`効果音 ${settings.sound ? 'ON' : 'OFF'}`}
814 </Button>
815 <Button
816 key="toggle-chatter"
817 hotkey="a"
818 dimColor={!settings.chatter}
819 onPress={() => void edit($, s => ({ ...s, settings: { ...s.settings, chatter: !s.settings.chatter } })).catch(() => {})}
820 >
821 {`AIおしゃべり ${settings.chatter ? 'ON' : 'OFF'}`}
822 </Button>
823 </Box>
824 <Text dimColor>{'名前を変える: /pet name <名前> 種族を変える: /pet switch <種族>'}</Text>
825 <Text dimColor>{'くわしくは /pet help'}</Text>
826 <Box marginTop={1}>
827 <Text dimColor wrap="wrap">
828 {`きろく: ${c.turns}ターン ・ ツール${toolTotal}回 ・ コミット${c.commits}回 ・ 累計$${c.costUsd.toFixed(2)} ・ ${c.streakDays}日連続`}
829 </Text>
830 </Box>
831 </Box>
832 )
833
834 const TABS: { id: PaneTab; label: string }[] = [
835 { id: 'home', label: 'ホーム' },
836 { id: 'achievements', label: '実績' },
837 { id: 'abilities', label: 'できること' },
838 { id: 'settings', label: 'せってい' },
839 ]
840
841
842 return (
843 <Box flexDirection="column">
844 {isPreview && <Text color="magenta">{'[プレビュー中] /pet preview off で戻る'}</Text>}
845 {isUnreadable && <Text color="red">{'[保存データを読めないため、変更を保存していません。新しいバージョンのペットで保存された可能性があります]'}</Text>}
846 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
847 {TABS.map((t, i) => (
848 <Button
849 key={`tab-${t.id}`}
850 hotkey={String(i + 4)}
851 variant={shown === t.id ? 'primary' : undefined}
852 dimColor={shown !== t.id}
853 onPress={() => void update($, tab, () => t.id)}
854 >
855 {t.label}
856 </Button>
857 ))}
858 </Box>
859 <Box key="screen" flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
860 <Box flexDirection="row" justifyContent="space-between">
861 <Text bold>{pet.name}</Text>
862 <Text dimColor>{`${pet.species === null ? '???' : SPECIES[pet.species].label}・${STAGES[stage]!.label}`}</Text>
863 </Box>
864 <Box key="content" flexDirection="column" height={contentRows(e.surface)} overflow="hidden">
865 {shown === 'home' && home}
866 {shown === 'achievements' && achievements}
867 {shown === 'abilities' && abilities}
868 {shown === 'settings' && settingsTab}
869 </Box>
870 </Box>
871 </Box>
872 )
873 })
874}
875hooks/achievements.ts 40 lines1import type { Counters } from '../types'
2
3export type Achievement = {
4 id: string
5 title: string
6 // how it is earned: hidden until it is unlocked
7 hint: string
8 xp: number
9 check: (c: Counters) => boolean
10}
11
12const tool = (c: Counters, name: string) => c.tools[name] ?? 0
13
14export const ACHIEVEMENTS: Achievement[] = [
15 { id: 'first-step', title: '初めの一歩', hint: '1ターン終える', xp: 10, check: c => c.turns >= 1 },
16 { id: 'reader', title: '読書家', hint: 'ファイルを50回読む', xp: 20, check: c => tool(c, 'Read') >= 50 },
17 { id: 'bash-master', title: 'Bash職人', hint: 'Bashを100回使う', xp: 40, check: c => tool(c, 'Bash') >= 100 },
18 { id: 'writer', title: '書き手', hint: 'ファイルを50回書き換える', xp: 40, check: c => tool(c, 'Write') + tool(c, 'Edit') >= 50 },
19 { id: 'first-commit', title: '初コミット', hint: 'git commit する', xp: 20, check: c => c.commits >= 1 },
20 { id: 'commit-10', title: 'コミット魔', hint: '10回コミットする', xp: 50, check: c => c.commits >= 10 },
21 { id: 'all-green', title: '全部緑', hint: 'テストを通す', xp: 20, check: c => c.testPasses >= 1 },
22 { id: 'comeback', title: '七転び八起き', hint: '失敗のあとに成功する', xp: 15, check: c => c.recoveries >= 1 },
23 { id: 'scolded', title: '叱られた', hint: 'ツールを止められる', xp: 10, check: c => c.denies >= 1 },
24 { id: 'night-owl', title: '夜更かし', hint: '深夜0〜4時に起動する', xp: 15, check: c => c.nightOwl },
25 { id: 'streak-3', title: '三日坊主じゃない', hint: '3日続けて使う', xp: 30, check: c => c.streakDays >= 3 },
26 { id: 'big-spender', title: '太っ腹', hint: '1セッションで$1使う', xp: 20, check: c => c.maxSessionCostUsd >= 1 },
27 { id: 'deep-thought', title: '長考', hint: '5分を超えるターン', xp: 20, check: c => c.longestTurnMs >= 5 * 60 * 1000 },
28 { id: 'parallel', title: '並列使い', hint: '3つのツールを同時に動かす', xp: 15, check: c => c.maxParallel >= 3 },
29 { id: 'grateful', title: '感謝の人', hint: 'ありがとうを10回言う', xp: 25, check: c => c.thanks >= 10 },
30 { id: 'summoner', title: '召喚士', hint: 'サブエージェントを呼ぶ', xp: 15, check: c => c.subagents >= 1 },
31 { id: 'veteran', title: '百戦錬磨', hint: '100ターン終える', xp: 60, check: c => c.turns >= 100 },
32]
33
34// The achievements the counters now earn that were not unlocked before.
35export const newlyEarned = (counters: Counters, unlocked: Record<string, string>) =>
36 ACHIEVEMENTS.filter(a => unlocked[a.id] === undefined && a.check(counters))
37
38// Each finished turn is worth a little XP on its own, so daily work moves the pet along too.
39export const XP_PER_TURN = 1
40hooks/pet.ts 335 lines1import type { CareKind, Counters, DiaryEntry, Pet, PetSave, Species, StageId } from '../types'
2
3export const STAGES: { id: StageId; label: string; xp: number }[] = [
4 { id: 'egg', label: '卵', xp: 0 },
5 { id: 'hatchling', label: '赤ちゃん', xp: 30 },
6 { id: 'child', label: '子ども', xp: 100 },
7 { id: 'adult', label: '大人', xp: 250 },
8 { id: 'final', label: '完全体', xp: 500 },
9]
10
11export const stageIndex = (xp: number) => STAGES.reduce((at, s, i) => (xp >= s.xp ? i : at), 0)
12export const stageOf = (xp: number) => STAGES[stageIndex(xp)]!
13// XP needed for the next stage, or null at the last one
14export const nextStageXp = (xp: number) => STAGES[stageIndex(xp) + 1]?.xp ?? null
15
16export type AbilityId = 'care' | 'face' | 'speech' | 'celebrate' | 'snack' | 'sound' | 'chatter'
17
18export const ABILITIES: { id: AbilityId; stage: StageId; title: string; detail: string }[] = [
19 { id: 'care', stage: 'egg', title: 'お世話', detail: 'ペインでなでたり、ごはんをあげたりできる' },
20 { id: 'face', stage: 'hatchling', title: '表情', detail: '気分とお腹の具合が顔に出る' },
21 { id: 'speech', stage: 'hatchling', title: 'おしゃべり(定型)', detail: '出来事に合わせてひとこと言う' },
22 { id: 'celebrate', stage: 'child', title: 'お祝い', detail: '実績解除を一緒に喜ぶ' },
23 { id: 'snack', stage: 'child', title: 'お菓子換算', detail: 'ステータスラインでコストをうまい棒に換算する' },
24 { id: 'sound', stage: 'adult', title: '効果音', detail: '実績解除と進化で音が鳴る' },
25 { id: 'chatter', stage: 'final', title: 'AIおしゃべり', detail: '小さなモデルが状況に合わせてひとこと考える' },
26]
27
28export const hasAbility = (save: PetSave, id: AbilityId) => {
29 const ability = ABILITIES.find(a => a.id === id)!
30 return stageIndex(save.pet.xp) >= STAGES.findIndex(s => s.id === ability.stage)
31}
32
33export type Mood = 'happy' | 'normal' | 'sad' | 'hungry'
34
35export const SPECIES: Record<Species, { label: string; faces: Record<Mood, string> }> = {
36 cat: { label: '猫', faces: { happy: '(=^・ω・^=)', normal: '(=・ω・=)', sad: '(=;ω;=)', hungry: '(=´-ω-`=)' } },
37 bird: { label: '鳥', faces: { happy: '(・θ・)ノ', normal: '(・θ・)', sad: '(;θ;)', hungry: '(-θ-)' } },
38 slime: { label: 'スライム', faces: { happy: '(◕ᴗ◕)', normal: '(・_・)', sad: '(;_;)', hungry: '(-_-)' } },
39 dragon: { label: 'ドラゴン', faces: { happy: '<(゚∀゚)>', normal: '<(・Д・)>', sad: '<(;Д;)>', hungry: '<(-Д-)>' } },
40 ghost: { label: 'おばけ', faces: { happy: 'ヽ(°ᗜ°)ノ', normal: '(°ᗜ°)', sad: '(;ᗜ;)', hungry: '(-ᗜ-)' } },
41}
42
43export const SPECIES_IDS = Object.keys(SPECIES) as Species[]
44
45// Fullness drops by 1 every 10 minutes since the last meal. Kept unrounded, so short gaps
46// between meals still add up; drawings round it.
47const FULLNESS_DECAY_MS = 10 * 60 * 1000
48export const currentFullness = (pet: Pet, now: number) =>
49 Math.max(0, pet.fullness - Math.max(0, now - pet.lastFedAt) / FULLNESS_DECAY_MS)
50
51export const moodOf = (pet: Pet, now: number): Mood => {
52 if (currentFullness(pet, now) < HUNGRY) return 'hungry'
53 if (pet.mood >= 70) return 'happy'
54 if (pet.mood < 30) return 'sad'
55 return 'normal'
56}
57
58export const clamp = (n: number) => (Number.isFinite(n) ? Math.max(0, Math.min(100, Math.round(n))) : 0)
59const clampExact = (n: number) => (Number.isFinite(n) ? Math.max(0, Math.min(100, n)) : 0)
60
61export const HUNGRY = 25
62
63// A turn is a meal of its output tokens. A pet that was hungry when it came sulks a little;
64// eating cheers it up, more for a bigger meal.
65export const afterMeal = (pet: Pet, now: number, eatenTokens: number): Pet => {
66 const before = currentFullness(pet, now)
67 const sulk = before < HUNGRY ? -3 : 0
68 const cheer = Math.min(5, Math.floor(eatenTokens / 200))
69 return {
70 ...pet,
71 // 100 tokens fill one point
72 fullness: clampExact(before + eatenTokens / 100),
73 lastFedAt: now,
74 mood: clamp(pet.mood + sulk + cheer),
75 }
76}
77
78// The pet as text, where no Raster can be drawn: an egg wobbles; a hatched pet bobs and dresses up as it grows.
79export const drawPet = (pet: Pet, now: number, frame: number) => {
80 const stage = stageOf(pet.xp).id
81 if (stage === 'egg' || pet.species === null) return frame % 2 === 0 ? '( )' : ' ( )'
82 const face = SPECIES[pet.species].faces[moodOf(pet, now)]
83 const bob = frame % 2 === 0 ? '' : ' '
84 if (stage === 'hatchling') return `${bob}${face}`
85 if (stage === 'child') return `${bob}${face}${frame % 2 === 0 ? '~' : ' '}`
86 if (stage === 'adult') return `${bob}ฅ${face}ฅ`
87 return frame % 2 === 0 ? `+${face}+` : `*${face}*`
88}
89
90export type LineKind = 'idle' | 'success' | 'error' | 'deny' | 'commit' | 'test' | 'thanks' | 'hungry' | 'hatch' | 'grow'
91
92const LINES: Record<LineKind, string[]> = {
93 idle: ['ふわぁ…', 'なにしてるの?', 'がんばってるね', 'ここにいるよ', 'ねむい…'],
94 success: ['いいね!', 'うまくいった!', 'その調子!', 'さすが!'],
95 error: ['あれれ…', 'どんまい!', 'もう一回いこう', 'だいじょうぶ?'],
96 deny: ['止められちゃった', 'こわかった…', 'それはダメだって'],
97 commit: ['コミットおめでとう!', '記録できたね!', 'えらい!'],
98 test: ['テスト全部緑!', 'グリーン最高!', '通った通った!'],
99 thanks: ['えへへ', 'どういたしまして!', 'うれしい!'],
100 hungry: ['おなかすいた…', 'ごはんまだ?', 'トークン食べたい…'],
101 hatch: ['うまれたよ!', 'はじめまして!', 'ここどこ?'],
102 grow: ['大きくなったよ!', 'できること増えた!', 'みてみて!'],
103}
104
105export const lineFor = (kind: LineKind, pick: number) => {
106 const list = LINES[kind]
107 return list[Math.floor(pick * list.length) % list.length]!
108}
109
110export const freshCounters = (): Counters => ({
111 turns: 0,
112 aborted: 0,
113 tools: {},
114 errors: 0,
115 denies: 0,
116 recoveries: 0,
117 failedTools: [],
118 commits: 0,
119 testPasses: 0,
120 subagents: 0,
121 thanks: 0,
122 costUsd: 0,
123 maxSessionCostUsd: 0,
124 longestTurnMs: 0,
125 maxParallel: 0,
126 sessions: 0,
127 lastSessionDay: null,
128 streakDays: 0,
129 nightOwl: false,
130})
131
132export const freshSave = (now: number): PetSave => ({
133 version: 1,
134 pet: {
135 name: 'たま',
136 species: null,
137 xp: 0,
138 bornAt: new Date(now).toISOString(),
139 mood: 60,
140 fullness: 80,
141 lastFedAt: now,
142 affection: 0,
143 },
144 unlocked: {},
145 counters: freshCounters(),
146 settings: { sound: true, chatter: true },
147 diary: [],
148 care: { day: localDay(now), feed: 0, pat: 0, play: 0 },
149})
150
151// A save read back from the store, checked field by field: a field missing or of the wrong
152// kind (a hand edit, an older or newer version of this mod) falls back to its default
153// alone, so one bad value never freezes or hides the pet.
154const num = (v: unknown, fallback: number) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback)
155const bool = (v: unknown, fallback: boolean) => (typeof v === 'boolean' ? v : fallback)
156const str = (v: unknown, fallback: string) => (typeof v === 'string' ? v : fallback)
157const obj = (v: unknown): Record<string, unknown> => (typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {})
158
159// Whether a stored value is a save this version can read: nothing stored yet, or version 1.
160// Anything else (a newer version, a broken file) must not be written over.
161export const isReadable = (raw: unknown) => raw === undefined || obj(raw).version === 1
162
163export const loadSave = (raw: unknown, now: number): PetSave => {
164 const fresh = freshSave(now)
165 const r = obj(raw)
166 if (r.version !== 1) return fresh
167 const p = obj(r.pet)
168 const c = obj(r.counters)
169 const st = obj(r.settings)
170 const d = fresh.counters
171 const species = SPECIES_IDS.find(id => id === p.species) ?? null
172 const counts = (v: unknown) =>
173 Object.fromEntries(Object.entries(obj(v)).filter((e): e is [string, number] => typeof e[1] === 'number' && Number.isFinite(e[1])))
174 return {
175 version: 1,
176 pet: {
177 name: str(p.name, fresh.pet.name),
178 species,
179 xp: Math.max(0, num(p.xp, 0)),
180 bornAt: typeof p.bornAt === 'string' && Number.isFinite(Date.parse(p.bornAt)) ? p.bornAt : fresh.pet.bornAt,
181 mood: clamp(num(p.mood, fresh.pet.mood)),
182 fullness: clampExact(num(p.fullness, fresh.pet.fullness)),
183 lastFedAt: num(p.lastFedAt, now),
184 affection: clamp(num(p.affection, 0)),
185 },
186 unlocked: Object.fromEntries(Object.entries(obj(r.unlocked)).filter((e): e is [string, string] => typeof e[1] === 'string')),
187 counters: {
188 turns: num(c.turns, d.turns),
189 aborted: num(c.aborted, d.aborted),
190 tools: counts(c.tools),
191 errors: num(c.errors, d.errors),
192 denies: num(c.denies, d.denies),
193 recoveries: num(c.recoveries, d.recoveries),
194 failedTools: Array.isArray(c.failedTools) ? c.failedTools.filter((t): t is string => typeof t === 'string') : [],
195 commits: num(c.commits, d.commits),
196 testPasses: num(c.testPasses, d.testPasses),
197 subagents: num(c.subagents, d.subagents),
198 thanks: num(c.thanks, d.thanks),
199 costUsd: num(c.costUsd, d.costUsd),
200 // older saves kept the last session's spend under sessionCostUsd
201 maxSessionCostUsd: num(c.maxSessionCostUsd, num(c.sessionCostUsd, d.maxSessionCostUsd)),
202 longestTurnMs: num(c.longestTurnMs, d.longestTurnMs),
203 maxParallel: num(c.maxParallel, d.maxParallel),
204 sessions: num(c.sessions, d.sessions),
205 lastSessionDay: typeof c.lastSessionDay === 'string' ? c.lastSessionDay : null,
206 streakDays: num(c.streakDays, d.streakDays),
207 nightOwl: bool(c.nightOwl, d.nightOwl),
208 },
209 settings: {
210 sound: bool(st.sound, fresh.settings.sound),
211 chatter: bool(st.chatter, fresh.settings.chatter),
212 },
213 diary: (Array.isArray(r.diary) ? r.diary : [])
214 .map(obj)
215 .filter(e => typeof e.at === 'number' && Number.isFinite(e.at) && typeof e.text === 'string')
216 .map(e => ({ at: e.at as number, text: e.text as string }))
217 .slice(-DIARY_KEEP),
218 care: (() => {
219 const c = obj(r.care)
220 return { day: str(c.day, fresh.care.day), feed: num(c.feed, 0), pat: num(c.pat, 0), play: num(c.play, 0) }
221 })(),
222 }
223}
224
225// The diary keeps this many lines across days.
226export const DIARY_KEEP = 40
227
228export const addDiary = (save: PetSave, entries: DiaryEntry[]): PetSave =>
229 entries.length === 0 ? save : { ...save, diary: [...save.diary, ...entries].slice(-DIARY_KEEP) }
230
231// Care a person can give each day, and what it does. Care earns no XP: growth comes from work.
232export const CARE: Record<CareKind, { label: string; perDay: number }> = {
233 feed: { label: 'ごはん', perDay: 3 },
234 pat: { label: 'なでる', perDay: 5 },
235 play: { label: 'あそぶ', perDay: 3 },
236}
237
238// Care counts start over only when the day moves forward (YYYY-MM-DD compares as text): a
239// clock set back, or a session in another time zone, keeps the counts already used.
240const careToday = (save: PetSave, now: number) => {
241 const today = localDay(now)
242 return today > save.care.day ? { day: today, feed: 0, pat: 0, play: 0 } : save.care
243}
244
245export const careLeft = (save: PetSave, kind: CareKind, now: number) => {
246 const used = careToday(save, now)[kind]
247 return Math.max(0, CARE[kind].perDay - used)
248}
249
250// One act of care: the save after it and the diary's words, or why it cannot be given now.
251export const giveCare = (save: PetSave, kind: CareKind, now: number): { save: PetSave; text: string } | { refused: string } => {
252 const isEgg = stageIndex(save.pet.xp) === 0 || save.pet.species === null
253 if (isEgg && kind !== 'pat') return { refused: 'まだ卵です。なでてあたためてあげよう' }
254 if (careLeft(save, kind, now) === 0) return { refused: `きょうの${CARE[kind].label}はもうおしまい。また明日` }
255 const pet = save.pet
256 const full = currentFullness(pet, now)
257 const care = careToday(save, now)
258 const after = { ...save, care: { ...care, [kind]: care[kind] + 1 } }
259 if (kind === 'feed') {
260 if (full >= 95) return { refused: 'おなかいっぱいみたい' }
261 return { save: { ...after, pet: { ...pet, fullness: clampExact(full + 30), lastFedAt: now, mood: clamp(pet.mood + 2) } }, text: 'ごはんをもらった' }
262 }
263 if (kind === 'pat') {
264 return {
265 save: { ...after, pet: { ...pet, mood: clamp(pet.mood + 5), affection: clamp(pet.affection + 2) } },
266 text: isEgg ? '卵をあたためてもらった' : 'なでてもらった',
267 }
268 }
269 return {
270 save: {
271 ...after,
272 pet: { ...pet, fullness: clampExact(full - 5), lastFedAt: now, mood: clamp(pet.mood + 10), affection: clamp(pet.affection + 5) },
273 },
274 text: 'いっしょにあそんだ',
275 }
276}
277
278// How the pet seems right now, for the pane's "いま" line.
279export const describe = (save: PetSave, now: number) => {
280 if (stageIndex(save.pet.xp) === 0 || save.pet.species === null) return 'ときどき卵が動いている…'
281 const mood = moodOf(save.pet, now)
282 if (mood === 'hungry') return 'おなかがすいているみたい'
283 if (mood === 'sad') return 'しょんぼりしている'
284 if (mood === 'happy') return 'ごきげん!'
285 return 'のんびりしている'
286}
287
288// Days since the egg came, counting the first day as day 1.
289export const ageDays = (save: PetSave, now: number) => {
290 const born = new Date(save.pet.bornAt)
291 const start = new Date(born.getFullYear(), born.getMonth(), born.getDate()).getTime()
292 const today = new Date(new Date(now).getFullYear(), new Date(now).getMonth(), new Date(now).getDate()).getTime()
293 return Number.isFinite(start) ? Math.max(1, Math.round((today - start) / 86_400_000) + 1) : 1
294}
295
296// A name as typed: no control or format characters (zero-width spaces, direction marks, line
297// separators), at most 12 characters as a person counts them (an emoji built of several code
298// points stays whole). Empty when nothing visible is left.
299const NAME_MAX = 12
300export const cleanName = (typed: string) => {
301 // the zero-width joiner stays: it holds emoji such as 👨👩👧 together
302 const visible = typed.replace(/[\p{Cc}\u2028\u2029]|(?!\u200d)\p{Cf}/gu, '').trim()
303 const chars =
304 typeof Intl !== 'undefined' && 'Segmenter' in Intl
305 ? Array.from(new Intl.Segmenter('ja', { granularity: 'grapheme' }).segment(visible), part => part.segment)
306 : Array.from(visible)
307 // a joiner left on its own joins nothing and shows nothing
308 return chars
309 .filter(c => c.replace(/\u200d/g, '') !== '')
310 .slice(0, NAME_MAX)
311 .join('')
312}
313
314// うまい棒 at ¥15, a dollar at ¥150: a rough figure, for fun.
315export const snacks = (usd: number) => Math.floor((usd * 150) / 15)
316
317// The day before `day` (YYYY-MM-DD) by the calendar, right across daylight saving changes.
318export const dayBefore = (now: number) => {
319 const d = new Date(now)
320 return localDay(new Date(d.getFullYear(), d.getMonth(), d.getDate() - 1, 12).getTime())
321}
322
323// Counters as of `now`: a new local day moves the streak (+1 after yesterday, else back to 1).
324export const rollDay = (c: Counters, now: number): Counters => {
325 const today = localDay(now)
326 if (c.lastSessionDay === today) return c
327 const streakDays = c.lastSessionDay === dayBefore(now) ? c.streakDays + 1 : 1
328 return { ...c, lastSessionDay: today, streakDays }
329}
330
331export const localDay = (now: number) => {
332 const d = new Date(now)
333 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
334}
335hooks/rules.ts 53 lines1import type { PetSave } from '../types'
2import { newlyEarned } from './achievements'
3import type { Achievement } from './achievements'
4import { clamp, SPECIES_IDS, stageIndex } from './pet'
5
6// A shell command's parts: split on && || ; | and newlines, each without leading
7// VAR=value assignments, so `cd app && CI=1 npm test` yields `npm test`. Quoted text is
8// blanked first, so `git commit -m "wip; npm test"` is one part, not a test run.
9export const segments = (command: string) =>
10 command
11 .replace(/'[^']*'|"(?:\\.|[^"\\])*"/g, "''")
12 .split(/&&|\|\||;|\||\n/)
13 .map(part => part.trim().replace(/^(?:\w+=\S*\s+)+/, ''))
14 .filter(Boolean)
15
16// A test runner at the start of a part: not any command that mentions "test".
17const TEST_RUNNER =
18 /^(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test\b|npx\s+(?:jest|vitest|mocha)\b|(?:pytest|jest|vitest|mocha)\b|python3?\s+-m\s+pytest\b|(?:go|cargo|deno|bun)\s+test\b|claude\s+plugin\s+test\b)/
19// `commit` as a word of its own: not `commit-tree`
20const GIT_COMMIT = /^git(?:\s+-C\s+\S+)?\s+commit(?![\w-])/
21
22export const isTestRun = (command: string) => segments(command).some(part => TEST_RUNNER.test(part))
23export const isCommit = (command: string) => segments(command).some(part => GIT_COMMIT.test(part) && !/--dry-run\b/.test(part))
24
25// "Thanks" said by the person: typed at the terminal or sent through Remote Control,
26// not a task notification, a scheduled prompt or another session's message.
27const THANKS = /ありがと|感謝|サンキュ|\bthank you\b|\bthanks\b/i
28export const isThanks = (originKind: string, text: string) =>
29 (originKind === 'composer' || originKind === 'bridge') && THANKS.test(text)
30
31// The save after a change, with what the change earned: achievements and their XP,
32// and a species at hatching when none was chosen. `pick` (0..1) picks the species.
33export const award = (save: PetSave, at: string, pick: number): { save: PetSave; earned: Achievement[] } => {
34 let after = save
35 const earned = newlyEarned(after.counters, after.unlocked)
36 if (earned.length > 0) {
37 after = {
38 ...after,
39 unlocked: { ...after.unlocked, ...Object.fromEntries(earned.map(a => [a.id, at])) },
40 pet: {
41 ...after.pet,
42 xp: after.pet.xp + earned.reduce((sum, a) => sum + a.xp, 0),
43 mood: clamp(after.pet.mood + 5 * earned.length),
44 },
45 }
46 }
47 if (stageIndex(after.pet.xp) > 0 && after.pet.species === null) {
48 const species = SPECIES_IDS[Math.min(SPECIES_IDS.length - 1, Math.floor(pick * SPECIES_IDS.length))]!
49 after = { ...after, pet: { ...after.pet, species } }
50 }
51 return { save: after, earned }
52}
53hooks/sprites.ts 306 lines1import type { Species, StageId } from '../types'
2import type { Mood } from './pet'
3
4// Pixel art drawn as the left half of each sprite, mirrored into the right half.
5// '.' clear, 'o' outline, 'b' body, 'l' light (belly), 'a' accent, 'e' eye, 'm' mouth.
6const mirror = (left: string[]) => left.map(row => row + [...row].reverse().join(''))
7
8// `large` is the 16x16 the pane draws; `small` is the hatchling peeking out of its shell
9type Sprite = { large: string[]; small: string[]; neck: number }
10
11const SPRITES: Record<Species, Sprite> = {
12 cat: {
13 large: mirror([
14 '........',
15 '..oo....',
16 '..oao...',
17 '..oaao..',
18 '..obbooo',
19 '.obbbbbb',
20 '.obbbbbb',
21 '.obbebbb',
22 '.obbebbb',
23 '.obbbbbb',
24 '.obbbbbm',
25 '.obbbbbb',
26 '..obblll',
27 '..obllll',
28 '..obbbbb',
29 '...ooooo',
30 ]),
31 small: mirror(['.o..', '.oao', '.obb', 'obeb', 'obbm', 'obll', '.obb', '..oo']),
32 neck: 11,
33 },
34 bird: {
35 large: mirror([
36 '........',
37 '......oo',
38 '....oobb',
39 '...obbbb',
40 '..obbbbb',
41 '..obbebb',
42 '..obbebb',
43 '.obbbbba',
44 '.obbbbbm',
45 'obbbllll',
46 'obbbllll',
47 '.obbllll',
48 '.obbblll',
49 '..obbbbb',
50 '...oaoo.',
51 '........',
52 ]),
53 small: mirror(['..oo', '.obb', 'obeb', 'obba', 'obbm', 'obll', '.obl', '..oa']),
54 neck: 9,
55 },
56 slime: {
57 large: mirror([
58 '........',
59 '........',
60 '.......o',
61 '......ob',
62 '.....obb',
63 '....obbb',
64 '...obbll',
65 '..obbbbb',
66 '..obebbb',
67 '.obbebbb',
68 '.obbbbbm',
69 'obbbbbbb',
70 'obbbbbbb',
71 'obbbbbbb',
72 '.obbbbbb',
73 '..oooooo',
74 ]),
75 small: mirror(['...o', '..ob', '.obl', 'obeb', 'obbm', 'obbb', 'obbb', '.ooo']),
76 neck: 11,
77 },
78 dragon: {
79 large: mirror([
80 '..a.....',
81 '..ao....',
82 '...ooooo',
83 '..obbbbb',
84 '..obbbbb',
85 '..obebbb',
86 '..obebbb',
87 '..obbbbb',
88 '..obbbmm',
89 'a.obbbbb',
90 'aaobllll',
91 'aaobllll',
92 '.aobllll',
93 '..obbbbb',
94 '..obo...',
95 '..ooo...',
96 ]),
97 small: mirror(['a...', 'ao..', '.obb', 'obeb', 'abmm', 'aobl', '.obb', '.oo.']),
98 neck: 9,
99 },
100 ghost: {
101 large: mirror([
102 '........',
103 '....oooo',
104 '...obbbb',
105 '..obbbbb',
106 '.obbbbbb',
107 '.obbebbb',
108 '.obbebbb',
109 '.obbbbbb',
110 '.obbbbbm',
111 '.obbbbbm',
112 'obbbbbbb',
113 'obbbbbbb',
114 '.obbbbbb',
115 '.obbbbbb',
116 '.obobbob',
117 '.o.o..o.',
118 ]),
119 small: mirror(['..oo', '.obb', 'obeb', 'obbm', 'obbb', 'obbb', 'obob', 'o.o.']),
120 neck: 10,
121 },
122}
123
124const EGG = mirror([
125 '........',
126 '........',
127 '......oo',
128 '.....obb',
129 '....obbb',
130 '...obbab',
131 '...obbbb',
132 '..obabbb',
133 '..obbbbb',
134 '..obbbab',
135 '..obbbbb',
136 '..obbbbb',
137 '...obabb',
138 '...obbbb',
139 '....oooo',
140 '........',
141])
142
143// The bottom of the shell a baby sits in.
144const SHELL = mirror(['..o.o.ob', '..obobbb', '..obbbbb', '...obbbb', '....oooo'])
145
146const CROWN = mirror(['g.g', 'ggg'])
147
148type Palette = { o: number; b: number; l: number; a: number }
149
150const PALETTES: Record<Species | 'egg', Palette> = {
151 cat: { o: 0x5a3418, b: 0xf0a040, l: 0xffe2b8, a: 0xff9eb5 },
152 bird: { o: 0x1e4a70, b: 0x5ab4f0, l: 0xe8f6ff, a: 0xffb020 },
153 slime: { o: 0x1f5a1f, b: 0x6cd36c, l: 0xd2f7d2, a: 0x3a9a3a },
154 dragon: { o: 0x5a1a14, b: 0xe05a4a, l: 0xffd8a8, a: 0xf5c542 },
155 ghost: { o: 0x6060a0, b: 0xe4e4f4, l: 0xffffff, a: 0xb8b8e0 },
156 egg: { o: 0x6a5a40, b: 0xf5ecd0, l: 0xffffff, a: 0x8fc0e0 },
157}
158
159const EYE = 0x202020
160const EYE_SLEEPY = 0x9a9a9a
161const MOUTH_HAPPY = 0xff5070
162const MOUTH = 0x6a3030
163const TEAR = 0x60b0ff
164const SCARF = 0xd03030
165const GOLD = 0xffcc33
166const SPARKLE = 0xfff6a0
167
168// A picture: a color per pixel, null where it is clear.
169export type Grid = (number | null)[][]
170
171const blank = (width: number, height: number): Grid => Array.from({ length: height }, () => Array<number | null>(width).fill(null))
172
173// Paints a sprite's letters in its palette, the face as the mood and the blink say.
174export const paint = (rows: string[], palette: Palette, mood: Mood, isBlinking: boolean): Grid => {
175 const at = (x: number, y: number) => rows[y]?.[x] ?? '.'
176 const grid = rows.map((row, y) =>
177 [...row].map((ch, x): number | null => {
178 if (ch === '.') return null
179 if (ch === 'e') {
180 const isTop = at(x, y + 1) === 'e'
181 // a closed or squinting eye keeps only its lowest pixel
182 if (isTop && (isBlinking || mood === 'happy')) return palette.b
183 if (isBlinking) return palette.o
184 return mood === 'hungry' ? EYE_SLEEPY : EYE
185 }
186 if (ch === 'm') return mood === 'happy' ? MOUTH_HAPPY : MOUTH
187 return palette[ch as keyof Palette] ?? null
188 }),
189 )
190 if (mood === 'sad' && !isBlinking) {
191 // a tear under each eye, where the body is
192 rows.forEach((row, y) =>
193 [...row].forEach((ch, x) => {
194 if (ch === 'e' && at(x, y + 1) !== 'e' && at(x, y + 1) === 'b') grid[y + 1]![x] = TEAR
195 }),
196 )
197 }
198 return grid
199}
200
201// Draws `src` onto `dst` at (x, y), clipping what falls outside.
202const blit = (dst: Grid, src: Grid, x: number, y: number) =>
203 src.forEach((row, sy) =>
204 row.forEach((c, sx) => {
205 if (c === null) return
206 const line = dst[y + sy]
207 if (line && x + sx >= 0 && x + sx < line.length) line[x + sx] = c
208 }),
209 )
210
211const recolor = (grid: Grid, rowIndex: number, from: number[], to: number) =>
212 grid[rowIndex]?.forEach((c, x) => {
213 if (c !== null && from.includes(c)) grid[rowIndex]![x] = to
214 })
215
216export const LARGE = { width: 16, height: 18 }
217
218// The pet as the pane's picture: the 16x16 sprite on a 16x18 canvas, with room to bob.
219// Frames: odd frames bob (an egg wobbles), every fourth blinks.
220export const compose = (species: Species | null, stage: StageId, mood: Mood, frame: number): Grid => {
221 const canvas = blank(LARGE.width, LARGE.height)
222 const isOdd = frame % 2 === 1
223 const isBlinking = frame % 4 === 3
224
225 if (stage === 'egg' || species === null) {
226 // the egg wobbles one pixel sideways on odd frames
227 blit(canvas, paint(EGG, PALETTES.egg, 'normal', false), isOdd ? 1 : 0, 1)
228 return canvas
229 }
230
231 const palette = PALETTES[species]
232 const sprite = SPRITES[species]
233 const bob = isOdd ? 1 : 0
234 if (stage === 'hatchling') {
235 // the small sprite peeking out of the bottom of its shell
236 blit(canvas, paint(sprite.small, palette, mood, isBlinking), 4, 6 + bob)
237 blit(canvas, paint(SHELL, PALETTES.egg, 'normal', false), 0, 13)
238 return canvas
239 }
240
241 const pic = paint(sprite.large, palette, mood, isBlinking)
242 if (stage === 'adult' || stage === 'final') recolor(pic, sprite.neck, [palette.b, palette.l], SCARF)
243 blit(canvas, pic, 0, 1 + bob)
244 if (stage === 'final') {
245 blit(canvas, CROWN.map(row => [...row].map(ch => (ch === 'g' ? GOLD : null))), 5, bob)
246 const sparkles: [number, number][] = isOdd
247 ? [
248 [14, 3],
249 [1, 10],
250 ]
251 : [
252 [1, 4],
253 [14, 11],
254 ]
255 for (const [x, y] of sparkles) canvas[y]![x] = SPARKLE
256 }
257 return canvas
258}
259
260const DEFAULT_COLOR = 0x01000000
261const UPPER_HALF = 0x2580
262const LOWER_HALF = 0x2584
263const SPACE = 0x20
264
265const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
266
267export const toBase64 = (bytes: Uint8Array) => {
268 let out = ''
269 for (let i = 0; i < bytes.length; i += 3) {
270 const [a, b, c] = [bytes[i]!, bytes[i + 1], bytes[i + 2]]
271 const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
272 out += BASE64[(n >> 18) & 63]! + BASE64[(n >> 12) & 63]!
273 out += b === undefined ? '=' : BASE64[(n >> 6) & 63]!
274 out += c === undefined ? '=' : BASE64[n & 63]!
275 }
276 return out
277}
278
279// A picture as Raster cells: two pixels per cell, the upper half block's
280// foreground the top pixel and its background the bottom one.
281export const toCells = (grid: Grid) => {
282 const height = Math.ceil(grid.length / 2)
283 const width = grid[0]?.length ?? 0
284 const words = new Uint32Array(width * height * 3)
285 for (let row = 0; row < height; row++) {
286 for (let x = 0; x < width; x++) {
287 const top = grid[row * 2]?.[x] ?? null
288 const bottom = grid[row * 2 + 1]?.[x] ?? null
289 const cell =
290 top === null && bottom === null
291 ? [SPACE, DEFAULT_COLOR, DEFAULT_COLOR]
292 : top === null
293 ? [LOWER_HALF, bottom!, DEFAULT_COLOR]
294 : [UPPER_HALF, top, bottom ?? DEFAULT_COLOR]
295 words.set(cell, (row * width + x) * 3)
296 }
297 }
298 // Raster cells are little-endian u32s
299 const bytes = new Uint8Array(words.length * 4)
300 const view = new DataView(bytes.buffer)
301 words.forEach((w, i) => view.setUint32(i * 4, w, true))
302 return { columns: width, rows: height, cells: toBase64(bytes) }
303}
304
305export const SPRITE_SOURCES = { SPRITES, EGG, SHELL }
306types/index.d.ts 96 lines1export type Species = 'cat' | 'bird' | 'slime' | 'dragon' | 'ghost'
2export type StageId = 'egg' | 'hatchling' | 'child' | 'adult' | 'final'
3
4export type Counters = {
5 turns: number
6 aborted: number
7 // by tool name
8 tools: Record<string, number>
9 errors: number
10 denies: number
11 // a tool that succeeded after its own last call failed
12 recoveries: number
13 // tools whose last call failed, waiting for a recovery
14 failedTools: string[]
15 commits: number
16 testPasses: number
17 subagents: number
18 thanks: number
19 costUsd: number
20 // the most one session has spent: each session raises it with its own spend only
21 maxSessionCostUsd: number
22 longestTurnMs: number
23 maxParallel: number
24 sessions: number
25 // local date (YYYY-MM-DD) of the last session, and how many days in a row
26 lastSessionDay: string | null
27 streakDays: number
28 nightOwl: boolean
29}
30
31export type Pet = {
32 name: string
33 // decided at hatching
34 species: Species | null
35 xp: number
36 bornAt: string
37 // 0..100
38 mood: number
39 // 0..100 as of lastFedAt; it drops with time (see currentFullness)
40 fullness: number
41 lastFedAt: number
42 // 0..100, raised by care
43 affection: number
44}
45
46export type CareKind = 'feed' | 'pat' | 'play'
47
48// One line of the diary: canned words only, never a command, a path or a prompt.
49export type DiaryEntry = { at: number; text: string }
50
51export type PetSave = {
52 version: 1
53 pet: Pet
54 // achievement id -> when it was unlocked (ISO)
55 unlocked: Record<string, string>
56 counters: Counters
57 settings: { sound: boolean; chatter: boolean }
58 // the latest diary lines, newest last
59 diary: DiaryEntry[]
60 // care given on `day` (YYYY-MM-DD), by kind, for the daily limits
61 care: { day: string; feed: number; pat: number; play: number }
62}
63
64export type PaneTab = 'home' | 'achievements' | 'abilities' | 'settings'
65
66export type Speech = { text: string; at: number }
67
68
69declare module 'claude-code' {
70 interface PluginState {
71 'coding-pet': {
72 // the store's save, mirrored so the pane redraws on change
73 // Shaped: kept under a tag, so a hot reload with a new PetSave form reads the old value as absent
74 save: Shaped<PetSave | null>
75 // true when the store holds a save this version cannot read: nothing is written then
76 unreadable: boolean
77 // a message from the mod itself (not the pet's words), shown on the いま line
78 notice: Speech | null
79 speech: Speech | null
80 // animation frame, ticked on a timer
81 frame: number
82 // the pane's tab
83 tab: PaneTab
84 // whether the pet's pane is open: the animation and the store refresh run only then
85 paneOpen: boolean
86 // set once per session, so a hot reload's session.start is not counted again
87 sessionCounted: boolean
88 lastChatAt: number
89 // the session's cost when this session started, so a resumed session's spend is not counted twice
90 seenSessionUsd: number | null
91 // /pet preview: draw the pet at this XP and species without touching the save
92 preview: { xp: number; species: Species } | null
93 }
94 }
95}
96