SLOPSHOPPER

hyday-pet

A virtual pet that lives above your Claude Code prompt and grows from the work Claude does: it evolves by what Claude does most, gets sick from tool errors…

newpanebandguardcommandtoast
★ 72v1.0.0MITupdated 2026-10-02mukiwu/muki-ai-plugins/plugins/hyday-pet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hyday-pet
│ ┃ pet ✕ › fix the failing auth test and add an audit log call ╭───────────╮ │ ┃ 啾啾 ● 12 │ hyday-pet │ │ ┃ 幼年期 · 0 天大 ⏺ Read(src/auth.ts) │ 啾啾 孵出來了! │ │ ┃ ⎿ Read 6 lines ╰───────────╯ │ ┃ ⏺ Update(src/auth.ts) │ ┃ .-"""-. ⎿ Added 2 lines, removed 1 line │ ┃ / \ ⏺ Bash(bun test) │ ┃ | - - | ⎿ 3 pass, 1 fail │ ┃ | w | │ ┃ \ / ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ '-...-' │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ 悠哉中 │ ┃ › /pet │ ┃ 飽足 ████████░░ 80 ⎿ hyday-pet: (^ω^) 啾啾(幼年期,0 天大,經驗 12) │ ┃ 心情 ████████░░ 80 ⎿ hyday-pet: 飽足 ████░ 80 心情 ████░ 80 精神 ████░ 80 │ ┃ 精神 ████████░░ 80 ⎿ hyday-pet: 病菌 ░░░░░ 0 │ ┃ 病菌 ░░░░░░░░░░ 0 ⎿ hyday-pet: 這階段的工作:改檔 33%、跑指令 44%、查資料 22% │ ┃ 成長 ░░░░░░░░░░ 差 138 │ ┃ 改檔 33% · 指令 44% · 查找 22% │ ┃ │ ┃ [ 餵食 ] [ 玩耍 ] [ 睡覺 ] │ ┃ [ 商店 ] [ 細節 ] │ ┃ [ 猜拳 ] [ 猜數字 ] │ ┃ [ 換色:粉紅 ] [ 收起 ] │ (^ω^) 啾啾 · 幼年期 ● 12 飽 ████░ 樂 ████░ 精 ████░ [ 餵食 ] [ 玩耍 ] [ 睡覺 ] [ 展開側邊欄 ] [ 收起 ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
(^ω^) 啾啾 · 幼年期 ● 12 飽 ████░ 樂 ████░ 精 ████░ [ 餵食 ] [ 玩耍 ] [ 睡覺 ] [ 展開側邊欄 ] [ 收起 ]
Pane · pet
啾啾 ● 12 幼年期 · 0 天大 .-"""-. / \ | - - | | w | \ / '-...-' 悠哉中 飽足 ████████░░ 80 心情 ████████░░ 80 精神 ████████░░ 80 病菌 ░░░░░░░░░░ 0 成長 ░░░░░░░░░░ 差 138 改檔 33% · 指令 44% · 查找 22% [ 餵食 ] [ 玩耍 ] [ 睡覺 ] [ 商店 ] [ 細節 ] [ 猜拳 ] [ 猜數字 ] [ 換色:粉紅 ] [ 收起 ]
README

hyday-pet

繁體中文版

A virtual pet that lives above your Claude Code prompt and feeds on the work you and Claude do together. The more Claude does, the faster it grows; the kind of work decides what species it becomes; tool errors make it sick, and green tests make it well again

(^ω^) 啾啾 · 幼年期  飽 ████░  樂 █████  精 ███░░  ● 42
[ 餵食 ] [ 玩耍 ] [ 睡覺 ] [ 展開側邊欄 ] [ 收起 ]

The interface is in Traditional Chinese

Install

This is a function-hooks mod, an early access feature. Turn it on in ~/.claude/settings.json:

