SLOPSHOPPER

coding-pet

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

newpaneguardcommandtoaststatus
v1.0.0MITupdated 2026-10-05ytskmt14/coding-pet/plugins/coding-pet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · coding-pet
│ ┃ Pet ✕ › fix the failing auth test and add an audit log call │ ┃ [ ホーム ] [ 実績 ] [ できること ] [ せってい │ ┃ ╭─────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ │ たま ???・卵 ⎿ Read 6 lines │ ┃ │ 年齢 1日目 ⏺ Update(src/auth.ts) │ ┃ │ ▄▄▄▄ 誕生日 10/9 ⎿ Added 2 lines, removed 1 line │ ┃ │ ▄▀▀▀▀▀▀▄ XP 0 / 30 ⏺ Bash(bun test) │ ┃ │ ▀▀▀▀▀▀▀▀▀▀ 実績 0/17 ⎿ 3 pass, 1 fail │ ┃ │ ▀▀▀▀▀▀▀▀▀▀▀▀ │ ┃ │ ▀▀▀▀▀▀▀▀▀▀▀▀ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ▀▀▀▀▀▀▀▀▀▀▀▀ │ ┃ │ ▀▀▀▀▀▀▀▀▀▀ ✻ Worked for 42s · done 4:20 PM │ ┃ │ │ ┃ │ ──────────────────────────────────────── › /pet │ ┃ │ ──────────── ⎿ coding-pet: ペットのペインを開きました。数字キーでお世話とタブの切り替えができます。 │ ┃ │ おなか ■■■■□ きげん ■■■□□ │ ┃ │ なかよし □□□□□ 成長 □□□□□ → │ ┃ │ [ ごはん - ] [ なでる 5/5 ] [ あそぶ - ] │ ┃ │ ──────────────────────────────────────── │ ┃ │ ──────────── │ ┃ │ いま: ときどき卵が動いている… │ ┃ │ きょうのできごと │ ┃ │ まだなにもない │ ┃ │ │ ┃ │ │ ┃ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Pet
[ ホーム ] [ 実績 ] [ できること ] [ せってい ] ╭──────────────────────────────────────────────────────────╮ │ たま ???・卵 │ │ 年齢 1日目 │ │ ▄▄▄▄ 誕生日 10/9 │ │ ▄▀▀▀▀▀▀▄ XP 0 / 30 │ │ ▀▀▀▀▀▀▀▀▀▀ 実績 0/17 │ │ ▀▀▀▀▀▀▀▀▀▀▀▀ │ │ ▀▀▀▀▀▀▀▀▀▀▀▀ │ │ ▀▀▀▀▀▀▀▀▀▀▀▀ │ │ ▀▀▀▀▀▀▀▀▀▀ │ │ │ │ ──────────────────────────────────────────────────── │ │ おなか ■■■■□ きげん ■■■□□ │ │ なかよし □□□□□ 成長 □□□□□ →赤ちゃん │ │ [ ごはん - ] [ なでる 5/5 ] [ あそぶ - ] │ │ ──────────────────────────────────────────────────── │ │ いま: ときどき卵が動いている… │ │ きょうのできごと │ │ まだなにもない │ │ │ │ │ │ │ │ │ │ │ │ │ ╰──────────────────────────────────────────────────────────╯
README

coding-pet

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 コミットしてよろこんだ         │
╰──────────────────────────────────────╯

できること

  • 育つ: 実績を解除すると XP が入り、卵 → 赤ちゃん → 子ども → 大人 → 完全体と成長します。種族(猫・鳥・スライム・ドラゴン・おばけ)は孵化したときに決まります
  • 実績: 全17個。名前は見えますが、解除の条件は解除するまで秘密です
  • できることが増える: 成長するとお祝い、効果音、AI おしゃべりなどが解禁されます
  • お世話: ごはん・なでる・あそぶで、おなか・きげん・なかよし度が上がります(1日の回数に上限あり、XP は入りません)
  • ドット絵: ターミナルでは 16×16 のドット絵で表示します(それ以外では顔文字)
  • 日記: きょうのできごとを記録します

動作条件

  • Claude Code 2.1.289 以降で動作を確認しています
  • この mod は Claude Code の関数フック(mods)で作られています。関数フックは early access の機能で、Claude Code の版によって API が変わり、動かなくなることがあります
  • ドット絵と効果音はターミナル(macOS)向けです。デスクトップアプリなどでは顔文字で表示されます

インストール

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ヘルプ

データとプライバシー

  • 育成データは、Claude Code の設定ディレクトリの plugins/store/ にある coding-pet で始まる JSON ファイルに保存されます。外部には送りません
  • ペットが数えるのは、ツールの使用回数・ターン数・コミットやテストの回数・エラーの回数・支出(/cost と同じ値)などの件数だけです。プロンプトの本文、コマンド、ファイルのパスは保存も送信もしません(「ありがとう」を言ったかどうかの判定にだけプロンプトを見ます)
  • 日記に書くのは決まった文言だけです
  • AI おしゃべり(完全体で解禁)だけは、ペットの一言を作るために小さなモデル(Haiku)を最短1分に1回呼びます。送るのは種族・ペットの名前・気分・件数で、あなたの Claude Code の利用枠(トークン)を少し使います。/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

Source 6 files
hooks/register.tsx 875 lines
1import { 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}
875
hooks/achievements.ts 40 lines
1import 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
40
hooks/pet.ts 335 lines
1import 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}
335
hooks/rules.ts 53 lines
1import 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}
53
hooks/sprites.ts 306 lines
1import 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 }
306
types/index.d.ts 96 lines
1export 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