{ "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }

Then add the marketplace and install:

claude plugin marketplace add mukiwu/muki-ai-plugins
claude plugin install hyday-pet@muki-ai-plugins

Restart Claude Code or run /reload-plugins

Claude Code only; npx skills add does not install this plugin

Growing up

  • It starts as an egg. Every tool call Claude makes is 1 XP, every finished turn is 3
  • 10 XP hatches it, 150 makes it a child, 800 an adult
  • Over time it gets hungry, bored and tired; sleep restores energy and it wakes up when rested
  • Every third meal it poops, and an uncleaned mess drains its mood faster
  • While Claude works, it cheers you on

Species

Each tool call counts as one of three kinds of work: editing files, running commands, or looking things up (MCP tools count as looking things up)

On becoming a child, the work it saw most as a baby decides the species:

Most common workSpecies
Editing filesMole (crafter)
Running commandsOtter (sailor)
Looking things upOwl (explorer)
Nothing above 45%Chimera (generalist)

On becoming an adult it branches once more by the work it saw as a child, giving a swimming mole, a pure-bred mole, and so on: 16 outcomes in all

Getting sick

  • A tool error adds 10 germs; a success removes 2
  • At 60 germs it falls sick: its stats drop faster and it refuses to play
  • A passing test command (vitest, jest, pytest, go test and the like) removes 20; while sick, any other success removes 4
  • Medicine removes 15 but tastes bad, costing 10 mood
  • Below 20 germs it recovers

Side pane

/pet open, or the button on the band, opens a pane with:

  • An animated ASCII pet that bounces, blinks, sparkles while Claude works, snores in its sleep and runs a fever when sick
  • Colored stat bars, germs, growth progress and this stage's work mix
  • Two mini games: rock paper scissors and guess the number
  • The shop and the detail page

In the fullscreen layout (/tui fullscreen) it docks beside the transcript; on the classic layout it sits above the prompt. The classic layout does not report mouse clicks, so focus the pane with ctrl+x then tab and use the hotkeys shown on the buttons

Coins and the shop

Earned byCoins
Claude finishing a turn2
A passing test run5
Winning rock paper scissors, guessing the number3, 8
Hatching, becoming a child, becoming an adult10, 30, 80

Premium food, fed on purchase:

FoodPriceEffect
Strawberry cake25Fullness +40, mood +15
Energy drink30Energy +40
Organic salad35Fullness +30, germs -15, no poop
Deluxe bento60Fullness to max, mood and energy +10

Color styles: pink and mono are free; mint and sky cost 150, gold 400, neon 800, rainbow 1200 and galaxy 1500. Each one has a live swatch in the shop, and the changing styles flow with the animation

Detail page

Birthday, coins, how many turns it has watched, errors and green tests seen, times sick, mini game record, and a log of hatching, evolving, falling sick and shopping

Commands

CommandWhat it does
/petShow status
/pet open, /pet closeOpen or close the side pane
/pet feed, play, sleep, wake, clean, healCare actions; Chinese words work too, like /pet 餵
/pet shop, /pet buy 蛋糕List the shop, buy something
/pet skin, /pet skin mintCycle through owned colors, or pick one
/pet detailDetail page
/pet name 小咪Rename it
/pet hide, /pet showHide or bring back the band above the prompt
/pet reset confirmGive up this pet and start over with a new egg

Save data

The pet lives in this plugin's own store, so every project and every open session share one pet. Each change reads the latest save first, so it never splits into two

Development

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir plugins/hyday-pet
claude plugin validate plugins/hyday-pet
claude plugin test plugins/hyday-pet

To restyle the pet, edit the ASCII art in hooks/sprites.ts; widths are padded for you. Keep the capital letters L, R and M out of the art: they are replaced with the eyes and mouth

Source 6 files
hooks/register.tsx 564 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Game, Pet, Skin, View } from '../types'
5import { cannotPlay, guess, GUESS_TRIES, HANDS, playRps, startGuess } from './games'
6import type { Hand } from './games'
7import { act, afterTool, ageDays, bar, coinsOf, detail, face, finishTurn, gainXp, germsOf, growth, hatch, mood, stage, summary, tick, titleOf, warnings, workShare } from './pet'
8import type { Action } from './pet'
9import { buyFood, buySkin, findFood, FOOD_ORDER, FOODS, owns, ownedSkins } from './shop'
10import type { FoodId } from './shop'
11import { accentColor, drawFrame, inkOn, previewColor, lineColor, SICK_COLOR, SKIN_ORDER, SKINS, skinOf, statColor } from './sprites'
12
13const DEFAULT_NAME = '啾啾'
14const TICK_MS = 60 * 1000
15const FRAME_MS = 500
16const PANE = 'pet'
17
18const petAtom = atom({ plugin: 'hyday-pet', key: 'pet' } as const, null)
19const isHidden = atom({ plugin: 'hyday-pet', key: 'isHidden' } as const, false)
20const isPaneOpen = atom({ plugin: 'hyday-pet', key: 'isPaneOpen' } as const, false)
21const isWorking = atom({ plugin: 'hyday-pet', key: 'isWorking' } as const, false)
22const frameAtom = atom({ plugin: 'hyday-pet', key: 'frame' } as const, 0)
23const gameAtom = atom({ plugin: 'hyday-pet', key: 'game' } as const, null)
24const viewAtom = atom({ plugin: 'hyday-pet', key: 'view' } as const, 'home')
25
26const ACTIONS: Record<string, Action> = {
27  feed: 'feed', 餵: 'feed', 餵食: 'feed',
28  play: 'play', 玩: 'play',
29  sleep: 'sleep', 睡: 'sleep', 睡覺: 'sleep',
30  wake: 'wake', 醒: 'wake', 叫醒: 'wake',
31  clean: 'clean', 清: 'clean', 清理: 'clean',
32  heal: 'heal', 藥: 'heal', 吃藥: 'heal', 餵藥: 'heal',
33}
34
35// 先讀 $.store(可能有別的 session 剛寫過),補上經過的時間,再套用變化,最後寫回兩邊
36const commit = async ($: EngineInterface, change: (pet: Pet) => Pet = p => p) => {
37  const now = await $.clock.now()
38  const stored = (await $.store.get('pet')) as Pet | undefined
39  // 舊存檔沒有已擁有的顏色清單,第一次讀到就把當時正在用的顏色記成已擁有
40  const loaded = stored ?? hatch(DEFAULT_NAME, now)
41  const before = loaded.ownedSkins ? loaded : { ...loaded, ownedSkins: ownedSkins(loaded) }
42  const after = change(tick(before, now))
43  await $.store.set('pet', after)
44  await update($, petAtom, () => after)
45  for (const line of warnings(before, after)) $.ui.toast(line)
46  return after
47}
48
49const perform = async ($: EngineInterface, action: Action | ((pet: Pet) => Action)) => {
50  let say = ''
51  await commit($, pet => {
52    const out = act(pet, typeof action === 'function' ? action(pet) : action)
53    say = out.say
54    return out.pet
55  })
56  return say
57}
58
59const setHidden = async ($: EngineInterface, value: boolean) => {
60  await $.store.set('hidden', value)
61  await update($, isHidden, () => value)
62}
63
64const findSkin = (word: string): Skin | undefined =>
65  SKIN_ORDER.find(skin => skin === word || SKINS[skin].label === word)
66
67// 指定顏色:買過就換上,沒買過就說價錢;不指定就在買過的裡面輪流換
68const setSkin = async ($: EngineInterface, skin?: Skin) => {
69  let say = ''
70  await commit($, p => {
71    if (skin && !owns(p, skin)) {
72      say = `${SKINS[skin].label}還沒買,商店賣 ${SKINS[skin].price} 金幣(/pet buy ${SKINS[skin].label})`
73      return p
74    }
75    const owned = SKIN_ORDER.filter(s => owns(p, s))
76    const next = skin ?? owned[(owned.indexOf(skinOf(p)) + 1) % owned.length] ?? 'pink'
77    say = `換成${SKINS[next].label}了`
78    return { ...p, skin: next }
79  })
80  return say
81}
82
83const sell = async ($: EngineInterface, item: { food: FoodId } | { skin: Skin }) => {
84  let say = ''
85  await commit($, p => {
86    const sale = 'food' in item ? buyFood(p, item.food) : buySkin(p, item.skin)
87    say = sale.say
88    return sale.pet
89  })
90  return say
91}
92
93const goTo = ($: EngineInterface, view: View) => update($, viewAtom, () => view)
94
95const randomInt = (max: number) => Math.floor(Math.random() * max)
96
97// 小遊戲:開局前先確認寵物有沒有力氣玩
98const startGame = async ($: EngineInterface, kind: Game['kind']) => {
99  const pet = await commit($)
100  const reason = cannotPlay(pet)
101  if (reason) {
102    $.ui.toast(reason)
103    return
104  }
105  const game: Game =
106    kind === 'rps'
107      ? { kind: 'rps', line: `跟 ${pet.name} 猜拳,出什麼?` }
108      : startGuess(pet, randomInt(9) + 1)
109  await update($, gameAtom, () => game)
110}
111
112const playHand = async ($: EngineInterface, mine: Hand) => {
113  let game: Game | null = null
114  let reason: string | undefined
115  await commit($, pet => {
116    reason = cannotPlay(pet)
117    if (reason) return pet
118    const out = playRps(pet, mine, randomInt(3) as Hand)
119    game = out.game
120    return out.pet
121  })
122  if (reason) $.ui.toast(reason)
123  if (game) await update($, gameAtom, () => game)
124}
125
126const playGuess = async ($: EngineInterface, n: number) => {
127  const current = await read($, gameAtom)
128  if (current?.kind !== 'guess') return
129  let game: Game = current
130  await commit($, pet => {
131    const out = guess(pet, current, n)
132    game = out.game
133    return out.pet
134  })
135  await update($, gameAtom, () => game)
136}
137
138const leaveGame = ($: EngineInterface) => update($, gameAtom, () => null)
139
140// 側邊欄開著才推動畫格,關掉就停掉計時器
141let animation: Timer | undefined
142
143const stopAnimation = () => {
144  animation?.cancel()
145  animation = undefined
146}
147
148// 先開側邊欄再記狀態:沒放出來(例如視窗太窄)就跳通知說原因,橫條也不會躲起來
149const openPane = async ($: EngineInterface, isAsked = true) => {
150  const pet = await read($, petAtom)
151  const opened = await $.ui.open({ id: PANE, title: pet?.name ?? DEFAULT_NAME, columns: 32, ...(isAsked ? { focus: true } : {}) })
152  animation ??= $.clock.every(FRAME_MS, () => void update($, frameAtom, n => n + 1))
153  await $.store.set('paneOpen', true)
154  await update($, isPaneOpen, () => true)
155  if (!opened.isPlaced && isAsked) $.ui.toast(`側邊欄放不下:${opened.reason}`)
156  return opened
157}
158
159// 側邊欄真的畫在畫面上才算數,開了但在等位置的不算
160const isPaneShown = async ($: EngineInterface) =>
161  (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced && pane.isShown)
162
163const markClosed = async ($: EngineInterface, isRemembered: boolean) => {
164  stopAnimation()
165  await update($, isPaneOpen, () => false)
166  if (isRemembered) await $.store.set('paneOpen', false)
167}
168
169// 自己呼叫 $.ui.close 時,自己的 ui.close hook 不一定收得到,所以先把狀態清掉
170const closePane = async ($: EngineInterface) => {
171  await markClosed($, true)
172  await $.ui.close({ id: PANE })
173}
174
175export const register: Register = on => {
176  on('session.start', async ($, e, next) => {
177    await $.command.register({
178      name: 'pet',
179      description: '照顧你的電子雞:餵食、玩耍、睡覺、清理,或打開側邊欄',
180      argumentHint: 'open | shop | buy <東西> | detail | feed | play | sleep | wake | clean | heal | skin <顏色> | name <名字> | hide | show',
181    })
182    const hidden = (await $.store.get('hidden')) === true
183    await update($, isHidden, () => hidden)
184    await commit($)
185    $.clock.every(TICK_MS, () => void commit($))
186    if ((await $.store.get('paneOpen')) === true) void openPane($, false)
187
188    return next(e)
189  })
190
191  on('command.run', { command: 'pet' }, async ($, e) => {
192    const [word = '', ...rest] = e.args.trim().split(/\s+/)
193    const now = await $.clock.now()
194
195    if (word === '' || word === 'status') {
196      const pet = await commit($)
197      return { text: summary(pet, now) }
198    }
199    if (word === 'open' || word === '開') {
200      const opened = await openPane($)
201      return {
202        text: opened.isPlaced
203          ? '側邊欄打開了(全螢幕模式下會停在對話旁邊)'
204          : '側邊欄已經開了,但這個視窗太窄放不下,拉寬一點就會出現',
205      }
206    }
207    if (word === 'close' || word === '關') {
208      await closePane($)
209      return { text: '側邊欄收起來了,/pet open 可以再打開' }
210    }
211    if (word === 'hide' || word === 'show') {
212      await setHidden($, word === 'hide')
213      return { text: word === 'hide' ? '收起來了,/pet show 可以叫回來' : '牠回來了' }
214    }
215    if (word === 'skin' || word === '顏色') {
216      const wanted = rest[0]
217      const skin = wanted ? findSkin(wanted) : undefined
218      if (wanted && !skin) {
219        return { text: `沒有 ${wanted} 這個顏色,可以選:${SKIN_ORDER.map(s => `${s}(${SKINS[s].label})`).join('、')}` }
220      }
221      return { text: await setSkin($, skin) }
222    }
223    if (word === 'shop' || word === '商店') {
224      const pet = await commit($)
225      const foods = FOOD_ORDER.map(id => `  ${FOODS[id].label}  ${FOODS[id].price} 金幣  ${FOODS[id].effect}`)
226      const skins = SKIN_ORDER.filter(s => SKINS[s].price > 0).map(
227        s => `  ${SKINS[s].label}  ${owns(pet, s) ? '已擁有' : `${SKINS[s].price} 金幣`}`,
228      )
229      return {
230        text: [`你有 ${coinsOf(pet)} 金幣`, '高級食物(買了直接餵)', ...foods, '顏色樣式', ...skins, '用 /pet buy 蛋糕 這樣買'].join('\n'),
231      }
232    }
233    if (word === 'buy' || word === '買') {
234      const wanted = rest.join('')
235      const food = findFood(wanted)
236      const skin = findSkin(wanted)
237      if (food) return { text: await sell($, { food }) }
238      if (skin) return { text: await sell($, { skin }) }
239      return { text: wanted ? `商店沒有賣 ${wanted},打 /pet shop 看看有什麼` : '要買什麼?打 /pet shop 看看有什麼' }
240    }
241    if (word === 'detail' || word === '細節') {
242      const pet = await commit($)
243      const d = detail(pet, 15)
244      return { text: [...d.facts, '', '大事記', ...d.log.map(line => `  ${line}`)].join('\n') }
245    }
246    if (word === 'name') {
247      const name = rest.join(' ').trim().slice(0, 12)
248      if (!name) return { text: '要取什麼名字?例如 /pet name 小咪' }
249      await commit($, pet => ({ ...pet, name }))
250      if (await read($, isPaneOpen)) await $.ui.open({ id: PANE, title: name })
251      return { text: `從現在起牠叫 ${name}` }
252    }
253    if (word === 'reset') {
254      if (rest[0] !== 'confirm') return { text: '這會丟掉現在這隻,重新拿一顆蛋。確定的話打 /pet reset confirm' }
255      await $.store.delete('pet')
256      await commit($, () => hatch(DEFAULT_NAME, now))
257      return { text: '拿到一顆新的蛋 🥚' }
258    }
259
260    const action = ACTIONS[word]
261    if (!action) return { text: `看不懂 ${word},可以用 open、close、shop、buy、detail、feed、play、sleep、wake、clean、heal、skin、name、hide、show` }
262    return { text: await perform($, action) }
263  })
264
265  on('ui.close', async ($, e, next) => {
266    const closed = await next(e)
267    if (e.id === PANE) await markClosed($, e.origin.kind !== 'unload')
268    return closed
269  })
270
271  on('prompt.submit', async ($, e, next) => {
272    await update($, isWorking, () => true)
273    return next(e)
274  })
275
276  // Claude 做事,寵物就長經驗:每次工具呼叫 +1,每輪結束 +3
277  // 也記下做的是哪種工作(決定物種),報錯會長病菌
278  on('tool.call', async ($, e, next) => {
279    const ran = await next(e)
280    if (ran.deny !== undefined) return ran
281    const outcome = {
282      tool: String(e.tool),
283      isError: ran.isError === true,
284      command: e.tool === 'Bash' ? e.command : undefined,
285    }
286    void commit($, pet => gainXp(afterTool(pet, outcome), 1))
287    return ran
288  })
289
290  on('turn.complete', async ($, e, next) => {
291    await update($, isWorking, () => false)
292    void commit($, finishTurn)
293    return next(e)
294  })
295
296  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
297    const { Box, Button, Text } = $.ui.resolve(e)
298    const pet = await read($, petAtom)
299    if (!pet) return <Text dimColor>寵物還在路上…</Text>
300
301    const frame = await read($, frameAtom)
302    const working = await read($, isWorking)
303    const game = await read($, gameAtom)
304    const view = await read($, viewAtom)
305    const now = await $.clock.now()
306    const skin = skinOf(pet)
307    const art = drawFrame(pet, working, frame)
308    const s = stage(pet)
309    const g = growth(pet)
310    const germs = germsOf(pet)
311    const share = workShare(pet)
312    const say = (p: Promise<string>) => void p.then(text => $.ui.toast(text))
313    const isBrowsing = game === null && view !== 'home'
314
315    const meter = (label: string, n: number, color: string | undefined, tail: string) => (
316      <Text wrap="truncate">
317        <Text dimColor>{label} </Text>
318        <Text color={color}>{bar(n, 10)}</Text>
319        <Text dimColor> {tail}</Text>
320      </Text>
321    )
322    const stat = (label: string, n: number) => meter(label, n, statColor(n), String(Math.round(n)).padStart(3))
323
324    const home = (
325      <Box flexDirection="column" alignItems="center">
326        {s !== 'egg' && (
327          <Box flexWrap="wrap" justifyContent="center" columnGap={1}>
328            <Button key="p-feed" hotkey="1" label="餵食" variant="primary" onPress={() => say(perform($, 'feed'))} />
329            <Button key="p-play" hotkey="2" label="玩耍" onPress={() => say(perform($, 'play'))} />
330            <Button
331              key="p-sleep"
332              hotkey="3"
333              label={pet.isAsleep ? '叫醒' : '睡覺'}
334              onPress={() => say(perform($, p => (p.isAsleep ? 'wake' : 'sleep')))}
335            />
336            {pet.poops > 0 && <Button key="p-clean" hotkey="4" label="清理" onPress={() => say(perform($, 'clean'))} />}
337            {germs > 0 && <Button key="p-heal" hotkey="m" label="吃藥" onPress={() => say(perform($, 'heal'))} />}
338          </Box>
339        )}
340        <Box columnGap={1}>
341          <Button key="p-shop" hotkey="s" label="商店" onPress={() => void goTo($, 'shop')} />
342          <Button key="p-detail" hotkey="i" label="細節" onPress={() => void goTo($, 'detail')} />
343        </Box>
344        {s !== 'egg' && (
345          <Box columnGap={1}>
346            <Button key="p-rps" hotkey="g" label="猜拳" onPress={() => void startGame($, 'rps')} />
347            <Button key="p-guess" hotkey="n" label="猜數字" onPress={() => void startGame($, 'guess')} />
348          </Box>
349        )}
350        <Box columnGap={1}>
351          <Button key="p-skin" hotkey="c" label={`換色:${SKINS[skin].label}`} dimColor onPress={() => say(setSkin($))} />
352          <Button key="p-close" role="dismiss" label="收起" dimColor onPress={() => void closePane($)} />
353        </Box>
354      </Box>
355    )
356
357    const rps = (
358      <Box flexDirection="column" alignItems="center">
359        <Text wrap="wrap">{game?.line ?? ''}</Text>
360        <Box columnGap={1}>
361          {HANDS.map((hand, i) => (
362            <Button key={`rps-${i}`} hotkey={String(i + 1)} label={hand} onPress={() => void playHand($, i as Hand)} />
363          ))}
364        </Box>
365        <Button key="game-back" hotkey="b" label="不玩了" dimColor onPress={() => void leaveGame($)} />
366      </Box>
367    )
368
369    const back = <Button key="view-back" hotkey="b" label="返回" dimColor onPress={() => void goTo($, 'home')} />
370
371    // 每個字用那個顏色當背景,會變化的顏色逐字換色
372    const preview = (text: string, sk: Skin) => (
373      <Text wrap="truncate">
374        {[...text].map((ch, i) => {
375          const bg = previewColor(sk, i, frame) ?? '#888888'
376          return (
377            <Text backgroundColor={bg} color={inkOn(bg)}>
378              {ch}
379            </Text>
380          )
381        })}
382      </Text>
383    )
384
385    const shopView = (
386      <Box flexDirection="column" alignItems="flex-start">
387        <Text bold>商店 · 你有 {coinsOf(pet)} 金幣</Text>
388        <Text> </Text>
389        <Text dimColor>高級食物,買了直接餵</Text>
390        {FOOD_ORDER.map((id, i) => (
391          <Box columnGap={1}>
392            <Button
393              key={`buy-${id}`}
394              hotkey={String(i + 1)}
395              label={`${FOODS[id].label} ${String(FOODS[id].price).padStart(2)}`}
396              dimColor={coinsOf(pet) < FOODS[id].price}
397              onPress={() => say(sell($, { food: id }))}
398            />
399            <Text dimColor wrap="truncate">{FOODS[id].effect}</Text>
400          </Box>
401        ))}
402        <Text> </Text>
403        <Text dimColor>顏色樣式</Text>
404        {SKIN_ORDER.filter(sk => SKINS[sk].price > 0).map((sk, i) => {
405          const isOwned = owns(pet, sk)
406          return (
407            <Box columnGap={1}>
408              <Button
409                key={`skin-${sk}`}
410                hotkey={String((i + 5) % 10)}
411                label={sk === skin ? '使用中' : isOwned ? ' 換上 ' : ' 購買 '}
412                variant={sk === skin ? 'primary' : undefined}
413                dimColor={!isOwned && coinsOf(pet) < SKINS[sk].price}
414                onPress={() => say(sell($, { skin: sk }))}
415              />
416              {preview(` ${SKINS[sk].label} `, sk)}
417              {!isOwned && <Text dimColor>{SKINS[sk].price} 金幣</Text>}
418            </Box>
419          )
420        })}
421        <Text> </Text>
422        {back}
423      </Box>
424    )
425
426    const info = detail(pet)
427    const detailView = (
428      <Box flexDirection="column" alignItems="flex-start">
429        {info.facts.map(line => (
430          <Text wrap="truncate">{line}</Text>
431        ))}
432        <Text> </Text>
433        <Text bold>大事記</Text>
434        {info.log.length === 0 && <Text dimColor>還沒有發生什麼事</Text>}
435        {info.log.map(line => (
436          <Text dimColor wrap="truncate">
437            {line}
438          </Text>
439        ))}
440        <Text> </Text>
441        {back}
442      </Box>
443    )
444
445    const guessing = game?.kind === 'guess' ? game : undefined
446    const guessView = guessing && (
447      <Box flexDirection="column" alignItems="center">
448        <Text wrap="wrap">{guessing.line}</Text>
449        {!guessing.isOver && <Text dimColor>{'●'.repeat(guessing.triesLeft)}{'○'.repeat(GUESS_TRIES - guessing.triesLeft)}</Text>}
450        {!guessing.isOver && (
451          <Box flexWrap="wrap" justifyContent="center" columnGap={1}>
452            {[1, 2, 3, 4, 5, 6, 7, 8, 9].map(n => (
453              <Button key={`guess-${n}`} hotkey={String(n)} label={String(n)} onPress={() => void playGuess($, n)} />
454            ))}
455          </Box>
456        )}
457        <Box columnGap={1}>
458          {guessing.isOver && (
459            <Button key="guess-again" hotkey="r" label="再玩一次" variant="primary" onPress={() => void startGame($, 'guess')} />
460          )}
461          <Button key="game-back" hotkey="b" label="不玩了" dimColor onPress={() => void leaveGame($)} />
462        </Box>
463      </Box>
464    )
465
466    return (
467      <Box flexDirection="column" alignItems="center" paddingX={1}>
468        <Text wrap="truncate">
469          <Text bold color={accentColor(skin, frame)}>{pet.name}</Text>
470          <Text color="#ffd866">  ● {coinsOf(pet)}</Text>
471        </Text>
472        <Text dimColor wrap="truncate">
473          {titleOf(pet)} · {ageDays(pet, now)} 天大
474        </Text>
475        <Text color={pet.isSick ? SICK_COLOR : accentColor(skin, frame)}>{art.effect || ' '}</Text>
476        <Box flexDirection="column" alignItems="flex-start">
477          {art.body.map((line, row) => (
478            <Text bold color={pet.isSick ? SICK_COLOR : lineColor(skin, row, frame)}>
479              {line || ' '}
480            </Text>
481          ))}
482        </Box>
483        <Text>{pet.poops > 0 ? '💩'.repeat(Math.min(pet.poops, 5)) : ' '}</Text>
484        <Text italic dimColor>
485          {art.caption}
486        </Text>
487        <Text> </Text>
488        {isBrowsing ? null : <Box flexDirection="column" alignItems="center">
489        {s !== 'egg' && stat('飽足', pet.hunger)}
490        {s !== 'egg' && stat('心情', pet.joy)}
491        {s !== 'egg' && stat('精神', pet.energy)}
492        {s !== 'egg' && meter('病菌', germs, germs >= 60 ? '#ff6188' : germs >= 30 ? '#ffd866' : '#78dce8', pet.isSick ? '生病' : String(Math.round(germs)).padStart(3))}
493        {g ? meter('成長', g.percent, accentColor(skin, frame) ?? 'cyan', `差 ${g.left}`) : <Text dimColor>已經完全長大了</Text>}
494        {s !== 'adult' && (
495          <Text dimColor wrap="truncate">
496            改檔 {share.build}% · 指令 {share.run}% · 查找 {share.explore}%
497          </Text>
498        )}
499        </Box>}
500        <Text> </Text>
501        {game !== null ? (game.kind === 'rps' ? rps : guessView) : view === 'shop' ? shopView : view === 'detail' ? detailView : home}
502      </Box>
503    )
504  })
505
506  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
507    const pet = await read($, petAtom)
508    const isQuiet =
509      e.props.hasSurvey || pet === null || (await read($, isHidden)) || ((await read($, isPaneOpen)) && (await isPaneShown($)))
510    if (isQuiet || pet === null) {
511      return next(e)
512    }
513
514    const { Box, Button, Text } = $.ui.resolve(e)
515    const isWide = e.props.bodyColumns >= 72
516    const s = stage(pet)
517    const skin = skinOf(pet)
518    const tone = mood(pet) < 30 || pet.hunger < 20 ? '#ff6188' : lineColor(skin, 0, 0)
519    const say = (p: Promise<string>) => void p.then(text => $.ui.toast(text))
520
521    return (
522      <Box flexDirection="column">
523        <Text wrap="truncate">
524          <Text color={tone} bold>
525            {face(pet, e.props.isWorking)}
526          </Text>
527          <Text color={accentColor(skin, 0)}> {pet.name}</Text>
528          <Text dimColor> · {titleOf(pet)}</Text>
529          {pet.isSick && <Text color="#ff6188"> 生病中</Text>}
530          {s !== 'egg' && <Text color="#ffd866">  ● {coinsOf(pet)}</Text>}
531          {s !== 'egg' && <Text dimColor>  飽 </Text>}
532          {s !== 'egg' && <Text color={statColor(pet.hunger)}>{bar(pet.hunger)}</Text>}
533          {s !== 'egg' && <Text dimColor>  樂 </Text>}
534          {s !== 'egg' && <Text color={statColor(pet.joy)}>{bar(pet.joy)}</Text>}
535          {s !== 'egg' && <Text dimColor>  精 </Text>}
536          {s !== 'egg' && <Text color={statColor(pet.energy)}>{bar(pet.energy)}</Text>}
537          {s === 'egg' && <Text dimColor>  孵化中 {pet.xp}/10</Text>}
538          {pet.poops > 0 && <Text>  {'💩'.repeat(Math.min(pet.poops, 5))}</Text>}
539        </Text>
540        <Box columnGap={1}>
541          {s !== 'egg' && <Button key="feed" hotkey="1" label="餵食" onPress={() => say(perform($, 'feed'))} />}
542          {s !== 'egg' && <Button key="play" hotkey="2" label="玩耍" onPress={() => say(perform($, 'play'))} />}
543          {s !== 'egg' && (
544            <Button
545              key="sleep"
546              hotkey="3"
547              label={pet.isAsleep ? '叫醒' : '睡覺'}
548              onPress={() => say(perform($, p => (p.isAsleep ? 'wake' : 'sleep')))}
549            />
550          )}
551          {s !== 'egg' && pet.poops > 0 && (
552            <Button key="clean" hotkey="4" label="清理" onPress={() => say(perform($, 'clean'))} />
553          )}
554          {s !== 'egg' && pet.isSick && (
555            <Button key="heal" hotkey="m" label="吃藥" variant="primary" onPress={() => say(perform($, 'heal'))} />
556          )}
557          <Button key="open" hotkey="o" label="展開側邊欄" dimColor onPress={() => void openPane($)} />
558          {isWide && <Button key="hide" label="收起" dimColor onPress={() => void setHidden($, true)} />}
559        </Box>
560      </Box>
561    )
562  })
563}
564
hooks/games.ts 73 lines
1import type { Game, Pet } from '../types'
2import { addLog, bump, earn, gainXp, stage } from './pet'
3
4const clamp = (n: number) => Math.max(0, Math.min(100, n))
5
6// 玩不了的時候回傳原因
7export const cannotPlay = (pet: Pet): string | undefined => {
8  if (stage(pet) === 'egg') return `${pet.name} 還是一顆蛋`
9  if (pet.isAsleep) return `${pet.name} 在睡覺`
10  if (pet.isSick) return `${pet.name} 生病了,不想玩`
11  if (pet.energy < 10) return `${pet.name} 太累了,讓牠睡一下吧`
12  return undefined
13}
14
15// 每玩一局都會耗體力、肚子會餓,心情、經驗和金幣看輸贏
16const reward = (pet: Pet, joy: number, xp: number, coins: number, isWin: boolean): Pet => {
17  const played = bump(earn(pet, coins), 'games')
18  const tired = {
19    ...(isWin ? bump(played, 'wins') : played),
20    joy: clamp(pet.joy + joy),
21    energy: clamp(pet.energy - 6),
22    hunger: clamp(pet.hunger - 3),
23  }
24  // 經驗走 gainXp,玩遊戲剛好跨過門檻也會進化
25  return gainXp(tired, xp)
26}
27
28export const HANDS = ['石頭', '剪刀', '布'] as const
29export type Hand = 0 | 1 | 2
30
31// 0 石頭贏 1 剪刀,1 剪刀贏 2 布,2 布贏 0 石頭
32export const judge = (mine: Hand, theirs: Hand): 'win' | 'lose' | 'draw' =>
33  mine === theirs ? 'draw' : (mine + 1) % 3 === theirs ? 'win' : 'lose'
34
35export const playRps = (pet: Pet, mine: Hand, theirs: Hand): { pet: Pet; game: Game } => {
36  const result = judge(mine, theirs)
37  const verdict = { win: '你贏了!金幣 +3', lose: `${pet.name} 贏了,得意洋洋`, draw: '平手,再一次' }[result]
38  const line = `你出${HANDS[mine]},${pet.name} 出${HANDS[theirs]},${verdict}`
39  // 寵物贏了也開心,只是你贏的時候牠學到比較多
40  const next =
41    result === 'win' ? reward(pet, 15, 2, 3, true) : result === 'lose' ? reward(pet, 10, 1, 1, false) : reward(pet, 8, 1, 1, false)
42  return { pet: next, game: { kind: 'rps', line } }
43}
44
45export const GUESS_TRIES = 3
46
47export const startGuess = (pet: Pet, secret: number): Game => ({
48  kind: 'guess',
49  secret,
50  triesLeft: GUESS_TRIES,
51  line: `${pet.name} 心裡想了一個 1 到 9 的數字,你有 ${GUESS_TRIES} 次機會`,
52  isOver: false,
53})
54
55export const guess = (pet: Pet, game: Extract<Game, { kind: 'guess' }>, n: number): { pet: Pet; game: Game } => {
56  if (game.isOver) return { pet, game }
57  if (n === game.secret) {
58    return {
59      pet: addLog(reward(pet, 25, 3, 8, true), '猜數字猜中了'),
60      game: { ...game, isOver: true, line: `猜中了!就是 ${n},${pet.name} 開心得轉圈圈,金幣 +8` },
61    }
62  }
63  const triesLeft = game.triesLeft - 1
64  if (triesLeft === 0) {
65    return {
66      pet: reward(pet, 8, 1, 1, false),
67      game: { ...game, triesLeft, isOver: true, line: `沒猜到,答案是 ${game.secret},${pet.name} 偷笑` },
68    }
69  }
70  const hint = n < game.secret ? '再大一點' : '再小一點'
71  return { pet, game: { ...game, triesLeft, line: `不是 ${n},${hint}(還剩 ${triesLeft} 次)` } }
72}
73
hooks/pet.ts 368 lines
1import type { Lineage, Pet, Stats, Work } from '../types'
2
3const HOUR = 60 * 60 * 1000
4
5// 每小時的變化量
6const DECAY = {
7  hunger: 8,
8  joy: 5,
9  energyAwake: 4,
10  energyAsleep: 20,
11  hungerAsleep: 4,
12}
13
14// 生病時數值掉得比較快
15const SICK_DECAY = 1.5
16
17// 病菌:報錯加、成功減,跨過門檻就生病或康復
18const GERMS = {
19  onError: 10,
20  onSuccess: 2,
21  onSuccessWhileSick: 4,
22  onTestPass: 20,
23  onMedicine: 15,
24  sickAt: 60,
25  curedAt: 20,
26}
27
28const clamp = (n: number) => Math.max(0, Math.min(100, n))
29
30// 金幣:常用的小東西一天內買得到,最貴的顏色要存一兩週
31export const COINS = {
32  perTurn: 2,
33  perTestPass: 5,
34  hatch: 10,
35  child: 30,
36  adult: 80,
37}
38
39const NO_STATS: Stats = {
40  turns: 0,
41  tools: 0,
42  errors: 0,
43  testPasses: 0,
44  games: 0,
45  wins: 0,
46  sickTimes: 0,
47  foods: 0,
48  coinsEarned: 0,
49}
50
51const LOG_LIMIT = 30
52
53export const coinsOf = (pet: Pet) => pet.coins ?? 0
54export const statsOf = (pet: Pet): Stats => ({ ...NO_STATS, ...pet.stats })
55
56export const earn = (pet: Pet, coins: number): Pet => {
57  const stats = statsOf(pet)
58  return { ...pet, coins: coinsOf(pet) + coins, stats: { ...stats, coinsEarned: stats.coinsEarned + coins } }
59}
60
61export const bump = (pet: Pet, key: keyof Stats, by = 1): Pet => {
62  const stats = statsOf(pet)
63  return { ...pet, stats: { ...stats, [key]: stats[key] + by } }
64}
65
66// 大事記用寵物最後一次更新的時間當作發生時間
67export const addLog = (pet: Pet, text: string): Pet => ({
68  ...pet,
69  log: [...(pet.log ?? []), { at: pet.lastTick, text }].slice(-LOG_LIMIT),
70})
71
72const NO_WORK: Work = { build: 0, run: 0, explore: 0 }
73
74export const hatch = (name: string, now: number): Pet => ({
75  name,
76  bornAt: now,
77  lastTick: now,
78  hunger: 80,
79  joy: 80,
80  energy: 80,
81  xp: 0,
82  poops: 0,
83  feeds: 0,
84  isAsleep: false,
85  work: NO_WORK,
86  germs: 0,
87  isSick: false,
88  coins: 0,
89  stats: NO_STATS,
90  log: [{ at: now, text: `拿到一顆蛋,取名${name}` }],
91})
92
93// 依經過時間扣數值;睡飽了自己醒來
94export const tick = (pet: Pet, now: number): Pet => {
95  const hours = Math.max(0, now - pet.lastTick) / HOUR
96  if (hours === 0) return pet
97
98  const sick = pet.isSick ? SICK_DECAY : 1
99  const messy = 1 + pet.poops * 0.5
100  const energy = pet.isAsleep
101    ? pet.energy + DECAY.energyAsleep * hours
102    : pet.energy - DECAY.energyAwake * hours
103  const next: Pet = {
104    ...pet,
105    lastTick: now,
106    hunger: clamp(pet.hunger - (pet.isAsleep ? DECAY.hungerAsleep : DECAY.hunger) * sick * hours),
107    joy: clamp(pet.joy - DECAY.joy * messy * sick * hours),
108    energy: clamp(energy),
109  }
110  return next.isAsleep && next.energy >= 100 ? { ...next, isAsleep: false } : next
111}
112
113export type Action = 'feed' | 'play' | 'sleep' | 'wake' | 'clean' | 'heal'
114
115export type Outcome = { pet: Pet; say: string }
116
117export const act = (pet: Pet, action: Action): Outcome => {
118  const n = pet.name
119  if (stage(pet) === 'egg' && action !== 'clean') {
120    return { pet, say: `${n} 還是一顆蛋,讓 Claude 多做點事,牠就會孵出來` }
121  }
122  if (pet.isAsleep && action !== 'wake') {
123    return { pet, say: `${n} 在睡覺,先別吵牠(/pet wake 可以叫醒)` }
124  }
125
126  switch (action) {
127    case 'feed': {
128      if (pet.hunger >= 95) {
129        return { pet: { ...pet, joy: clamp(pet.joy - 5) }, say: `${n} 吃不下了,還被你塞得有點不高興` }
130      }
131      const feeds = pet.feeds + 1
132      const poops = feeds % 3 === 0 ? pet.poops + 1 : pet.poops
133      const fed = { ...pet, hunger: clamp(pet.hunger + 25), feeds, poops }
134      return { pet: fed, say: poops > pet.poops ? `${n} 吃飽了,順便大了一坨 💩` : `${n} 吃得很開心` }
135    }
136    case 'play': {
137      if (pet.isSick) return { pet, say: `${n} 生病了,不想玩` }
138      if (pet.energy < 15) return { pet, say: `${n} 太累了,讓牠睡一下吧` }
139      return {
140        pet: { ...pet, joy: clamp(pet.joy + 20), energy: clamp(pet.energy - 12), hunger: clamp(pet.hunger - 5) },
141        say: `${n} 玩得好開心`,
142      }
143    }
144    case 'sleep':
145      return { pet: { ...pet, isAsleep: true }, say: `${n} 睡著了 zZ` }
146    case 'wake':
147      if (!pet.isAsleep) return { pet, say: `${n} 本來就醒著` }
148      return { pet: { ...pet, isAsleep: false, joy: clamp(pet.joy - 5) }, say: `${n} 被叫醒,有點起床氣` }
149    case 'clean':
150      if (pet.poops === 0) return { pet, say: '沒有東西要清' }
151      return { pet: { ...pet, poops: 0, joy: clamp(pet.joy + 5) }, say: `清乾淨了,${n} 舒服多了` }
152    case 'heal': {
153      if (germsOf(pet) === 0) return { pet, say: `${n} 很健康,不用吃藥` }
154      const healed = withGerms({ ...pet, joy: clamp(pet.joy - 10) }, germsOf(pet) - GERMS.onMedicine)
155      return { pet: healed, say: healed.isSick ? `${n} 吃了藥,苦著臉,還要再休養` : `${n} 吃了藥,好苦,但舒服多了` }
156    }
157  }
158}
159
160// Claude 每做一件事,寵物就吸收一點經驗;跨進成長期、成熟期時決定物種
161export const gainXp = (pet: Pet, amount: number): Pet => {
162  const next = { ...pet, xp: pet.xp + amount }
163  const from = stage(pet)
164  const to = stage(next)
165  if (from === to) return next
166  if (to === 'baby') return addLog(earn(next, COINS.hatch), '孵出來了')
167  if (to === 'child') {
168    const grown = { ...next, species: dominant(workOf(pet)), work: NO_WORK }
169    return addLog(earn(grown, COINS.child), `長成${SPECIES[grown.species].name}(${SPECIES[grown.species].family})`)
170  }
171  const adult = { ...next, trait: dominant(workOf(pet)), work: NO_WORK }
172  return addLog(earn(adult, COINS.adult), `長大成${speciesLabel(adult) ?? '成熟期'}`)
173}
174
175// 一輪對話結束:經驗 +3、金幣 +2
176export const finishTurn = (pet: Pet): Pet => bump(earn(gainXp(pet, 3), COINS.perTurn), 'turns')
177
178// 把工具歸到三種工作,不屬於任何一種就回傳 undefined
179export const classify = (tool: string): keyof Work | undefined => {
180  if (['Edit', 'Write', 'NotebookEdit', 'MultiEdit'].includes(tool)) return 'build'
181  if (['Bash', 'PowerShell', 'Monitor'].includes(tool)) return 'run'
182  if (['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'LSP', 'Agent'].includes(tool)) return 'explore'
183  if (tool.startsWith('mcp__')) return 'explore'
184  return undefined
185}
186
187const TEST_COMMAND = /\b(test|tests|vitest|jest|pytest|mocha|rspec|phpunit|ctest)\b|\b(go|cargo|bun|deno|swift|dotnet|mix) test\b|\bnpm t\b/
188
189export const isTestCommand = (command: string) => TEST_COMMAND.test(command)
190
191export type ToolOutcome = { tool: string; isError: boolean; command?: string }
192
193// 一次工具呼叫的結果:記工作量,報錯長病菌,成功消一點,測試綠燈消很多
194export const afterTool = (pet: Pet, outcome: ToolOutcome): Pet => {
195  const kind = classify(outcome.tool)
196  const work = workOf(pet)
197  const tallied = bump(pet, 'tools')
198  const counted = kind ? { ...tallied, work: { ...work, [kind]: work[kind] + 1 } } : tallied
199  if (stage(pet) === 'egg') return counted
200
201  const germs = germsOf(pet)
202  if (outcome.isError) return withGerms(bump(counted, 'errors'), germs + GERMS.onError)
203  const isTestPass = outcome.tool === 'Bash' && outcome.command !== undefined && isTestCommand(outcome.command)
204  if (isTestPass) return withGerms(bump(earn(counted, COINS.perTestPass), 'testPasses'), germs - GERMS.onTestPass)
205  return withGerms(counted, germs - (pet.isSick ? GERMS.onSuccessWhileSick : GERMS.onSuccess))
206}
207
208export const withGerms = (pet: Pet, germs: number): Pet => {
209  const g = clamp(germs)
210  const isSick = pet.isSick ? g > GERMS.curedAt : g >= GERMS.sickAt
211  const next = { ...pet, germs: g, isSick }
212  if (isSick && !pet.isSick) return addLog(bump(next, 'sickTimes'), '生病了')
213  if (!isSick && pet.isSick) return addLog(next, '病好了')
214  return next
215}
216
217export const germsOf = (pet: Pet) => pet.germs ?? 0
218export const workOf = (pet: Pet): Work => pet.work ?? NO_WORK
219
220// 佔超過 45% 的那種工作勝出,不然就是什麼都做的雜學系
221export const dominant = (work: Work): Lineage => {
222  const total = work.build + work.run + work.explore
223  if (total < 5) return 'mixed'
224  const [top, count] = (Object.entries(work) as [keyof Work, number][]).sort((a, b) => b[1] - a[1])[0] ?? ['build', 0]
225  return count / total >= 0.45 ? top : 'mixed'
226}
227
228export const SPECIES: Record<Lineage, { name: string; family: string; adultTrait: string }> = {
229  build: { name: '鼴鼠', family: '工匠系', adultTrait: '會蓋房子的' },
230  run: { name: '水獺', family: '水手系', adultTrait: '會游泳的' },
231  explore: { name: '貓頭鷹', family: '探險系', adultTrait: '會飛的' },
232  mixed: { name: '奇美拉', family: '雜學系', adultTrait: '什麼都會一點的' },
233}
234
235// 物種的完整名字,例如:會游泳的鼴鼠、純種水獺
236export const speciesLabel = (pet: Pet): string | undefined => {
237  if (!pet.species) return undefined
238  const base = SPECIES[pet.species].name
239  if (!pet.trait) return base
240  return pet.trait === pet.species ? `純種${base}` : `${SPECIES[pet.trait].adultTrait}${base}`
241}
242
243export type Stage = 'egg' | 'baby' | 'child' | 'adult'
244
245export const stage = (pet: Pet): Stage =>
246  pet.xp < 10 ? 'egg' : pet.xp < 150 ? 'baby' : pet.xp < 800 ? 'child' : 'adult'
247
248// 離下一個階段還差多少,成熟期之後回傳 null
249export const growth = (pet: Pet): { percent: number; left: number } | null => {
250  const steps = [0, 10, 150, 800]
251  const to = steps.find(n => n > pet.xp)
252  if (to === undefined) return null
253  const from = steps[steps.indexOf(to) - 1] ?? 0
254  return { percent: ((pet.xp - from) / (to - from)) * 100, left: to - pet.xp }
255}
256
257export const STAGE_LABEL: Record<Stage, string> = {
258  egg: '蛋',
259  baby: '幼年期',
260  child: '成長期',
261  adult: '成熟期',
262}
263
264// 階段加物種,例如:成長期・水獺
265export const titleOf = (pet: Pet) => {
266  const label = speciesLabel(pet)
267  return label ? `${STAGE_LABEL[stage(pet)]}・${label}` : STAGE_LABEL[stage(pet)]
268}
269
270export const mood = (pet: Pet) =>
271  Math.round(clamp((pet.hunger + pet.joy + pet.energy) / 3 - pet.poops * 10))
272
273export const face = (pet: Pet, isWorking: boolean): string => {
274  const s = stage(pet)
275  if (s === 'egg') return isWorking ? '(  ⊙ )' : '(    )'
276  if (pet.isAsleep) return '(-ω-) zZ'
277  if (pet.isSick) return '(×﹏×)'
278  if (pet.hunger < 20) return '(´﹃`)'
279  const m = mood(pet)
280  if (m < 30) return '(;ω;)'
281  if (isWorking) return s === 'adult' ? '(•̀ω•́)و' : '(・ω・)ノ'
282  if (m >= 75) return s === 'baby' ? '(^ω^)' : 'ヾ(^ω^)ノ'
283  return '(・ω・)'
284}
285
286// 數值跨過門檻時要提醒一次
287export const warnings = (before: Pet, after: Pet): string[] => {
288  const out: string[] = []
289  const n = after.name
290  const from = stage(before)
291  const to = stage(after)
292  if (from !== to) {
293    if (from === 'egg') out.push(`${n} 孵出來了!`)
294    else if (to === 'child' && after.species) {
295      const sp = SPECIES[after.species]
296      out.push(`${n} 長成了${sp.name}(${sp.family})!`)
297    } else if (to === 'adult') out.push(`${n} 長大了,變成${speciesLabel(after) ?? STAGE_LABEL[to]}`)
298    else out.push(`${n} 長大了,進入${STAGE_LABEL[to]}`)
299  }
300  if (!before.isSick && after.isSick) out.push(`${n} 生病了,讓測試變綠、少出點錯,或用 /pet heal 餵藥`)
301  if (before.isSick && !after.isSick) out.push(`${n} 病好了!`)
302  if (before.hunger >= 20 && after.hunger < 20) out.push(`${n} 肚子餓了`)
303  if (before.joy >= 20 && after.joy < 20) out.push(`${n} 覺得好無聊`)
304  if (before.isAsleep && !after.isAsleep) out.push(`${n} 睡飽起床了`)
305  return out
306}
307
308export const bar = (n: number, width = 5) => {
309  const full = Math.round((n / 100) * width)
310  return '█'.repeat(full) + '░'.repeat(width - full)
311}
312
313export const ageDays = (pet: Pet, now: number) => Math.floor((now - pet.bornAt) / (24 * HOUR))
314
315// 這個階段的工作比例,給摘要和側邊欄顯示
316export const workShare = (pet: Pet) => {
317  const w = workOf(pet)
318  const total = w.build + w.run + w.explore || 1
319  return {
320    build: Math.round((w.build / total) * 100),
321    run: Math.round((w.run / total) * 100),
322    explore: Math.round((w.explore / total) * 100),
323  }
324}
325
326export const summary = (pet: Pet, now: number) => {
327  const share = workShare(pet)
328  return [
329    `${face(pet, false)}  ${pet.name}(${titleOf(pet)},${ageDays(pet, now)} 天大,經驗 ${pet.xp})`,
330    `飽足 ${bar(pet.hunger)} ${Math.round(pet.hunger)}  心情 ${bar(pet.joy)} ${Math.round(pet.joy)}  精神 ${bar(pet.energy)} ${Math.round(pet.energy)}`,
331    `病菌 ${bar(germsOf(pet))} ${Math.round(germsOf(pet))}${pet.isSick ? '(生病中)' : ''}`,
332    stage(pet) === 'adult' ? '' : `這階段的工作:改檔 ${share.build}%、跑指令 ${share.run}%、查資料 ${share.explore}%`,
333    pet.poops > 0 ? `地上有 ${pet.poops} 坨 💩,用 /pet clean 清掉` : '',
334  ]
335    .filter(Boolean)
336    .join('\n')
337}
338
339const pad = (n: number) => String(n).padStart(2, '0')
340
341// 毫秒時間轉成 10/02 14:30,用這台電腦的時區
342export const stamp = (at: number) => {
343  const d = new Date(at)
344  return `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
345}
346
347export const birthday = (pet: Pet) => {
348  const d = new Date(pet.bornAt)
349  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
350}
351
352// 細節頁:統計數字和最近的大事記,指令和側邊欄共用
353export const detail = (pet: Pet, recent = 8) => {
354  const st = statsOf(pet)
355  return {
356    facts: [
357      `生日 ${birthday(pet)}`,
358      `金幣 ${coinsOf(pet)}(總共賺過 ${st.coinsEarned})`,
359      `陪 Claude 做完 ${st.turns} 輪對話、用了 ${st.tools} 次工具`,
360      `看過 ${st.errors} 次報錯、${st.testPasses} 次測試綠燈`,
361      `生過 ${st.sickTimes} 次病`,
362      `玩了 ${st.games} 局小遊戲、贏 ${st.wins} 局`,
363      `吃過 ${st.foods} 份高級食物`,
364    ],
365    log: (pet.log ?? []).slice(-recent).reverse().map(entry => `${stamp(entry.at)}  ${entry.text}`),
366  }
367}
368
hooks/shop.ts 74 lines
1import type { Pet, Skin } from '../types'
2import { addLog, bump, coinsOf, germsOf, stage, withGerms } from './pet'
3import { SKIN_ORDER, SKINS, skinOf } from './sprites'
4
5const clamp = (n: number) => Math.max(0, Math.min(100, n))
6
7export type FoodId = 'cake' | 'drink' | 'salad' | 'bento'
8
9// 高級食物買了就直接餵,效果比一般餵食好;只有沙拉不會讓牠想大便
10export const FOODS: Record<FoodId, { label: string; price: number; effect: string; feed: (pet: Pet) => Pet }> = {
11  cake: {
12    label: '草莓蛋糕',
13    price: 25,
14    effect: '飽足 +40、心情 +15',
15    feed: pet => ({ ...pet, hunger: clamp(pet.hunger + 40), joy: clamp(pet.joy + 15) }),
16  },
17  drink: {
18    label: '能量飲料',
19    price: 30,
20    effect: '精神 +40',
21    feed: pet => ({ ...pet, energy: clamp(pet.energy + 40), hunger: clamp(pet.hunger + 5) }),
22  },
23  salad: {
24    label: '有機沙拉',
25    price: 35,
26    effect: '飽足 +30、病菌 -15、不會大便',
27    feed: pet => withGerms({ ...pet, hunger: clamp(pet.hunger + 30) }, germsOf(pet) - 15),
28  },
29  bento: {
30    label: '豪華便當',
31    price: 60,
32    effect: '飽足全滿、心情和精神 +10',
33    feed: pet => ({ ...pet, hunger: 100, joy: clamp(pet.joy + 10), energy: clamp(pet.energy + 10) }),
34  },
35}
36
37export const FOOD_ORDER: FoodId[] = ['cake', 'drink', 'salad', 'bento']
38
39// 舊存檔沒有買過的紀錄,就當成只有免費的和當時正在用的那個
40export const ownedSkins = (pet: Pet): Skin[] => {
41  const free = SKIN_ORDER.filter(skin => SKINS[skin].price === 0)
42  return [...new Set([...free, ...(pet.ownedSkins ?? []), skinOf(pet)])]
43}
44
45export const owns = (pet: Pet, skin: Skin) => ownedSkins(pet).includes(skin)
46
47export type Sale = { pet: Pet; say: string }
48
49const short = (pet: Pet, price: number) => `金幣不夠,還差 ${price - coinsOf(pet)}`
50
51export const buyFood = (pet: Pet, id: FoodId): Sale => {
52  const food = FOODS[id]
53  if (stage(pet) === 'egg') return { pet, say: `${pet.name} 還是一顆蛋,吃不了東西` }
54  if (pet.isAsleep) return { pet, say: `${pet.name} 在睡覺,醒來再吃` }
55  if (coinsOf(pet) < food.price) return { pet, say: short(pet, food.price) }
56  // 沙拉以外都算一次餵食,每三次會大一坨
57  const paid = { ...pet, coins: coinsOf(pet) - food.price }
58  const feeds = id === 'salad' ? pet.feeds : pet.feeds + 1
59  const poops = id !== 'salad' && feeds % 3 === 0 ? pet.poops + 1 : pet.poops
60  const fed = bump(food.feed({ ...paid, feeds, poops }), 'foods')
61  return { pet: addLog(fed, `吃了${food.label}`), say: `${pet.name} 吃了${food.label},好幸福` }
62}
63
64export const buySkin = (pet: Pet, skin: Skin): Sale => {
65  const { label, price } = SKINS[skin]
66  if (owns(pet, skin)) return { pet: { ...pet, skin }, say: `換成${label}了` }
67  if (coinsOf(pet) < price) return { pet, say: short(pet, price) }
68  const bought = { ...pet, coins: coinsOf(pet) - price, ownedSkins: [...ownedSkins(pet), skin], skin }
69  return { pet: addLog(bought, `買了${label}顏色`), say: `買下${label},馬上換上了` }
70}
71
72export const findFood = (word: string): FoodId | undefined =>
73  FOOD_ORDER.find(id => id === word || FOODS[id].label === word || FOODS[id].label.endsWith(word))
74
hooks/sprites.ts 220 lines
1import type { Lineage, Pet, Skin } from '../types'
2import { mood, stage } from './pet'
3
4// 把一段 ASCII 圖拆成行,去掉頭尾空行,每行補到一樣寬,整塊靠左對齊才不會歪
5const art = (raw: string): string[] => {
6  const lines = raw.split('\n')
7  while (lines[0]?.trim() === '') lines.shift()
8  while (lines.at(-1)?.trim() === '') lines.pop()
9  const width = Math.max(...lines.map(line => line.length))
10  return lines.map(line => line.padEnd(width))
11}
12
13// 畫面裡的 L、R 是左右眼,M 是嘴巴,畫的時候換成表情;圖裡其他地方不要用這三個大寫字母
14const BABY = art(String.raw`
15   .-"""-.
16  /       \
17 |  L   R  |
18 |    M    |
19  \       /
20   '-...-'
21`)
22
23// 成長期依物種長出不同的樣子
24const CHILD: Record<Lineage, string[]> = {
25  // 鼴鼠:小眼睛、大鼻子、挖土的爪子
26  build: art(String.raw`
27     _.----._
28    /  L  R  \
29   |   (M)    |
30  /|  '----'  |\
31 (_/\________/\_)
32    ^^      ^^
33`),
34  // 水獺:鬍鬚、圓肚子、一條尾巴泡在水裡
35  run: art(String.raw`
36     .-"""-.
37   _/ L   R \_
38 =-    .M.    -=
39    \ '---' /
40    /       \__
41   (_/'---'\_~~~~
42`),
43  // 貓頭鷹:尖耳朵、大圓眼、收起來的翅膀
44  explore: art(String.raw`
45    /\_______/\
46   (  (L) (R)  )
47    \    M    /
48    /'"""""""'\
49   ( \       / )
50    '"^^---^^"'
51`),
52  // 奇美拉:貓臉、背上一片小翅膀
53  mixed: art(String.raw`
54    /\_/\     _
55   ( L R )   / )
56   =( M )= _/ /
57    )   (_/  /
58   (__)-(__)'
59`),
60}
61
62// 成熟期在頭上多一個標誌:安全帽、水泡、羽冠、星星
63const CROWN: Record<Lineage, string[]> = {
64  build: art(String.raw`
65      _____
66    _|_____|_
67`),
68  run: art(String.raw`
69     o  .  O
70`),
71  explore: art(String.raw`
72      \ | /
73`),
74  mixed: art(String.raw`
75     *  .  *
76`),
77}
78
79const EGG = [
80  art(String.raw`
81    .--.
82   /    \
83  |      |
84  |      |
85   \____/
86`),
87  art(String.raw`
88    .--.
89   / /\ \
90  | /  \/|
91  |      |
92   \____/
93`),
94] as const
95
96export type Mode = 'idle' | 'working' | 'sleep' | 'sick' | 'hungry' | 'sad'
97
98export const modeOf = (pet: Pet, isWorking: boolean): Mode => {
99  if (pet.isAsleep) return 'sleep'
100  if (pet.isSick) return 'sick'
101  if (pet.hunger < 20) return 'hungry'
102  if (mood(pet) < 30) return 'sad'
103  return isWorking ? 'working' : 'idle'
104}
105
106const EXPRESSION: Record<Mode, { eye: string; mouth: string }> = {
107  idle: { eye: 'o', mouth: 'w' },
108  working: { eye: '^', mouth: 'v' },
109  sleep: { eye: '-', mouth: '.' },
110  sick: { eye: 'x', mouth: '~' },
111  hungry: { eye: 'o', mouth: 'O' },
112  sad: { eye: 'T', mouth: 'n' },
113}
114
115const SPARKLES = ['  ✦     ✧  ', ' ✧    ✦    ', '    ✦    ✧ ', ' ✦   ✧     ']
116const FEVER = ['   +   +   ', '  +   +    ', '   +    +  ', '    +   +  ']
117const SNORE = ['        z  ', '         Z ', '       z   ', '          Z']
118
119const bodyOf = (pet: Pet): string[] => {
120  const s = stage(pet)
121  if (s === 'baby' || s === 'egg') return BABY
122  const species = pet.species ?? 'mixed'
123  if (s === 'child') return CHILD[species]
124  // 頭飾和身體寬度不同,一起再補一次寬
125  return art([...CROWN[species], ...CHILD[species]].join('\n'))
126}
127
128export type Frame = { effect: string; body: string[]; caption: string }
129
130// frame 每半秒加 1,所有動畫都從它算出來
131export const drawFrame = (pet: Pet, isWorking: boolean, frame: number): Frame => {
132  const s = stage(pet)
133  const mode = modeOf(pet, isWorking)
134  const bounce = mode === 'working' ? frame % 2 === 1 : Math.floor(frame / 2) % 2 === 1
135
136  if (s === 'egg') {
137    const wobble = isWorking && frame % 2 === 1
138    const body = [...EGG[pet.xp >= 5 ? 1 : 0]].map(line => (wobble ? ` ${line.slice(0, -1)}` : line))
139    return { effect: '', body, caption: pet.xp >= 5 ? '蛋殼裂開了…' : '在蛋裡睡覺' }
140  }
141
142  const blink = mode === 'idle' && frame % 9 === 0
143  const { eye, mouth } = blink ? { eye: '-', mouth: EXPRESSION.idle.mouth } : EXPRESSION[mode]
144  const lines = bodyOf(pet).map(line => line.replace('L', eye).replace('R', eye).replace('M', mouth))
145  // 彈跳:身體上移一行,把空行補到下面
146  const body = mode === 'sleep' || mode === 'sad' || mode === 'sick' || !bounce ? ['', ...lines] : [...lines, '']
147
148  const effect =
149    mode === 'working' ? (SPARKLES[frame % SPARKLES.length] ?? '')
150    : mode === 'sleep' ? (SNORE[frame % SNORE.length] ?? '')
151    : mode === 'sick' ? (FEVER[frame % FEVER.length] ?? '')
152    : ''
153
154  const caption = {
155    idle: '悠哉中',
156    working: '正在幫 Claude 加油!',
157    sleep: '呼…呼…',
158    sick: '生病了…測試變綠牠就會好',
159    hungry: '肚子好餓…',
160    sad: '有點寂寞…',
161  }[mode]
162
163  return { effect, body, caption }
164}
165
166// price 是商店售價,0 是一開始就有的
167export const SKINS: Record<Skin, { label: string; price: number; body?: string; accent?: string }> = {
168  pink: { label: '粉紅', price: 0, body: '#ff9ecf', accent: '#ffd1e8' },
169  mono: { label: '單色', price: 0 },
170  mint: { label: '薄荷', price: 150, body: '#7be0c3', accent: '#c7f5e8' },
171  sky: { label: '天空', price: 150, body: '#79b8ff', accent: '#c8e1ff' },
172  gold: { label: '金色', price: 400, body: '#ffcc66', accent: '#fff0c2' },
173  neon: { label: '霓虹', price: 800 },
174  rainbow: { label: '彩虹', price: 1200 },
175  galaxy: { label: '星河', price: 1500 },
176}
177
178export const SKIN_ORDER: Skin[] = ['pink', 'mono', 'mint', 'sky', 'gold', 'neon', 'rainbow', 'galaxy']
179
180const RAINBOW = ['#ff6188', '#fc9867', '#ffd866', '#a9dc76', '#78dce8', '#ab9df2']
181const NEON = ['#ff2ec4', '#00f0ff']
182const GALAXY = ['#3b1e7a', '#5b3fbf', '#7597de', '#b9a6ff', '#e6dcff', '#b9a6ff', '#7597de', '#5b3fbf']
183
184export const skinOf = (pet: Pet): Skin => pet.skin ?? 'pink'
185
186// 第 row 行在第 frame 格要用的顏色
187// 彩虹每行不同色、往下流;霓虹整隻在兩色之間閃;星河是深紫到淡紫的漸層慢慢流動
188export const lineColor = (skin: Skin, row: number, frame: number) => {
189  if (skin === 'rainbow') return RAINBOW[(row + frame) % RAINBOW.length]
190  if (skin === 'neon') return NEON[Math.floor(frame / 2) % NEON.length]
191  if (skin === 'galaxy') return GALAXY[(row + Math.floor(frame / 2)) % GALAXY.length]
192  return SKINS[skin].body
193}
194
195export const accentColor = (skin: Skin, frame: number) => {
196  if (skin === 'rainbow') return RAINBOW[frame % RAINBOW.length]
197  if (skin === 'neon') return NEON[(Math.floor(frame / 2) + 1) % NEON.length]
198  if (skin === 'galaxy') return GALAXY[(Math.floor(frame / 2) + 4) % GALAXY.length]
199  return SKINS[skin].accent
200}
201
202// 商店預覽:第 i 個字的背景色,會變化的顏色也跟著動畫流動
203export const previewColor = (skin: Skin, i: number, frame: number): string | undefined => {
204  if (skin === 'rainbow') return RAINBOW[(i + frame) % RAINBOW.length]
205  if (skin === 'neon') return NEON[(i + Math.floor(frame / 2)) % NEON.length]
206  if (skin === 'galaxy') return GALAXY[(i + Math.floor(frame / 2)) % GALAXY.length]
207  return SKINS[skin].body
208}
209
210// 背景亮就用深色字,背景暗就用淺色字
211export const inkOn = (background: string) => {
212  const n = Number.parseInt(background.slice(1), 16)
213  const luminance = 0.299 * (n >> 16) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)
214  return luminance > 140 ? '#1b1b1b' : '#f5f5f5'
215}
216
217export const SICK_COLOR = '#a9dc76'
218
219export const statColor = (n: number) => (n >= 60 ? '#a9dc76' : n >= 30 ? '#ffd866' : '#ff6188')
220
types/index.d.ts 73 lines
1export type Skin = 'pink' | 'mint' | 'sky' | 'gold' | 'mono' | 'rainbow' | 'neon' | 'galaxy'
2
3// build 是改檔案(陸),run 是跑指令(水),explore 是查資料(空),mixed 是什麼都做
4export type Lineage = 'build' | 'run' | 'explore' | 'mixed'
5
6export type Work = { build: number; run: number; explore: number }
7
8export type Pet = {
9  name: string
10  bornAt: number
11  lastTick: number
12  // 0 到 100,越高越好
13  hunger: number
14  joy: number
15  energy: number
16  xp: number
17  poops: number
18  feeds: number
19  isAsleep: boolean
20  // 以下欄位舊存檔沒有,讀的時候補預設值
21  skin?: Skin
22  // 目前這個階段累積的工作量,進入成長期、成熟期時拿來決定物種
23  work?: Work
24  // 進入成長期時決定的物種
25  species?: Lineage
26  // 進入成熟期時,依成長期的工作再分化一次
27  trait?: Lineage
28  // 0 到 100,工具報錯會增加,累積太多就生病
29  germs?: number
30  isSick?: boolean
31  coins?: number
32  // 買過的顏色樣式;舊存檔只有免費的和當時正在用的
33  ownedSkins?: Skin[]
34  stats?: Stats
35  log?: LogEntry[]
36}
37
38export type Stats = {
39  turns: number
40  tools: number
41  errors: number
42  testPasses: number
43  games: number
44  wins: number
45  sickTimes: number
46  foods: number
47  coinsEarned: number
48}
49
50// 細節頁的大事記,at 是毫秒時間
51export type LogEntry = { at: number; text: string }
52
53export type View = 'home' | 'shop' | 'detail'
54
55export type Game =
56  | { kind: 'rps'; line: string }
57  | { kind: 'guess'; secret: number; triesLeft: number; line: string; isOver: boolean }
58
59declare module 'claude-code' {
60  interface PluginState {
61    'hyday-pet': {
62      pet: Pet | null
63      isHidden: boolean
64      isPaneOpen: boolean
65      isWorking: boolean
66      frame: number
67      // null 是寵物首頁,其他是正在玩的小遊戲
68      game: Game | null
69      view: View
70    }
71  }
72}
73