A Digimon V-Pet above the prompt that eats this session's prompt cache and grows from your work: it gets hungry before the cache goes cold, rests at lunch and…

A Digimon V-Pet that lives above the Claude Code prompt. It eats your session's prompt cache, fights while Claude works, and digivolves by the Digital Monster Color's rules from what you actually do: turns, tests, commits and how well you look after it.


Every Digimon and evolution chart of the five versions: Digitama Hatchery, Digital Monster Color.
/plugin marketplace add trongtaiz/digi-pet
/plugin install digi-pet@digi-pet
Then /reload-plugins, or start a new session. Your pet hatches from a random egg (one of the five DMC versions).
digi-pet is a hooks-module plugin: it needs a Claude Code build that loads plugin hook modules. Notifications use osascript, so they are macOS only; everything else runs anywhere.
The pet sits in a small Digivice in the band above the prompt. What the band shows follows what Claude is doing:
⏵ Bash npm test 12s).With little room (a long draft in the prompt) the band shrinks to the bare screen, then a half-size one, then a single line. /digi pane opens a side pane with the full picture: the Digivice, its six stats as bars, the branches its stage can take and its evolution so far.

Claude Code caches the conversation for a while after each request (60 minutes on a 1-hour cache, 5 on the default one). Coming back before it expires is cheap; after, the whole context is sent again at full price. The pet's hunger is that clock:
| Since the last request (60 min cache) | The pet |
|---|---|
| under 30 min | full |
| 30 to 45 min | peckish |
| 45 to 55 min | hungry: a toast and a macOS notification |
| 55 to 60 min | starving: another warning, with the minutes left |
| 60 min and over | the cache is cold |
Any prompt feeds it. The thresholds scale with ttlMinutes. The pet does not get hungry when the cache is not worth keeping warm:
restHours, lunch and evenings by default), unless you /digi wake it, which keeps it up until that window ends;/digi break 90m, /digi break until 15:30) or after /digi sleep, until your next prompt;/digi side, or a folder listed in sidePaths), where it still shows when the cache goes cold (cache 42m left (cold at 10:00));minContextTokens, since a small cache is cheap to rebuild.It also does not warn you about a cache that will expire during rest hours anyway: feeding it then would only move the expiry into the break.

A cache that goes cold, and is then picked up again the same day, is a care mistake: the pet is sick for the next three turns, and the mistake counts against it when it digivolves. To keep this fair:
dayStartsHour, so a late night belongs to the evening before);A context over 85% full is an overfeed, which also counts at evolution time. Turns sent in rest hours are sleep disturbances.
While a turn runs, monsters run in from the left and the pet shoots them down. That part is for show. The real battles are your checks: tests, builds, type-checks and linters (npm test, pytest, tsc, cargo test, eslint and the like).
| A check is red | It goes green in the same turn |
|---|---|
![]() | ![]() |
Failures are read from the check's output as well as its exit code, since most runs are piped through tail. Wins this stage show top right. The win ratio counts over the pet's whole life and decides its later evolutions.
Each subagent Claude runs joins the fight as a small Digimon in front of the pet, its task written above it: Koromon for a general-purpose agent, Tokomon for Explore, Tsunomon for Plan, Tanemon or Pagumon for any other type. Up to three stand on the field, and +n counts the rest. When the subagent is done the ally cheers and leaves; if it failed, it lies grey a moment first. One still running between turns waits on the pet's own screen.
Allies are for show. What counts toward growth is the summon: each Agent call that returns without an error, whether or not an ally was drawn for it.
Six stats grow from your work. Past 20 points in a day a stat grows a quarter as fast, so no single marathon day makes the pet.
| Stat | Grows from |
|---|---|
| STA | time Claude spends working |
| INT | research: reads, searches, web lookups |
| ATK | lines written (Edit and Write) |
| DEF | checks that pass |
| SPD | parallel tool calls and quick turns |
| SYN | your prompts, pats (/digi pet, three a day) and a streak of active days |
The pet hatches from an egg, then goes Baby I, Baby II, Rookie, Champion, Ultimate and Mega. Each stage lasts a while before it can digivolve (at normal pace; an active day is one with at least five turns):
| Stage | Before it can digivolve |
|---|---|
| Egg | 3 turns |
| Baby I | 25 turns |
| Baby II | 2 active days and 100 turns |
| Rookie | 5 active days |
| Champion | 10 active days and 30 trophies |
| Ultimate | 21 active days, 60 trophies and 10 summons |
| Mega | 14 active days, then a jogress |
A trophy is a commit or an opened pull request; a summon is a subagent. Pull requests count on any forge whose CLI or MCP server opens them: GitHub (gh), GitLab (glab, or git push -o merge_request.create), Gitea and Forgejo (tea, fj), Azure DevOps (az repos pr create) and Gerrit (a push to refs/for/). pace makes every stage last a quarter as long (fast) or twice as long (slow).
Which Digimon it becomes is the Digital Monster Color's own chart. The counts since the pet entered its stage pick the branch: care mistakes, training (turns where Claude used tools, up to 12 a day), overfeeds, sleep disturbances, battles and win ratio. Becoming an Ultimate or a Mega takes an 80% win ratio. Under 40% it never happens; between 40% and 80% there is a chance that rises with the ratio, rolled once a day.
Two house rules sit on top of the chart:
git push --force, --no-verify, git reset --hard, rm -rf of anything but temp or build folders) and interrupted turns are chaos. Five or more per day of a stage sends the pet down the Virus branch where there is one./digi shows the counts and what the next stage still needs. /digi pane lists the branches: each Digimon the stage can become, the counts its rule asks for (ticked when met), ▸ on the one the counts lead to now, and the chaos that would send it down the Virus branch. /digi log lists every digivolution so far.

A Mega with 15 battles this stage and an 80% win ratio can fuse with the partner its chart names: /digi jogress. Each new pet hatches from one of the five DMC version eggs at random, and each egg leads to a different chart.
| Command | |
|---|---|
/digi or /digi stats | Stats, hunger, and what the next evolution needs |
/digi pane | Open the side pane: the Digivice, stats, branches and evolution log |
/digi log | Evolution history |
/digi pet | Pat it (the first three a day raise SYN) |
/digi jogress | Fuse with a partner, where the chart allows |
/digi sleep | Sleep until your next prompt |
/digi wake | Wake it: ends a sleep or a break, and keeps it up through the rest window it is in |
/digi break 90m / until 15:30 / off | A one-off rest window |
/digi side [off] | Mark this session as side work: no hunger |
/digi sim <species> [state] / sim off | Preview any species and state |
/digi debug ttl <min> / off | Shorten the cache TTL to watch hunger play out |
Set them with claude plugin configure digi-pet@digi-pet:
| Option | Default | |
|---|---|---|
ttlMinutes | 60 | Your cache TTL: 60 on a 1-hour cache, 5 on the default one |
restHours | 12:00-14:00,18:00-08:00 | Nap windows, local time |
dayStartsHour | 4 | A late night counts as the evening before |
sidePaths | Globs whose sessions are side sessions | |
minContextTokens | 20000 | Below this the cache is cheap to rebuild; no hunger |
notify | true | macOS notifications when hungry or starving |
pace | normal | fast, normal or slow growth |
reducedMotion | false | Draw everything still |
bun install
claude plugin test plugin # tests
claude plugin validate plugin
bun run preview # preview/index.html: every state, rendered
bun scripts/preview.ts --html --readme # redraw the images in docs/images
To run your working copy, add the folder as a marketplace: /plugin marketplace add /path/to/digi-pet.
The code is MIT (see LICENSE). The Digimon sprites, names and evolution data are not: they belong to Bandai / Toei, and digi-pet is an unofficial fan project. See NOTICE.md.
hooks/register.tsx 877 lines1// digi-pet: a Digimon V-Pet that lives above the prompt. It eats this session's
2// prompt cache: as the cache nears its expiry the pet grows hungry and yells
3// (the band, a toast, a macOS notification), and a cache left to go cold on a
4// working day is a care mistake. Rest windows, breaks, `/digi sleep` and side
5// sessions keep it quiet. Each session's counts go to the store; after each
6// turn they grow the six stats and, once a stage has lasted, digivolve the pet.
7import { atom, read, update } from 'claude-code'
8import type { Elements, EngineInterface, Register, RenderElement } from 'claude-code'
9
10import type { DigiActivity, DigiAlly, DigiBattle, DigiRun, DigiEvolving, DigiFeeding, DigiForecast, DigiGrowth, DigiMood, DigiSim } from '../types'
11import { allyOf, busyLabel, oneLine, toolLabel, toolRun } from './activity'
12import { segments } from './cells'
13import type { Grid } from './cells'
14import { ACTIVE_TURNS, PATS_PER_DAY, START_SPECIES, asPet, countsOf, eggOf, evolveTo, forecastOf, jogressOf, minus, paceOf, progressOf, statsOf, winRatio } from './growth'
15import type { Context, Counts, Forecast, Pet } from './growth'
16import {
17 clockText,
18 dayOf,
19 hungerOf,
20 isCareMistake,
21 isResting,
22 isSidePath,
23 minutesLeft,
24 parseBreak,
25 parseOffset,
26 parseRest,
27 quietOf,
28 restEnd,
29 warningOf,
30} from './hunger'
31import type { Config, Quiet } from './hunger'
32import { byDay, totals, writer } from './ledger'
33import type { Counters, Ledger } from './ledger'
34import { BAND_WIDTH, DEVICE_ROWS, MINI_BAND_ROWS, band, isLoud, miniBand, notification, statusLine, toast } from './render'
35import type { Attribute, Hunger } from './render'
36import type { RunProps } from './run'
37import type { ScreenProps } from './screen'
38import { isRisky, signalOf, trophiesOf } from './signals'
39import { SPECIES } from './species.gen'
40import { isSpecies, petView, spriteOf } from './view'
41import type { PaneJson, ViewJson } from './view'
42
43const PANE = 'digi'
44const MINUTE = 60_000
45const TICK_MS = 15_000
46const EATING_MS = 4_000
47const SICK_TURNS = 3
48/** A quick turn, for SPD. */
49const QUICK_MS = 60_000
50const LOUD_ROWS = 9
51const WORK_ROWS = 8
52/** The pet's block while it fights: its screen and a few words, the arena takes the rest. */
53const WORK_BLOCK = 54
54const MIN_ARENA = 20
55/** How long an ally stays on the field once its subagent is done (or failed), cheering (or lying grey). */
56const LEAVE_MS = 2500
57/** How long the band plays a digivolution. */
58const EVOLVE_MS = 20_000
59/** The context this full is an overfeed; it counts again once the context has dropped under REFED. */
60const OVERFULL_PERCENT = 85
61const REFED_PERCENT = 70
62const STATES = ['full', 'peckish', 'hungry', 'starving', 'cold', 'asleep', 'sick', 'eating', 'evolving'] as const
63
64const EMPTY: DigiFeeding = { lastFedAt: null, isSide: false, isAsleep: false, breakUntil: null, awakeUntil: null, contextTokens: null }
65
66const feeding = atom({ plugin: 'digi-pet', key: 'feeding' } as const, EMPTY)
67const now = atom({ plugin: 'digi-pet', key: 'now' } as const, 0)
68const mood = atom({ plugin: 'digi-pet', key: 'mood' } as const, { kind: 'normal', turnsLeft: 0 } as DigiMood)
69const sim = atom({ plugin: 'digi-pet', key: 'sim' } as const, null as DigiSim | null)
70const isPicking = atom({ plugin: 'digi-pet', key: 'isPicking' } as const, false)
71const mistakes = atom({ plugin: 'digi-pet', key: 'mistakes' } as const, 0)
72const facts = atom({ plugin: 'digi-pet', key: 'facts' } as const, [] as string[])
73const local = atom({ plugin: 'digi-pet', key: 'local' } as const, { offsetMin: 0, project: '', ttlMs: 60 * MINUTE })
74const species = atom({ plugin: 'digi-pet', key: 'species' } as const, START_SPECIES)
75const activity = atom({ plugin: 'digi-pet', key: 'activity' } as const, { act: 'idle' } as DigiActivity)
76const growth = atom({ plugin: 'digi-pet', key: 'growth' } as const, null as DigiGrowth | null)
77const evolving = atom({ plugin: 'digi-pet', key: 'evolving' } as const, null as DigiEvolving | null)
78const battle = atom({ plugin: 'digi-pet', key: 'battle' } as const, { open: 0, won: 0, flash: false } as DigiBattle)
79const allies = atom({ plugin: 'digi-pet', key: 'allies' } as const, [] as DigiAlly[])
80
81const USAGE = '[stats | pane | log | pet | jogress | sleep | wake | break 90m|until 15:30|off | side [off] | sim <species> [state] [to] | sim off | debug ttl <min>|off]'
82
83/** Whether the prompt's draft has a `/` or `@` picker open, which the engine draws above the band. */
84function isPickerOpen(text: string, cursor = text.length): boolean {
85 const before = text.slice(0, cursor)
86 return /^\/\S*$/.test(before) || /(^|\s)@\S*$/.test(before)
87}
88
89/** The store as the ledger takes it, each call spelled on `$`. */
90function storeOf($: EngineInterface) {
91 return {
92 get: (key: string) => $.store.get(key),
93 set: (key: string, value: unknown) => $.store.set(key, value),
94 keys: () => $.store.keys(),
95 }
96}
97
98/** What the hooks share for this load: the options, and what a reload starts over. */
99type Ctx = {
100 ttlMinutes: number
101 restText: string
102 rest: Config['rest']
103 dayStartsHour: number
104 minContextTokens: number
105 sidePaths: string[]
106 isNotifying: boolean
107 ledger: ReturnType<typeof writer> | undefined
108 tick: { cancel: () => void } | undefined
109 eatingTimer: { cancel: () => void } | undefined
110 isMistakePending: boolean
111 warned: { fedAt: number; levels: Set<string> }
112 shownStatus: string | undefined
113 /** The main loop's tool calls running now, by id, with their labels. */
114 running: Map<string, string>
115 /** The same calls, with what each runs, for the row under the pet. */
116 runs: Map<string, DigiRun>
117 /** Whether the running turn has used a tool: a turn of training. */
118 isTraining: boolean
119 /** The checks gone red this turn and not green again: battles still being fought. */
120 red: Set<string>
121 isOverfull: boolean
122 /** `pace`: how long every stage lasts, against normal. */
123 pace: number
124 /** `reducedMotion`: the band and the pane drawn still. */
125 isStill: boolean
126 /** A `/digi side` flag kept by the conversation a /clear just ended, for the one it starts. */
127 sideOnClear: boolean | undefined
128}
129
130function contextOf(options: Readonly<Record<string, unknown>>): Ctx {
131 const restText = String(options.restHours ?? '12:00-14:00,18:00-08:00')
132 return {
133 ttlMinutes: Number(options.ttlMinutes) || 60,
134 restText,
135 rest: parseRest(restText),
136 dayStartsHour: Number(options.dayStartsHour ?? 4),
137 minContextTokens: Number(options.minContextTokens ?? 20_000),
138 sidePaths: String(options.sidePaths ?? '')
139 .split(',')
140 .map(s => s.trim())
141 .filter(Boolean),
142 isNotifying: options.notify !== false,
143 ledger: undefined,
144 tick: undefined,
145 eatingTimer: undefined,
146 isMistakePending: false,
147 warned: { fedAt: -1, levels: new Set() },
148 shownStatus: undefined,
149 running: new Map(),
150 runs: new Map(),
151 isTraining: false,
152 red: new Set(),
153 isOverfull: false,
154 pace: paceOf(options.pace),
155 isStill: options.reducedMotion === true,
156 sideOnClear: undefined,
157 }
158}
159
160async function configOf($: EngineInterface, ctx: Ctx): Promise<Config> {
161 const { offsetMin, ttlMs } = await read($, local)
162 return { ttlMs, rest: ctx.rest, dayStartsHour: ctx.dayStartsHour, minContextTokens: ctx.minContextTokens, offsetMin }
163}
164
165function ledgerOf($: EngineInterface, ctx: Ctx) {
166 ctx.ledger ??= writer(storeOf($))
167 return ctx.ledger
168}
169
170async function bump($: EngineInterface, ctx: Ctx, delta: Partial<Counters>) {
171 const cfg = await configOf($, ctx)
172 return ledgerOf($, ctx).bump(await $.session.id(), dayOf(await $.clock.now(), cfg), delta)
173}
174
175function quietText(q: Quiet | null, f: DigiFeeding, t: number, cfg: Config): string | undefined {
176 switch (q) {
177 case 'side': {
178 // Not hungry, but when the cache goes cold is still worth knowing.
179 const left = minutesLeft(f, t, cfg)
180 return `side session · ${left > 0 ? `cache ${Math.ceil(left)}m left (cold at ${clockText(f.lastFedAt! + cfg.ttlMs, cfg.offsetMin)})` : 'cache cold'}`
181 }
182 case 'asleep':
183 return '💤 asleep until your next prompt'
184 case 'break':
185 return `☕ on break until ${clockText(f.breakUntil ?? t, cfg.offsetMin)}`
186 case 'rest': {
187 const end = restEnd(t, cfg)
188 return `💤 resting${end ? ` until ${clockText(end, cfg.offsetMin)}` : ''}`
189 }
190 case 'small':
191 return 'small context · not hungry'
192 case 'unfed':
193 return 'waiting for the first prompt'
194 default:
195 return undefined
196 }
197}
198
199/** The pet as the band and the pane draw it, now (or as `/digi sim` asks). */
200async function viewOf($: EngineInterface, ctx: Ctx, t: number): Promise<ViewJson> {
201 const cfg = await configOf($, ctx)
202 const { project } = await read($, local)
203 const shown = await read($, sim)
204 const base = { attribute: null, project, ttlMinutes: cfg.ttlMs / MINUTE }
205 if (shown) {
206 const state = shown.state as (typeof STATES)[number]
207 const hunger: Hunger = (['full', 'peckish', 'hungry', 'starving', 'cold'] as const).find(h => h === state) ?? 'full'
208 const left = { full: 48, peckish: 24, hungry: 12, starving: 4, cold: 0 }[hunger] * (cfg.ttlMs / (60 * MINUTE))
209 return {
210 ...base,
211 species: shown.species,
212 hunger,
213 minutesLeft: left,
214 mood: state === 'asleep' || state === 'sick' || state === 'eating' ? state : 'normal',
215 ...withText('evolvingTo', state === 'evolving' ? (shown.to ?? undefined) : undefined),
216 }
217 }
218 const f = await read($, feeding)
219 const q = quietOf(f, t, cfg)
220 const m = await read($, mood)
221 const hunger: Hunger = f.lastFedAt === null || q ? 'full' : hungerOf(t - f.lastFedAt, cfg.ttlMs)
222 const isNapping = q === 'rest' || q === 'break' || q === 'asleep'
223 const id = await read($, species)
224 const ev = await read($, evolving)
225 const shownId = ev && t < ev.until ? ev.from : id
226 const g = await read($, growth)
227 return {
228 ...base,
229 species: shownId,
230 attribute: attributeOf(shownId),
231 ...withText('evolvingTo', ev && t < ev.until ? ev.to : undefined),
232 ...(g ? { next: g.next } : {}),
233 hunger,
234 minutesLeft: minutesLeft(f, t, cfg),
235 mood: m.kind !== 'normal' ? m.kind : isNapping ? 'asleep' : 'normal',
236 ...withText('quiet', quietText(q, f, t, cfg)),
237 ...(await alliesOf($)),
238 ...(await actOf($)),
239 }
240}
241
242function attributeOf(id: string): Attribute | null {
243 const a = SPECIES[id]?.attribute
244 return a === 'Vaccine' || a === 'Data' || a === 'Virus' ? a : null
245}
246
247/** A grid of cells as Text rows. */
248function drawn(ui: Pick<Elements['terminal'], 'Box' | 'Text'>, rows: Grid): RenderElement {
249 const { Box, Text } = ui
250 return (
251 <Box flexDirection="column">
252 {rows.map(row => (
253 <Text wrap="truncate">
254 {segments(row).map(seg => (
255 <Text color={seg.c} backgroundColor={seg.bg} bold={seg.b} dimColor={seg.d}>
256 {seg.text}
257 </Text>
258 ))}
259 </Text>
260 ))}
261 </Box>
262 )
263}
264
265/** The record as lines: this session today, and every session ever. */
266async function recordLines($: EngineInterface, ctx: Ctx): Promise<string[]> {
267 const cfg = await configOf($, ctx)
268 const today = (await ledgerOf($, ctx).read(await $.session.id()))[dayOf(await $.clock.now(), cfg)]
269 const all = await totals(storeOf($))
270 return [
271 `Today here: ${today?.turns ?? 0} turns · ${today?.meals ?? 0} meals · cache ${today?.cacheHits ?? 0} hit / ${today?.cacheMisses ?? 0} miss after a gap`,
272 `All time: ${all.turns} turns · ${all.meals} meals · ${all.restTurns} in rest hours · cache ${all.cacheHits} hit / ${all.cacheMisses} miss`,
273 ]
274}
275
276/** Weight, as the device shows it: grams, here from the context the pet carries (10k tokens a gram, 5g at least). */
277function weightOf(tokens: number | null): number {
278 return Math.max(5, Math.round((tokens ?? 0) / 10_000))
279}
280
281/** The stats, this stage's counts and the way on, as `/digi stats` prints them. */
282function growthLines(g: DigiGrowth): string[] {
283 const s = g.stats
284 const st = g.stage
285 return [
286 `Stats: STA ${s.STA} · INT ${s.INT} · ATK ${s.ATK} · DEF ${s.DEF} · SPD ${s.SPD} · SYN ${s.SYN}`,
287 `This stage: ${st.careMistakes} care mistakes · ${st.training} training · ${st.overfeed} overfeed · ${st.sleepDisturbances} sleep disturbances · ${st.chaos} chaos · ${st.trophies} trophies · ${st.summons} summons`,
288 `Battles this stage: ${g.battles.won}W ${g.battles.lost}L · win ratio ${g.winRatio}% over every battle · ${g.trophies} trophies ever`,
289 g.next ? `Next: ${g.next.label}` : 'Fully digivolved',
290 ...(g.log.length ? [`Evolution: ${g.log.map(e => `${e.name} (${e.day})`).join(' → ')}`] : []),
291 ]
292}
293
294/** The allies for the view, as plain data; nothing when there are none. */
295async function alliesOf($: EngineInterface): Promise<Pick<ViewJson, 'allies'>> {
296 const list = await read($, allies)
297 return list.length ? { allies: list.map(({ species, label, leaving }) => ({ species, label, ...(leaving ? { leaving } : {}) })) } : {}
298}
299
300/** This session's agents, or null when the engine will not say. */
301async function agents($: EngineInterface) {
302 return $.agent.list().catch(() => null)
303}
304
305/** An ally's subagent has stopped: it cheers (or lies grey) a moment, then leaves the field. */
306async function leave($: EngineInterface, id: string, how: 'done' | 'failed') {
307 const list = await read($, allies)
308 if (!list.some(a => a.id === id && !a.leaving)) return
309 await update($, allies, l => l.map(a => (a.id === id ? { ...a, leaving: how } : a)))
310 $.clock.after(LEAVE_MS, () => void update($, allies, l => l.filter(a => a.id !== id)))
311}
312
313/** The running turn's act and tool for the view, or nothing between turns. */
314async function actOf($: EngineInterface): Promise<Pick<ViewJson, 'act' | 'tool'>> {
315 const a = await read($, activity)
316 if (a.act === 'idle') return {}
317 return { act: a.act, ...withText('tool', a.tool) }
318}
319
320async function settle($: EngineInterface, ctx: Ctx) {
321 const tool = busyLabel([...ctx.running.values()])
322 const run = [...ctx.runs.values()].pop()
323 await update($, activity, (a): DigiActivity => (a.act === 'idle' ? a : tool ? { act: 'tool', tool, ...(run ? { run } : {}) } : { act: 'think' }))
324}
325
326/** `{ [key]: text }`, or nothing when there is no text: a Client's props hold no undefined. */
327function withText<K extends string>(key: K, text: string | undefined): { [P in K]?: string } {
328 return (text === undefined ? {} : { [key]: text }) as { [P in K]?: string }
329}
330
331async function notify($: EngineInterface, ctx: Ctx, title: string, body: string) {
332 if (!ctx.isNotifying) return
333 // Title and body ride as arguments, never inside the script, so no project name can break out of it.
334 await $.process
335 .run(['osascript', '-e', 'on run argv', '-e', 'display notification (item 2 of argv) with title (item 1 of argv)', '-e', 'end run', title, body])
336 .catch(() => undefined)
337}
338
339/** Every tick: redraw the hearts, and yell once per level per meal while yelling can still save the cache. */
340async function onTick($: EngineInterface, ctx: Ctx) {
341 const t = await $.clock.now()
342 await update($, now, () => t)
343 // A stop the hooks missed: an ally whose subagent is gone or over leaves all the same.
344 const out = (await read($, allies)).filter(a => !a.leaving)
345 const listed = out.length ? await agents($) : null
346 for (const a of listed ? out : []) {
347 const status = listed!.find(x => x.id === a.id)?.status
348 if (status === undefined || status === 'completed') await leave($, a.id, 'done')
349 else if (status === 'failed' || status === 'killed') await leave($, a.id, 'failed')
350 }
351 // Another session digivolved the pet: draw what it is now.
352 const kept = asPet(await $.store.get('pet'))
353 if (kept.species !== (await read($, species)) && isSpecies(kept.species)) await grow($, ctx)
354 const f = await read($, feeding)
355 const cfg = await configOf($, ctx)
356 const level = warningOf(f, t, cfg)
357 if (level && f.lastFedAt !== null) {
358 if (ctx.warned.fedAt !== f.lastFedAt) ctx.warned = { fedAt: f.lastFedAt, levels: new Set() }
359 if (!ctx.warned.levels.has(level)) {
360 ctx.warned.levels.add(level)
361 const v = petView(await viewOf($, ctx, t), 0)
362 const text = toast(v)
363 const n = notification(v)
364 if (text) $.ui.toast(text, { timeoutMs: 8000 })
365 if (n) await notify($, ctx, n.title, n.body)
366 }
367 }
368 const status = level === 'starving' ? statusLine(petView(await viewOf($, ctx, t), 0)) : undefined
369 if (status !== ctx.shownStatus) {
370 ctx.shownStatus = status
371 $.ui.status(status)
372 }
373}
374
375/** (Re)starts the tick: every 15 s, or often enough that a short TTL's starving stretch (1/12 of it) is never skipped. */
376function startTick($: EngineInterface, ctx: Ctx, ttlMs: number) {
377 ctx.tick?.cancel()
378 ctx.tick = $.clock.every(Math.min(TICK_MS, Math.floor(ttlMs / 24)), () => void onTick($, ctx))
379}
380
381async function endEating($: EngineInterface) {
382 await update($, mood, m => (m.kind === 'eating' || m.kind === 'happy' ? ({ kind: 'normal', turnsLeft: 0 } as DigiMood) : m))
383}
384
385async function setMood($: EngineInterface, ctx: Ctx, next: DigiMood) {
386 await update($, mood, () => next)
387 ctx.eatingTimer?.cancel()
388 if (next.kind === 'eating' || next.kind === 'happy') ctx.eatingTimer = $.clock.after(EATING_MS, () => void endEating($))
389}
390
391/** A request of the main loop answered: the cache is fed. */
392async function meal($: EngineInterface, ctx: Ctx, usage: { cache_read_input_tokens: number; cache_creation_input_tokens: number } | null) {
393 const t = await $.clock.now()
394 const f = await read($, feeding)
395 const cfg = await configOf($, ctx)
396 const gap = f.lastFedAt === null ? null : t - f.lastFedAt
397 const wasHungry = f.lastFedAt !== null && !quietOf(f, t, cfg) && ['hungry', 'starving', 'cold'].includes(hungerOf(t - f.lastFedAt, cfg.ttlMs))
398 const isHit = usage ? usage.cache_read_input_tokens >= usage.cache_creation_input_tokens : null
399 const isLongGap = gap !== null && gap > 5 * MINUTE
400
401 // The band draws from the tick's clock: bring it up to the meal first, or the meal looks to be in its future.
402 await update($, now, () => t)
403 await update($, feeding, x => ({ ...x, lastFedAt: t }))
404 if (isLongGap && isHit !== null) {
405 const fact = `Cache ${isHit ? 'hit ✓' : 'miss ✗'} after a ${Math.round(gap / MINUTE)}m gap (${clockText(t, cfg.offsetMin)})`
406 await update($, facts, list => [fact, ...list.filter(l => !l.startsWith('Cache '))].slice(0, 4))
407 }
408 await bump($, ctx, { meals: 1, ...(isLongGap && isHit !== null ? (isHit ? { cacheHits: 1 } : { cacheMisses: 1 }) : {}) })
409
410 if (ctx.isMistakePending) {
411 ctx.isMistakePending = false
412 // The cache answered after all: nothing was lost.
413 if (isHit !== true) {
414 await bump($, ctx, { careMistakes: 1 })
415 await update($, mistakes, n => n + 1)
416 await setMood($, ctx, { kind: 'sick', turnsLeft: SICK_TURNS })
417 const name = spriteOf(await read($, species)).name
418 $.ui.toast(`${name} got sick: the cache went cold and had to be rebuilt (care mistake, ${dayOf(t, cfg)})`, { timeoutMs: 8000 })
419 return
420 }
421 }
422 if (wasHungry && (await read($, mood)).kind !== 'sick') await setMood($, ctx, { kind: 'eating', turnsLeft: 0 })
423}
424
425/** What a finished tool call adds to the stats, and a check's run to its battle: red opens one, green again wins it. */
426async function trained<R extends object>($: EngineInterface, ctx: Ctx, e: { tool: string }, result: R): Promise<R> {
427 if ('deny' in result && result.deny !== undefined) return result
428 const r = result as { isError?: boolean; text?: string }
429 const input = e as unknown as Record<string, unknown>
430 const signal = signalOf(e.tool, input, r.isError === true, r.text ?? '')
431 if (signal.kind === 'research') await bump($, ctx, { research: 1 })
432 if (signal.kind === 'write' && signal.lines) await bump($, ctx, { linesWritten: signal.lines })
433 if (signal.kind === 'summon') await bump($, ctx, { summons: 1 })
434 // A commit can ride along with a check (`npm test && git commit`): judged on its own.
435 const trophies = trophiesOf(e.tool, input, r.isError === true, r.text ?? '')
436 if (trophies) await bump($, ctx, { trophies })
437 if (e.tool === 'Bash' && isRisky(String(input.command ?? ''))) await bump($, ctx, { chaos: 1 })
438 if (signal.kind === 'check' && !signal.isPass) ctx.red.add(signal.runner)
439 if (signal.kind === 'check' && signal.isPass && ctx.red.delete(signal.runner)) {
440 await bump($, ctx, { checksPassed: 1, battlesWon: 1 })
441 await update($, battle, b => ({ open: ctx.red.size, won: b.won + 1, flash: true }))
442 } else if (signal.kind === 'check' && signal.isPass) await bump($, ctx, { checksPassed: 1 })
443 if (signal.kind === 'check') await update($, battle, b => (b.open === ctx.red.size ? b : { ...b, open: ctx.red.size }))
444 return result
445}
446
447/** The stats and the way to the next stage from every session's days; digivolves the pet, one stage, once its stage has lasted. */
448async function grow($: EngineInterface, ctx: Ctx) {
449 const t = await $.clock.now()
450 const cfg = await configOf($, ctx)
451 const today = dayOf(t, cfg)
452 const days = await byDay(storeOf($))
453 // Read back every time: another session may have digivolved the pet.
454 let pet = await petOf($)
455 const all = countsOf(days, today, ctx.pace)
456 const life: Context = { life: all, day: today, pace: ctx.pace }
457 const to = evolveTo(pet.species, minus(all, pet.base), SPECIES, life)
458 if (to && isSpecies(to)) {
459 const from = pet.species
460 const isHatching = SPECIES[from]?.stage === 'egg'
461 // Hatching takes nothing from the counts: the egg's turns are the Baby's first.
462 pet = { species: to, enteredDay: today, base: isHatching ? pet.base : all, log: [...pet.log, { id: to, day: today }] }
463 await digivolve($, ctx, pet, from, isHatching ? `${spriteOf(from).name} hatched into ${spriteOf(to).name}!` : `${spriteOf(from).name} digivolved to ${spriteOf(to).name}!`)
464 }
465 if ((await read($, species)) !== pet.species) await update($, species, () => pet.species)
466 const g = growthOf(pet, days, all, life)
467 await update($, growth, () => g)
468 if ((await read($, battle)).won !== g.battles.won) await update($, battle, b => ({ ...b, won: g.battles.won }))
469}
470
471async function petOf($: EngineInterface): Promise<Pet> {
472 const kept = await $.store.get('pet')
473 // No pet yet: draw an egg and keep it at once, so every later read finds the same one.
474 if (kept === undefined || kept === null) {
475 const egg = asPet({ species: eggOf(SPECIES, Math.random()) })
476 await $.store.set('pet', egg)
477 return egg
478 }
479 const pet = asPet(kept)
480 return isSpecies(pet.species) ? pet : { ...pet, species: START_SPECIES }
481}
482
483/** Keeps the pet's new form, plays it in the band, and says so. */
484async function digivolve($: EngineInterface, ctx: Ctx, pet: Pet, from: string, text: string) {
485 const t = await $.clock.now()
486 await $.store.set('pet', pet)
487 await update($, now, () => t)
488 await update($, evolving, () => ({ from, to: pet.species, until: t + EVOLVE_MS }))
489 $.ui.toast(text, { timeoutMs: 8000 })
490 await notify($, ctx, 'digi-pet', text)
491}
492
493function growthOf(pet: Pet, days: Ledger, all: Counts, life: Context): DigiGrowth {
494 const stage = minus(all, pet.base)
495 return {
496 stats: statsOf(days),
497 battles: { won: stage.wins, lost: stage.battles - stage.wins },
498 ageDays: Object.values(days).filter(d => d.turns >= ACTIVE_TURNS).length,
499 next: progressOf(pet.species, stage, SPECIES, life),
500 log: pet.log.map(e => ({ name: spriteOf(e.id).name, day: e.day })),
501 stage: {
502 careMistakes: stage.careMistakes,
503 training: stage.training,
504 overfeed: stage.overfeed,
505 sleepDisturbances: stage.sleepDisturbances,
506 trophies: stage.trophies,
507 summons: stage.summons,
508 chaos: stage.chaos,
509 },
510 trophies: all.trophies,
511 winRatio: winRatio(all),
512 forecast: forecastJson(forecastOf(pet.species, stage, SPECIES, life)),
513 }
514}
515
516/** The forecast with each branch's name and attribute, for the pane. */
517function forecastJson(f: Forecast | null): DigiForecast | null {
518 if (!f) return null
519 return {
520 branches: f.branches.map(({ to, ...b }) => ({ ...b, name: spriteOf(to).name, attribute: SPECIES[to]?.attribute ?? '' })),
521 virus: f.virus && { name: spriteOf(f.virus.to).name, have: f.virus.have, need: f.virus.need },
522 }
523}
524
525/** `/digi jogress`: a Mega whose chart names a partner fuses with it, once its stage has lasted and it has won enough. */
526async function jogress($: EngineInterface, ctx: Ctx): Promise<string> {
527 const t = await $.clock.now()
528 const today = dayOf(t, await configOf($, ctx))
529 const days = await byDay(storeOf($))
530 const pet = await petOf($)
531 const all = countsOf(days, today, ctx.pace)
532 const made = jogressOf(pet.species, minus(all, pet.base), SPECIES, { life: all, day: today, pace: ctx.pace })
533 if ('missing' in made) return made.missing
534 const text = `${spriteOf(pet.species).name} and ${made.partner} jogressed into ${spriteOf(made.to).name}!`
535 await digivolve($, ctx, { species: made.to, enteredDay: today, base: all, log: [...pet.log, { id: made.to, day: today }] }, pet.species, text)
536 await grow($, ctx)
537 return text
538}
539
540/** A conversation's own state: its clock, project, side flag, the pet as kept, and the tick. */
541async function begin($: EngineInterface, ctx: Ctx, cwd: string) {
542 const zone = await $.process.run(['date', '+%z']).catch(() => null)
543 const offsetMin = parseOffset(zone?.stdout ?? '') ?? -new Date().getTimezoneOffset()
544 const project = cwd.replace(/\/$/, '').split('/').pop() || cwd
545 const ttl = Number(await $.store.get('debugTtlMinutes')) || ctx.ttlMinutes
546 await update($, local, () => ({ offsetMin, project, ttlMs: ttl * MINUTE }))
547 const home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
548 const id = await $.session.id()
549 const keptSide = await $.store.get(`side:${id}`)
550 const isSide = typeof keptSide === 'boolean' ? keptSide : isSidePath(cwd, ctx.sidePaths, home)
551 await update($, feeding, f => ({ ...f, isSide }))
552 await grow($, ctx)
553 const onRecord = (await totals(storeOf($))).careMistakes
554 await update($, mistakes, () => onRecord)
555 startTick($, ctx, ttl * MINUTE)
556 await onTick($, ctx)
557}
558
559export const register: Register = (on, options) => {
560 const ctx = contextOf(options)
561
562 on('session.start', async ($, e, next) => {
563 await $.command.register({ name: 'digi', description: 'Your Digimon V-Pet: its hunger, breaks, side sessions and a preview of any species', argumentHint: USAGE })
564 await begin($, ctx, e.cwd)
565 return next(e)
566 })
567
568 on('session.end', async ($, e, next) => {
569 // A /clear starts a conversation with an empty cache; the side flag stays with the terminal.
570 if (e.reason === 'clear') {
571 ctx.isMistakePending = false
572 const keptSide = await $.store.get(`side:${e.sessionId}`)
573 ctx.sideOnClear = typeof keptSide === 'boolean' ? keptSide : undefined
574 await update($, feeding, f => ({ ...EMPTY, isSide: f.isSide }))
575 }
576 return next(e)
577 })
578
579 // A /clear raises no session.start, and its conversation starts with no state: set it up again, or the band draws an egg until the next tick.
580 on('classic.SessionStart', async ($, e, next) => {
581 if (e.source === 'clear') {
582 if (ctx.sideOnClear !== undefined) await $.store.set(`side:${await $.session.id()}`, ctx.sideOnClear)
583 ctx.sideOnClear = undefined
584 await begin($, ctx, e.cwd)
585 }
586 return next(e)
587 })
588
589 // A subagent is an ally on the field while it runs: in when it starts, out (done or failed) when it stops.
590 on('classic.SubagentStart', async ($, e, next) => {
591 const info = (await agents($))?.find(a => a.id === e.agent_id)
592 const type = oneLine(e.agent_type)
593 const ally: DigiAlly = { id: e.agent_id, type, label: oneLine(info?.description ?? '') || type, species: allyOf(type) }
594 await update($, allies, list => (list.some(a => a.id === ally.id) ? list : [...list, ally]))
595 return next(e)
596 })
597
598 on('classic.SubagentStop', async ($, e, next) => {
599 // Its status may still read running as it stops: only a failed or killed one is a loss.
600 const status = (await agents($))?.find(a => a.id === e.agent_id)?.status
601 await leave($, e.agent_id, status === 'failed' || status === 'killed' ? 'failed' : 'done')
602 return next(e)
603 })
604
605 on('prompt.edit', async ($, e, next) => {
606 const box = await next(e)
607 const isOpen = isPickerOpen(box.text, box.cursor)
608 if ((await read($, isPicking)) !== isOpen) await update($, isPicking, () => isOpen)
609 return box
610 })
611
612 on('prompt.submit', async ($, e, next) => {
613 if (await read($, isPicking)) await update($, isPicking, () => false)
614 const t = await $.clock.now()
615 const f = await read($, feeding)
616 const cfg = await configOf($, ctx)
617 const mine = await ledgerOf($, ctx).read(await $.session.id())
618 const days = Object.entries(mine)
619 .filter(([, c]) => c.careMistakes > 0)
620 .map(([day]) => day)
621 ctx.isMistakePending = isCareMistake(f, t, cfg, days)
622 // A prompt ends `/digi sleep` and a break: the person is back.
623 if (f.isAsleep || f.breakUntil !== null) await update($, feeding, x => ({ ...x, isAsleep: false, breakUntil: null }))
624 await bump($, ctx, { prompts: 1 })
625 return next(e)
626 })
627
628 on('turn.step', async function* ($, e, next) {
629 const result = yield* next(e)
630 if (!e.agentId && result.stopReason !== null) await meal($, ctx, result.usage)
631 if (result.toolUses.length >= 2) await bump($, ctx, { parallelSteps: 1 })
632 return result
633 })
634
635 on('turn.start', async ($, e, next) => {
636 ctx.running.clear()
637 ctx.runs.clear()
638 ctx.isTraining = false
639 ctx.red.clear()
640 await update($, battle, b => (b.open || b.flash ? { ...b, open: 0, flash: false } : b))
641 await update($, activity, () => ({ act: 'think' }) as DigiActivity)
642 return next(e)
643 })
644
645 on('tool.call', async ($, e, next) => {
646 if (e.agentId) return trained($, ctx, e, await next(e))
647 ctx.isTraining = true
648 if ((await read($, battle)).flash) await update($, battle, b => ({ ...b, flash: false }))
649 const input = e as unknown as { tool: string } & Record<string, unknown>
650 ctx.running.set(e.tool_use_id, toolLabel(input))
651 const run = { id: e.tool_use_id, ...toolRun(input) }
652 ctx.runs.set(e.tool_use_id, run)
653 await update($, activity, () => (e.tool === 'AskUserQuestion' ? { act: 'ask' } : { act: 'tool', tool: busyLabel([...ctx.running.values()]), run }) as DigiActivity)
654 try {
655 return await trained($, ctx, e, await next(e))
656 } finally {
657 ctx.running.delete(e.tool_use_id)
658 ctx.runs.delete(e.tool_use_id)
659 await settle($, ctx)
660 }
661 })
662
663 on('turn.complete', async ($, e, next) => {
664 const result = await next(e)
665 if (e.agentId) return result
666 ctx.running.clear()
667 ctx.runs.clear()
668 await update($, activity, () => ({ act: 'idle' }) as DigiActivity)
669 const t = await $.clock.now()
670 // A check still red when the turn ends is a battle lost; an interrupted turn fought none to the end, and is a risky move.
671 const isAborted = e.reason === 'aborted'
672 const lost = isAborted ? 0 : ctx.red.size
673 await bump($, ctx, {
674 turns: 1,
675 activeMs: e.durationMs,
676 ...(isResting(t, await configOf($, ctx)) ? { restTurns: 1 } : {}),
677 ...(ctx.isTraining ? { toolTurns: 1 } : {}),
678 ...(ctx.isTraining && e.reason === 'answer' && e.durationMs < QUICK_MS ? { quickTurns: 1 } : {}),
679 ...(lost ? { battlesLost: lost } : {}),
680 ...(isAborted ? { chaos: 1 } : {}),
681 })
682 ctx.isTraining = false
683 ctx.red.clear()
684 await update($, battle, b => (b.open ? { ...b, open: 0 } : b))
685 const m = await read($, mood)
686 if (m.kind === 'sick') await setMood($, ctx, m.turnsLeft > 1 ? { kind: 'sick', turnsLeft: m.turnsLeft - 1 } : { kind: 'normal', turnsLeft: 0 })
687 await grow($, ctx)
688 return result
689 })
690
691 on('session.measure', async ($, e, next) => {
692 const tokens = e.context.tokens ?? null
693 if ((await read($, feeding)).contextTokens !== tokens) await update($, feeding, f => ({ ...f, contextTokens: tokens }))
694 const percent = e.context.percent
695 if (percent !== undefined && percent >= OVERFULL_PERCENT && !ctx.isOverfull) {
696 ctx.isOverfull = true
697 await bump($, ctx, { overfeeds: 1 })
698 } else if (percent !== undefined && percent < REFED_PERCENT) ctx.isOverfull = false
699 return next(e)
700 })
701
702 on('command.run', { command: 'digi' }, async ($, e) => {
703 const [verb = '', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
704 const arg = rest.join(' ')
705 const t = await $.clock.now()
706 const cfg = await configOf($, ctx)
707 const id = await $.session.id()
708 switch (verb.toLowerCase()) {
709 case 'pane':
710 await $.ui.open({ id: PANE, title: 'digi-pet' })
711 return { text: 'Opened the digi-pet pane.' }
712 case 'pet': {
713 await bump($, ctx, { pats: 1 })
714 // A pat cheers a pet up, but does not cure one sick from a care mistake.
715 if ((await read($, mood)).kind !== 'sick') await setMood($, ctx, { kind: 'happy', turnsLeft: 0 })
716 await grow($, ctx)
717 return { text: `${spriteOf(await read($, species)).name} is happy! ♥ (the first ${PATS_PER_DAY} pats a day raise SYN)` }
718 }
719 case 'log': {
720 const g = await read($, growth)
721 const pet = await petOf($)
722 if (!pet.log.length) return { text: `${spriteOf(pet.species).name} has not digivolved yet.${g?.next ? ` Next: ${g.next.label}` : ''}` }
723 return { text: [`Evolution of ${spriteOf(pet.species).name}:`, ...pet.log.map(e => ` ${e.day} ${spriteOf(e.id).name}`), ...(g?.next ? [`Next: ${g.next.label}`] : [])].join('\n') }
724 }
725 case 'jogress':
726 return { text: await jogress($, ctx) }
727 case 'sleep':
728 await update($, feeding, f => ({ ...f, isAsleep: true }))
729 return { text: 'The pet sleeps until your next prompt: no hunger, no alerts.' }
730 case 'wake': {
731 // Read the clock and the pet afresh first: a conversation that lost them sleeps by UTC.
732 await begin($, ctx, await $.session.cwd())
733 const fresh = await configOf($, ctx)
734 const end = restEnd(t, fresh)
735 await update($, feeding, f => ({ ...f, isAsleep: false, breakUntil: null, awakeUntil: end }))
736 const name = spriteOf(await read($, species)).name
737 return { text: end ? `${name} is awake until ${clockText(end, fresh.offsetMin)}, when this rest window ends: hunger and alerts are on.` : `${name} is awake.` }
738 }
739 case 'break': {
740 if (arg === 'off') {
741 await update($, feeding, f => ({ ...f, breakUntil: null }))
742 return { text: 'Break over.' }
743 }
744 const until = parseBreak(arg || '60m', t, cfg.offsetMin)
745 if (until === null) return { text: 'Usage: /digi break 90m | 1h30m | until 15:30 | off' }
746 await update($, feeding, f => ({ ...f, breakUntil: until }))
747 return { text: `On break until ${clockText(until, cfg.offsetMin)}: no hunger, no alerts, and a cache gone cold meanwhile is no care mistake. Your next prompt ends it.` }
748 }
749 case 'side': {
750 const isSide = arg !== 'off'
751 await update($, feeding, f => ({ ...f, isSide }))
752 await $.store.set(`side:${id}`, isSide)
753 return { text: isSide ? 'Side session: the pet does not get hungry here. Usage still counts toward its growth. `/digi side off` to undo.' : 'Main session again: the pet minds this cache.' }
754 }
755 case 'sim': {
756 if (rest[0] === 'off' || !rest[0]) {
757 await update($, sim, () => null)
758 return { text: 'Simulation off.' }
759 }
760 const [who, state = 'full', to] = rest
761 if (!isSpecies(who!)) return { text: `No species "${who}". Ids are the chart's short names: bota, koro, agu, grey, devi, nume, metalgrey_vi, blitzgrey, …` }
762 if (!STATES.includes(state as (typeof STATES)[number])) return { text: `States: ${STATES.join(' ')}` }
763 if (to !== undefined && !isSpecies(to)) return { text: `No species "${to}".` }
764 await update($, sim, () => ({ species: who!, state, to: to ?? (state === 'evolving' ? 'grey' : null) }))
765 return { text: `Showing ${spriteOf(who!).name} as ${state}. \`/digi sim off\` to go back.` }
766 }
767 case 'debug': {
768 if (rest[0] !== 'ttl') return { text: 'Usage: /digi debug ttl <minutes> | off' }
769 const minutes = rest[1] === 'off' ? null : Number(rest[1])
770 if (minutes !== null && !(minutes > 0)) return { text: 'Usage: /digi debug ttl <minutes> | off' }
771 await $.store.set('debugTtlMinutes', minutes)
772 await update($, local, l => ({ ...l, ttlMs: (minutes ?? ctx.ttlMinutes) * MINUTE }))
773 startTick($, ctx, (minutes ?? ctx.ttlMinutes) * MINUTE)
774 ctx.warned = { fedAt: -1, levels: new Set() }
775 await onTick($, ctx)
776 return { text: minutes ? `Cache TTL is ${minutes} min for the pet until \`/digi debug ttl off\`.` : `Cache TTL back to ${ctx.ttlMinutes} min.` }
777 }
778 case '':
779 case 'stats': {
780 const v = await viewOf($, ctx, t)
781 const f = await read($, feeding)
782 const g = await read($, growth)
783 const lines = [
784 `${spriteOf(v.species).name} (${spriteOf(v.species).stage}${v.attribute ? `, ${v.attribute}` : ''})${g ? ` · day ${g.ageDays}` : ''} · ${weightOf(f.contextTokens)}g`,
785 ...(g ? growthLines(g) : []),
786 v.quiet ? `Hunger: ${v.quiet}` : f.lastFedAt === null ? 'Hunger: not fed yet' : `Hunger: ${v.hunger}, the cache goes cold in ${Math.ceil(v.minutesLeft)}m (TTL ${cfg.ttlMs / MINUTE}m)`,
787 `Care mistakes: ${await read($, mistakes)} in all`,
788 ...(await recordLines($, ctx)),
789 ...(await read($, facts)),
790 `Rest hours: ${ctx.restText} · side session: ${f.isSide ? 'yes' : 'no'}`,
791 `Usage: /digi ${USAGE}`,
792 ]
793 return { text: lines.join('\n') }
794 }
795 default:
796 return { text: `Usage: /digi ${USAGE}` }
797 }
798 })
799
800 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
801 if (e.props.hasSurvey) return next(e)
802 const ui = $.ui.resolve(e)
803 const { Box } = ui
804 const t = await read($, now)
805 const v = await viewOf($, ctx, t)
806 const width = Math.max(20, e.props.bodyColumns - 2)
807 const pv = petView(v, 0)
808 // While a `/` or `@` picker is open the engine draws it above the band: one row keeps it near the prompt.
809 const room = (await read($, isPicking)) ? 1 : e.props.maxRows
810 let mine: RenderElement
811 if ('Client' in ui && room >= WORK_ROWS && width >= 60) {
812 const { Client } = ui
813 const isFighting = v.act !== undefined && !(isLoud(pv) && room >= LOUD_ROWS)
814 const fight = await read($, battle)
815 const block = Math.min(width, isFighting ? WORK_BLOCK : BAND_WIDTH)
816 // With a row to spare under the Digivice, what the tool runs goes there and the bubble is the pet's own.
817 const run = isFighting && v.act === 'tool' && room > DEVICE_ROWS ? (await read($, activity)).run : undefined
818 const shown = run ? { ...v, tool: 'Fighting!' } : v
819 const props: ScreenProps = { kind: 'band', width: block, maxRows: run ? room - 1 : room, view: shown, isStill: ctx.isStill }
820 const shape = isLoud(pv) && room >= LOUD_ROWS ? 'loud' : (v.act ?? 'idle')
821 const screen = <Client key={`band-${v.species}-${shape}`} module="./screen.tsx" width={block} props={props} />
822 const field = width - block - 2
823 const top =
824 isFighting && field >= MIN_ARENA ? (
825 <Box flexDirection="row" columnGap={2} alignItems="flex-end">
826 <Client key={`arena-${v.act}-${fight.won}`} module="./arena.tsx" width={field} props={{ width: field, act: v.act!, fight, allies: v.allies ?? [], isStill: ctx.isStill }} />
827 {screen}
828 </Box>
829 ) : (
830 screen
831 )
832 const runProps: RunProps | undefined = run && { width, tool: run.tool, text: run.text, isStill: ctx.isStill }
833 mine = runProps ? (
834 <Box flexDirection="column" alignItems="flex-end">
835 {top}
836 <Client key={`run-${run!.id}`} module="./run.tsx" width={width} props={runProps} />
837 </Box>
838 ) : (
839 top
840 )
841 } else if (room >= MINI_BAND_ROWS && width >= 40) {
842 mine = drawn(ui, miniBand(pv, 0, Math.min(width, BAND_WIDTH)))
843 } else {
844 mine = drawn(ui, band(pv, 0, width, 1))
845 }
846 const below = await next(e)
847 // At the band's right edge, as spinner's pet sat.
848 return (
849 <Box flexDirection="column">
850 <Box flexDirection="row" justifyContent="flex-end">
851 {mine}
852 </Box>
853 {below}
854 </Box>
855 )
856 })
857
858 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
859 const ui = $.ui.resolve(e)
860 const { Text } = ui
861 const t = await read($, now)
862 const g = await read($, growth)
863 const view: PaneJson = {
864 ...(await viewOf($, ctx, t)),
865 careMistakes: await read($, mistakes),
866 facts: [...(await recordLines($, ctx)), ...(await read($, facts))],
867 ...(g ? { stats: g.stats, battles: g.battles, ageDays: g.ageDays, log: g.log, trophies: g.trophies, chaos: g.stage.chaos, forecast: g.forecast } : {}),
868 weight: weightOf((await read($, feeding)).contextTokens),
869 }
870 if (!('Client' in ui)) return <Text>{`${spriteOf(view.species).name} · ${view.quiet ?? view.hunger}`}</Text>
871 const { Client } = ui
872 const width = Math.max(40, e.props.bodyColumns)
873 const props: ScreenProps = { kind: 'pane', width, view, isStill: ctx.isStill }
874 return <Client key="pane" module="./screen.tsx" width={width} props={props} />
875 })
876}
877hooks/activity.ts 47 lines1// What a running turn is doing, for the pet's bubble. `toolLabel` and
2// `busyLabel` are after hoobnn's spinner mod (claude-code/spinner/hooks/pet.ts,
3// https://github.com/hoobnn/hoobnn-agent-mods, MIT © 2026 hoobnn).
4import { textWidth } from './cells'
5
6/** Text from a tool call or a subagent as one clean line: the first line, tabs as spaces, control characters (C0, DEL, C1) out, runs of space as one. */
7export function oneLine(text: string): string {
8 return text.split('\n')[0]!.replace(/\t/g, ' ').replace(/[\u0000-\u001f\u007f-\u009f]/g, '').replace(/\s+/g, ' ').trim()
9}
10
11/** What a tool call is about, short: `Bash: npm test`, `Edit: render.ts`, `WebSearch`. */
12export function toolLabel(e: { tool: string } & Record<string, unknown>): string {
13 const clip = (raw: string) => {
14 const s = oneLine(raw)
15 return textWidth(s) > 32 ? `${Array.from(s).slice(0, 31).join('')}…` : s
16 }
17 if (typeof e.command === 'string') return clip(`${e.tool}: ${e.command.split('\n')[0]!.trim()}`)
18 const path = [e.file_path, e.notebook_path, e.path].find(p => typeof p === 'string') as string | undefined
19 if (path) return clip(`${e.tool}: ${path.split('/').pop()}`)
20 if (typeof e.pattern === 'string') return clip(`${e.tool}: ${e.pattern}`)
21 if (typeof e.description === 'string' && e.description.trim()) return clip(`${e.tool}: ${e.description.trim()}`)
22 return clip(e.tool.replace(/^mcp__[^_]+__/, ''))
23}
24
25/** The bubble for the calls running now: subagents side by side are counted, else the latest call. */
26export function busyLabel(labels: readonly string[]): string | undefined {
27 const agents = labels.filter(label => /^(?:Agent|Task)\b/.test(label))
28 return agents.length > 1 ? `Agent ×${agents.length}` : labels[labels.length - 1]
29}
30
31/** What a tool runs, for the row under the pet: the tool's name and its command, path, pattern or task, one clean line. */
32export function toolRun(e: { tool: string } & Record<string, unknown>): { tool: string; text: string } {
33 const tool = e.tool.replace(/^mcp__[^_]+__/, '')
34 const str = (v: unknown) => (typeof v === 'string' ? v : undefined)
35 const task = [str(e.subagent_type), str(e.description)?.trim()].filter(Boolean).join(' · ')
36 const text = str(e.command) ?? str(e.file_path) ?? str(e.notebook_path) ?? str(e.url) ?? str(e.query) ?? str(e.pattern) ?? (task || str(e.path)) ?? ''
37 // One line, no escapes or tabs to break the row: the first line, control characters out, runs of space as one.
38 return { tool: oneLine(tool), text: oneLine(text) }
39}
40
41/** Which Baby II stands in for a subagent, by its type: the same one every time, so you learn them. */
42export function allyOf(type: string): string {
43 const known: Record<string, string> = { 'general-purpose': 'koro', Explore: 'toko', Plan: 'tuno' }
44 const others = ['tane', 'pagu']
45 return known[type] ?? others[[...type].reduce((h, ch) => h + ch.charCodeAt(0), 0) % others.length]!
46}
47hooks/cells.ts 116 lines1// Cells and half-block pixels, copied from hoobnn's spinner mod
2// (https://github.com/hoobnn/hoobnn-agent-mods, claude-code/spinner/hooks/cells.ts, MIT © 2026 hoobnn).
3
4export type Style = { c?: string; bg?: string; b?: boolean; d?: boolean }
5export type Cell = Style & { ch: string }
6export type Seg = Style & { text: string }
7export type Grid = Cell[][]
8
9// ---- cells ---------------------------------------------------------------
10
11/** Cells a code point takes: CJK, fullwidth forms and emoji two, the rest one. */
12function cpWidth(cp: number): number {
13 if (cp < 0x1100) return 1
14 if (
15 cp <= 0x115f ||
16 (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) ||
17 (cp >= 0xac00 && cp <= 0xd7a3) ||
18 (cp >= 0xf900 && cp <= 0xfaff) ||
19 (cp >= 0xfe30 && cp <= 0xfe4f) ||
20 (cp >= 0xff00 && cp <= 0xff60) ||
21 (cp >= 0xffe0 && cp <= 0xffe6) ||
22 (cp >= 0x1f300 && cp <= 0x1faff) ||
23 (cp >= 0x20000 && cp <= 0x3fffd)
24 ) {
25 return 2
26 }
27 return 1
28}
29
30export function textWidth(text: string): number {
31 let w = 0
32 for (const ch of text) w += cpWidth(ch.codePointAt(0)!)
33 return w
34}
35
36/** `text` padded with spaces to `w` cells. */
37export function padTo(text: string, w: number): string {
38 return text + ' '.repeat(Math.max(0, w - textWidth(text)))
39}
40
41export function blank(w: number, h: number): Grid {
42 return Array.from({ length: h }, () => Array.from({ length: w }, () => ({ ch: ' ' })))
43}
44
45/** Writes `text` at column `x` of row `y`, clipped to the grid; a wide character takes two cells. */
46export function put(g: Grid, x: number, y: number, text: string, style: Style = {}): void {
47 const row = g[y]
48 if (!row) return
49 let col = Math.round(x)
50 for (const ch of text) {
51 const w = cpWidth(ch.codePointAt(0)!)
52 if (col >= 0 && col + w <= row.length) {
53 // A wide character left half-covered would shift the rest of the row.
54 if (row[col]!.ch === '' && col > 0) row[col - 1] = { ch: ' ' }
55 row[col] = { ch, ...style }
56 if (w === 2) row[col + 1] = { ch: '' }
57 else if (col + 1 < row.length && row[col + 1]!.ch === '') row[col + 1] = { ch: ' ' }
58 }
59 col += w
60 }
61}
62
63/** A row as runs of one style each, what a Text per run draws. */
64export function segments(row: Cell[]): Seg[] {
65 const out: Seg[] = []
66 for (const cell of row) {
67 // The right half of a wide character: drawn by its left half.
68 if (cell.ch === '') continue
69 const last = out[out.length - 1]
70 if (last && last.c === cell.c && last.bg === cell.bg && !!last.b === !!cell.b && !!last.d === !!cell.d) last.text += cell.ch
71 else out.push({ text: cell.ch, c: cell.c, bg: cell.bg, b: cell.b || undefined, d: cell.d || undefined })
72 }
73 return out
74}
75
76// ---- pixels --------------------------------------------------------------
77
78/** Two pixels a cell, one above the other: `w` across, `rows * 2` down. */
79export type Canvas = { w: number; h: number; px: (string | undefined)[] }
80
81export function canvas(w: number, rows: number): Canvas {
82 return { w, h: rows * 2, px: new Array(w * rows * 2) }
83}
84
85export function plot(cv: Canvas, x: number, y: number, color: string): void {
86 const px = Math.round(x)
87 const py = Math.round(y)
88 if (px >= 0 && px < cv.w && py >= 0 && py < cv.h) cv.px[py * cv.w + px] = color
89}
90
91/** Draws pixel art: each character of `art` a pixel colored by `colors`, `.` left clear. */
92export function draw(cv: Canvas, x: number, y: number, art: readonly string[], colors: Record<string, string>): void {
93 art.forEach((line, j) => {
94 for (let i = 0; i < line.length; i++) {
95 const color = colors[line[i]!]
96 if (color) plot(cv, x + i, y + j, color)
97 }
98 })
99}
100
101/** The canvas as cells: `▀` in the top pixel's color over the bottom one's. */
102export function cells(cv: Canvas): Grid {
103 const g = blank(cv.w, cv.h / 2)
104 for (let r = 0; r < cv.h / 2; r++) {
105 for (let x = 0; x < cv.w; x++) {
106 const top = cv.px[2 * r * cv.w + x]
107 const bottom = cv.px[(2 * r + 1) * cv.w + x]
108 const row = g[r]!
109 if (top && bottom) row[x] = top === bottom ? { ch: '█', c: top } : { ch: '▀', c: top, bg: bottom }
110 else if (top) row[x] = { ch: '▀', c: top }
111 else if (bottom) row[x] = { ch: '▄', c: bottom }
112 }
113 }
114 return g
115}
116hooks/growth.ts 287 lines1// How the pet grows, pure: the six stats from every day's counters, and when and
2// into what it digivolves. The branches are Digital Monster Color's own rules
3// (care mistakes, training, overfeed, sleep disturbances, battles, win ratio),
4// counted since the pet entered its stage; how long a stage lasts is ours (GATES),
5// and so are Chaos turning a branch Virus and a jogress partner on call.
6import type { Counters, Ledger } from './ledger'
7import { STAT_NAMES } from './render'
8import type { StatName } from './render'
9
10export type Range = { min: number; max: number | null }
11const REQUIREMENTS = ['careMistakes', 'training', 'overfeed', 'sleepDisturbances', 'battles', 'winRatio'] as const
12export type Requirement = (typeof REQUIREMENTS)[number]
13/** A way on: the requirements to meet, or a jogress with the partner named. */
14export type Rule = { to: string; jogress?: string } & Partial<Record<Requirement, Range>>
15export type SpeciesRules = { name: string; stage: string; attribute: string; power: number | null; rules: Rule[] }
16
17/** What a stage's rules read: counted since the pet entered the stage. */
18export type Counts = {
19 turns: number
20 /** Days before today with at least ACTIVE_TURNS turns: a holiday is no day of growth. */
21 activeDays: number
22 careMistakes: number
23 training: number
24 overfeed: number
25 sleepDisturbances: number
26 battles: number
27 wins: number
28 trophies: number
29 summons: number
30 chaos: number
31}
32
33/** The pet as the store keeps it (`pet`): what it is, since when, and the counts it entered its stage with. */
34export type Pet = { species: string; enteredDay: string; base: Counts; log: { id: string; day: string }[] }
35
36/** What else growing reads: every battle ever (the win ratio carries over a digivolution), the day, the pace. */
37export type Context = { life: Pick<Counts, 'battles' | 'wins'>; day: string; pace: number }
38
39export const START_SPECIES = 'egg1'
40export const ACTIVE_TURNS = 5
41/** Turns with a tool that count as training in a day (at normal pace); more that day train no further. */
42export const TRAINING_PER_DAY = 12
43/** Risky moves a stage takes, per day it lasts, before its branch turns Virus. */
44export const CHAOS_PER_DAY = 5
45/** `pace` → how long every stage lasts, against normal. */
46export const PACES: Record<string, number> = { fast: 0.25, normal: 1, slow: 2 }
47
48type Gate = { days?: number; turns?: number; trophies?: number; summons?: number }
49/** How long each stage lasts before the pet can digivolve. Guesses: tune after a few weeks. */
50export const GATES: Record<string, Gate> = {
51 egg: { turns: 3 },
52 baby1: { turns: 25 },
53 baby2: { days: 2, turns: 100 },
54 rookie: { days: 5 },
55 champion: { days: 10, trophies: 30 },
56 ultimate: { days: 21, trophies: 60, summons: 10 },
57 mega: { days: 14 },
58}
59const NEXT_STAGE: Record<string, string> = { egg: 'Baby I', baby1: 'Baby II', baby2: 'Rookie', rookie: 'Champion', champion: 'Ultimate', ultimate: 'Mega', mega: 'Jogress' }
60/** As the device asks of Stages V and VI: below 40% wins never, at 80% surely, between by chance. */
61const MIN_WIN_RATIO = 40
62/** What a jogress asks of a Mega: the battles a Stage VI asks for. */
63const JOGRESS = { battles: { min: 15, max: null }, winRatio: { min: 80, max: null } } as const
64
65/** A day's stat points: past DAILY_POINTS a stat gains a quarter as fast, so no one day makes the pet. */
66const DAILY_POINTS = 20
67const POINTS: Record<StatName, (c: Counters) => number> = {
68 STA: c => c.activeMs / 360_000,
69 INT: c => c.research / 4,
70 ATK: c => c.linesWritten / 25,
71 DEF: c => c.checksPassed * 2,
72 SPD: c => c.parallelSteps + c.quickTurns,
73 SYN: c => c.prompts / 2 + Math.min(PATS_PER_DAY, c.pats) * 2,
74}
75export const PATS_PER_DAY = 3
76/** A day's SYN for showing up: one point per active day in the week before it, up to five. */
77const STREAK_DAYS = 7
78const STREAK_POINTS = 5
79
80export const ZERO_COUNTS: Counts = {
81 turns: 0,
82 activeDays: 0,
83 careMistakes: 0,
84 training: 0,
85 overfeed: 0,
86 sleepDisturbances: 0,
87 battles: 0,
88 wins: 0,
89 trophies: 0,
90 summons: 0,
91 chaos: 0,
92}
93
94export function paceOf(name: unknown): number {
95 return PACES[String(name)] ?? 1
96}
97
98/** A gate's number at `pace`, never under one. */
99function scaled(n: number, pace: number): number {
100 return Math.max(1, Math.ceil(n * pace))
101}
102
103export function countsOf(days: Ledger, today: string, pace = 1): Counts {
104 const trainingCap = Math.ceil(TRAINING_PER_DAY / pace)
105 const c = { ...ZERO_COUNTS }
106 for (const [day, d] of Object.entries(days)) {
107 c.turns += d.turns
108 if (day < today && d.turns >= ACTIVE_TURNS) c.activeDays++
109 c.careMistakes += d.careMistakes
110 c.training += Math.min(trainingCap, d.toolTurns)
111 c.overfeed += d.overfeeds
112 c.sleepDisturbances += d.restTurns
113 c.battles += d.battlesWon + d.battlesLost
114 c.wins += d.battlesWon
115 c.trophies += d.trophies
116 c.summons += d.summons
117 c.chaos += d.chaos
118 }
119 return c
120}
121
122export function minus(a: Counts, b: Counts): Counts {
123 return Object.fromEntries(Object.keys(ZERO_COUNTS).map(k => [k, Math.max(0, a[k as keyof Counts] - b[k as keyof Counts])])) as Counts
124}
125
126export function statsOf(days: Ledger): Record<StatName, number> {
127 const soft = (n: number) => (n <= DAILY_POINTS ? n : DAILY_POINTS + (n - DAILY_POINTS) / 4)
128 const active = new Set(Object.entries(days).filter(([, d]) => d.turns >= ACTIVE_TURNS).map(([day]) => day))
129 const stats = Object.fromEntries(STAT_NAMES.map(s => [s, 0])) as Record<StatName, number>
130 for (const [day, d] of Object.entries(days)) {
131 for (const s of STAT_NAMES) stats[s] += soft(POINTS[s](d))
132 if (active.has(day)) stats.SYN += Math.min(STREAK_POINTS, daysBefore(day, STREAK_DAYS).filter(x => active.has(x)).length)
133 }
134 return Object.fromEntries(STAT_NAMES.map(s => [s, Math.floor(stats[s])])) as Record<StatName, number>
135}
136
137/** The `n` dates before `day` (YYYY-MM-DD). */
138function daysBefore(day: string, n: number): string[] {
139 const t = Date.parse(`${day}T12:00:00Z`)
140 return Array.from({ length: n }, (_, i) => new Date(t - (i + 1) * 86_400_000).toISOString().slice(0, 10))
141}
142
143export function winRatio(c: Pick<Counts, 'battles' | 'wins'>): number {
144 return c.battles ? Math.floor((100 * c.wins) / c.battles) : 0
145}
146
147/** The day's roll for a species, 0..1: the same in every session, a new one each day. */
148export function rollOf(id: string, day: string): number {
149 let h = 2166136261
150 for (const ch of `${id}@${day}`) h = Math.imul(h ^ ch.charCodeAt(0), 16777619)
151 return (h >>> 0) / 2 ** 32
152}
153
154function isMet(r: Rule, c: Counts, ctx: Context, roll: number): boolean {
155 const values: Record<Requirement, number> = { ...c, winRatio: winRatio(ctx.life) }
156 return REQUIREMENTS.every(k => {
157 const range = r[k]
158 if (!range) return true
159 const v = values[k]
160 if (range.max !== null && v > range.max) return false
161 if (v >= range.min) return true
162 // Short of the win ratio asked: from 40% up, a chance that grows with it.
163 return k === 'winRatio' && v >= MIN_WIN_RATIO && roll < (v - MIN_WIN_RATIO) / (range.min - MIN_WIN_RATIO)
164 })
165}
166
167function isGateOpen(stage: string, c: Counts, pace: number): boolean {
168 const gate = GATES[stage]
169 if (!gate) return false
170 return (['days', 'turns', 'trophies', 'summons'] as const).every(k => !gate[k] || c[k === 'days' ? 'activeDays' : k] >= scaled(gate[k], pace))
171}
172
173/** What the pet digivolves into now, given its stage's counts; null while its stage lasts or no rule lets it. */
174export function evolveTo(id: string, c: Counts, species: Record<string, SpeciesRules>, ctx: Context = { life: c, day: '', pace: 1 }): string | null {
175 const s = species[id]
176 if (!s || !s.rules.some(r => !r.jogress) || !isGateOpen(s.stage, c, ctx.pace)) return null
177 return branchOf(id, c, species, ctx)
178}
179
180/** Where the stage's counts lead, its gate aside: the Virus branch on enough Chaos, else the first rule met, else a Rookie's catch-all. */
181function branchOf(id: string, c: Counts, species: Record<string, SpeciesRules>, ctx: Context): string | null {
182 const s = species[id]!
183 const rules = s.rules.filter(r => !r.jogress)
184 // A reckless stage takes the Virus branch, where it has one.
185 const virus = virusOf(s, species)
186 if (virus && c.chaos >= chaosLimit(s, ctx.pace)) return virus
187 const roll = rollOf(id, ctx.day)
188 const rule = rules.find(r => isMet(r, c, ctx, roll))
189 if (rule) return rule.to
190 // A Rookie no rule takes still digivolves, as on the device: into its chart's catch-all, listed last.
191 return s.stage === 'rookie' ? rules[rules.length - 1]!.to : null
192}
193
194/** The Virus branch Chaos can send a stage down: only where it has a choice. */
195function virusOf(s: SpeciesRules, species: Record<string, SpeciesRules>): string | null {
196 const targets = [...new Set(s.rules.filter(r => !r.jogress).map(r => r.to))]
197 return targets.length > 1 ? (targets.find(to => species[to]?.attribute === 'Virus') ?? null) : null
198}
199
200/** The Chaos that turns a stage Virus: CHAOS_PER_DAY for each day it lasts. */
201function chaosLimit(s: SpeciesRules, pace: number): number {
202 return CHAOS_PER_DAY * scaled(GATES[s.stage]?.days ?? 1, pace)
203}
204
205/** A rule's requirement as it stands: the count, the range asked, and whether the count is in it. */
206export type Need = { key: Requirement; have: number; min: number; max: number | null; isMet: boolean }
207/** A way on from the stage: where it leads, its closest rule, whether the counts lead there now, and whether it is a Rookie's catch-all. */
208export type Branch = { to: string; needs: Need[]; isNow: boolean; isCatchAll: boolean }
209export type Forecast = { branches: Branch[]; virus: { to: string; have: number; need: number } | null }
210
211/** The stage's ways on, as the pane shows them; null where there is no choice to make. */
212export function forecastOf(id: string, c: Counts, species: Record<string, SpeciesRules>, ctx: Context = { life: c, day: '', pace: 1 }): Forecast | null {
213 const s = species[id]
214 const rules = s?.rules.filter(r => !r.jogress) ?? []
215 const targets = [...new Set(rules.map(r => r.to))]
216 if (!s || targets.length < 2) return null
217 const values: Record<Requirement, number> = { ...c, winRatio: winRatio(ctx.life) }
218 const needsOf = (r: Rule): Need[] =>
219 REQUIREMENTS.filter(k => r[k]).map(k => {
220 const { min, max } = r[k]!
221 return { key: k, have: values[k], min, max, isMet: values[k] >= min && (max === null || values[k] <= max) }
222 })
223 const unmet = (needs: Need[]) => needs.filter(n => !n.isMet).length
224 const now = branchOf(id, c, species, ctx)
225 const virus = virusOf(s, species)
226 return {
227 branches: targets.map(to => ({
228 to,
229 // A target several rules lead to stands by the one closest to met.
230 needs: rules.filter(r => r.to === to).map(needsOf).reduce((best, n) => (unmet(n) < unmet(best) ? n : best)),
231 isNow: to === now,
232 isCatchAll: s.stage === 'rookie' && to === rules[rules.length - 1]!.to,
233 })),
234 virus: virus ? { to: virus, have: c.chaos, need: chaosLimit(s, ctx.pace) } : null,
235 }
236}
237
238/** The jogress a Mega can make now (`/digi jogress`): its partner and what they become, or why not. */
239export function jogressOf(id: string, c: Counts, species: Record<string, SpeciesRules>, ctx: Context): { to: string; partner: string } | { missing: string } {
240 const rule = species[id]?.rules.find(r => r.jogress)
241 if (!rule) return { missing: `${species[id]?.name ?? id} has no jogress partner on its chart.` }
242 if (!isGateOpen(species[id]!.stage, c, ctx.pace)) return { missing: `Not yet: ${progressOf(id, c, species, ctx)?.label ?? ''}` }
243 if (!isMet({ to: rule.to, ...JOGRESS }, c, ctx, rollOf(id, ctx.day))) return { missing: `Not yet: ${JOGRESS.battles.min} battles this stage and a ${JOGRESS.winRatio.min}% win ratio first.` }
244 return { to: rule.to, partner: rule.jogress! }
245}
246
247/** The way to the next stage (`day 1/2 · turns 40/100 → Rookie`) and how far along, 0..1; null at the last stage. */
248export function progressOf(id: string, c: Counts, species: Record<string, SpeciesRules>, ctx: Context = { life: c, day: '', pace: 1 }): { label: string; ratio: number } | null {
249 const s = species[id]
250 const gate = s && GATES[s.stage]
251 if (!s || !gate || !s.rules.length) return null
252 const parts: string[] = []
253 const ratios: number[] = []
254 const add = (label: string, have: number, need: number) => {
255 parts.push(`${label} ${Math.min(have, need)}/${need}`)
256 ratios.push(have / need)
257 }
258 if (gate.days) add('day', c.activeDays, scaled(gate.days, ctx.pace))
259 if (gate.turns) add('turns', c.turns, scaled(gate.turns, ctx.pace))
260 if (gate.trophies) add('trophies', c.trophies, scaled(gate.trophies, ctx.pace))
261 if (gate.summons) add('summons', c.summons, scaled(gate.summons, ctx.pace))
262 const fights = s.rules.some(r => r.jogress) ? JOGRESS : s.rules.find(r => r.battles)
263 if (fights?.battles) {
264 parts.push(`battles ${c.battles}/${fights.battles.min} · win ${winRatio(ctx.life)}%${fights.winRatio ? `/${fights.winRatio.min}%` : ''}`)
265 ratios.push(c.battles / fights.battles.min)
266 }
267 return { label: `${parts.join(' · ')} → ${NEXT_STAGE[s.stage]}`, ratio: Math.max(0, Math.min(1, ...ratios)) }
268}
269
270/** A new pet's egg: one of the chart's versions, `roll` (0..1) picking among them evenly. */
271export function eggOf(species: Record<string, SpeciesRules>, roll: number): string {
272 const eggs = Object.keys(species).filter(id => species[id]!.stage === 'egg')
273 return eggs[Math.min(eggs.length - 1, Math.floor(roll * eggs.length))] ?? START_SPECIES
274}
275
276export function asPet(kept: unknown, start = START_SPECIES): Pet {
277 const p = (kept && typeof kept === 'object' ? kept : {}) as Partial<Pet>
278 const base = { ...ZERO_COUNTS }
279 for (const k of Object.keys(base) as (keyof Counts)[]) base[k] = Number(p.base?.[k]) || 0
280 return {
281 species: typeof p.species === 'string' ? p.species : start,
282 enteredDay: typeof p.enteredDay === 'string' ? p.enteredDay : '',
283 base,
284 log: Array.isArray(p.log) ? p.log.filter(e => e && typeof e.id === 'string').map(e => ({ id: e.id, day: String(e.day ?? '') })) : [],
285 }
286}
287hooks/hunger.ts 201 lines1// The pet's hunger: this session's prompt cache, how long it has left, when the
2// pet may yell about it, and when a cold cache counts as a care mistake. Pure:
3// every time is epoch milliseconds, and local time comes from an offset.
4import type { Hunger } from './render'
5
6const MINUTE = 60_000
7const DAY = 24 * 60 * MINUTE
8
9export type Config = {
10 ttlMs: number
11 /** Rest windows, in minutes of the local day; `end` before `start` runs past midnight. */
12 rest: Window[]
13 /** The hour the day begins, so a late night belongs to the evening before. */
14 dayStartsHour: number
15 minContextTokens: number
16 /** Local time minus UTC, in minutes. */
17 offsetMin: number
18}
19
20export type Window = { start: number; end: number }
21
22/** This session's side of the hunger. */
23export type Feeding = {
24 /** When the last request of the main loop answered; null before the first. */
25 lastFedAt: number | null
26 isSide: boolean
27 /** `/digi sleep`: done with this session until the next prompt. */
28 isAsleep: boolean
29 /** `/digi break`: resting until then, or null. */
30 breakUntil: number | null
31 /** `/digi wake`: up through the rest window it woke in, until that window ends; null otherwise. */
32 awakeUntil: number | null
33 /** The live context's tokens, as the last measurement said; null before one. */
34 contextTokens: number | null
35}
36
37/** `"12:00-14:00,18:00-08:00"` as windows; a part that does not parse is left out. */
38export function parseRest(text: string): Window[] {
39 const minutes = (hm: string) => {
40 const m = hm.trim().match(/^(\d{1,2})(?::(\d{2}))?$/)
41 if (!m) return null
42 const h = Number(m[1])
43 const min = Number(m[2] ?? 0)
44 return h <= 24 && min < 60 ? (h * 60 + min) % (24 * 60) : null
45 }
46 return text
47 .split(',')
48 .map(part => part.split('-'))
49 .flatMap(([a, b]) => {
50 const start = a === undefined ? null : minutes(a)
51 const end = b === undefined ? null : minutes(b)
52 return start === null || end === null || start === end ? [] : [{ start, end }]
53 })
54}
55
56/** Minutes into the local day at `ms`. */
57export function localMinute(ms: number, offsetMin: number): number {
58 const m = Math.floor((ms + offsetMin * MINUTE) / MINUTE) % (24 * 60)
59 return m < 0 ? m + 24 * 60 : m
60}
61
62/** The local working day `ms` falls in, as `YYYY-MM-DD`: before `dayStartsHour` is still yesterday. */
63export function dayOf(ms: number, cfg: Pick<Config, 'offsetMin' | 'dayStartsHour'>): string {
64 return new Date(ms + cfg.offsetMin * MINUTE - cfg.dayStartsHour * 60 * MINUTE).toISOString().slice(0, 10)
65}
66
67function inWindow(minute: number, w: Window): boolean {
68 return w.start < w.end ? minute >= w.start && minute < w.end : minute >= w.start || minute < w.end
69}
70
71export function isResting(ms: number, cfg: Pick<Config, 'rest' | 'offsetMin'>): boolean {
72 const minute = localMinute(ms, cfg.offsetMin)
73 return cfg.rest.some(w => inWindow(minute, w))
74}
75
76/** When the next rest window begins after `ms` (now, if one is on); null with none. */
77export function nextRest(ms: number, cfg: Pick<Config, 'rest' | 'offsetMin'>): number | null {
78 if (isResting(ms, cfg)) return ms
79 if (!cfg.rest.length) return null
80 const minute = localMinute(ms, cfg.offsetMin)
81 const waits = cfg.rest.map(w => (w.start - minute + 24 * 60) % (24 * 60))
82 const startOfMinute = ms - (ms % MINUTE)
83 return startOfMinute + Math.min(...waits) * MINUTE
84}
85
86/** When the current rest window ends, for the band's "until 08:00". */
87export function restEnd(ms: number, cfg: Pick<Config, 'rest' | 'offsetMin'>): number | null {
88 if (!isResting(ms, cfg)) return null
89 let t = ms - (ms % MINUTE)
90 for (let i = 0; i < 24 * 60 && isResting(t, cfg); i++) t += MINUTE
91 return t
92}
93
94export function hungerOf(elapsedMs: number, ttlMs: number): Hunger {
95 const f = elapsedMs / ttlMs
96 if (f >= 1) return 'cold'
97 if (f >= 55 / 60) return 'starving'
98 if (f >= 45 / 60) return 'hungry'
99 if (f >= 30 / 60) return 'peckish'
100 return 'full'
101}
102
103/** Why the pet is not hungry now, or null when it is minding the cache. */
104export type Quiet = 'unfed' | 'side' | 'asleep' | 'break' | 'rest' | 'small'
105
106export function quietOf(f: Feeding, now: number, cfg: Config): Quiet | null {
107 if (f.lastFedAt === null) return 'unfed'
108 if (f.isSide) return 'side'
109 if (f.isAsleep) return 'asleep'
110 if (f.breakUntil !== null && now < f.breakUntil) return 'break'
111 if (isResting(now, cfg) && !isAwake(f, now)) return 'rest'
112 if (f.contextTokens !== null && f.contextTokens < cfg.minContextTokens) return 'small'
113 return null
114}
115
116function isAwake(f: Feeding, now: number): boolean {
117 return f.awakeUntil !== null && now < f.awakeUntil
118}
119
120/**
121 * The warning due now (`hungry` or `starving`), or null. None while the pet is
122 * quiet, and none when a rest or a break begins before the cache would go
123 * cold anyway: feeding it then would only move its end into the rest.
124 */
125export function warningOf(f: Feeding, now: number, cfg: Config): 'hungry' | 'starving' | null {
126 if (quietOf(f, now, cfg) || f.lastFedAt === null) return null
127 const hunger = hungerOf(now - f.lastFedAt, cfg.ttlMs)
128 if (hunger !== 'hungry' && hunger !== 'starving') return null
129 const coldAt = f.lastFedAt + cfg.ttlMs
130 const rest = nextRest(isAwake(f, now) ? f.awakeUntil! : now, cfg)
131 if (rest !== null && rest < coldAt) return null
132 if (f.breakUntil !== null && f.breakUntil > now) return null
133 return hunger
134}
135
136/**
137 * Whether coming back at `now` counts as a care mistake: the cache went cold
138 * on a working day, nothing excused it (side, sleep, a break, a rest window
139 * since the last meal), and this session has none on record for today.
140 */
141export function isCareMistake(f: Feeding, now: number, cfg: Config, mistakeDays: readonly string[]): boolean {
142 if (f.lastFedAt === null || f.isSide || f.isAsleep) return false
143 const coldAt = f.lastFedAt + cfg.ttlMs
144 if (now < coldAt) return false
145 if (f.breakUntil !== null && f.breakUntil > coldAt) return false
146 if (f.contextTokens !== null && f.contextTokens < cfg.minContextTokens) return false
147 const day = dayOf(now, cfg)
148 if (dayOf(f.lastFedAt, cfg) !== day || mistakeDays.includes(day)) return false
149 // A rest window between the meal and now: the work stopped, as it does overnight.
150 const rest = nextRest(f.lastFedAt, cfg)
151 return rest === null || rest > now
152}
153
154/** Minutes until the cache goes cold, never below 0. */
155export function minutesLeft(f: Feeding, now: number, cfg: Pick<Config, 'ttlMs'>): number {
156 return f.lastFedAt === null ? cfg.ttlMs / MINUTE : Math.max(0, (f.lastFedAt + cfg.ttlMs - now) / MINUTE)
157}
158
159/** `+0700` (what `date +%z` prints) as minutes; null when it does not parse. */
160export function parseOffset(text: string): number | null {
161 const m = text.trim().match(/^([+-])(\d{2})(\d{2})$/)
162 return m ? (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3])) : null
163}
164
165/** `90m`, `2h`, `1h30m` or `until 15:30` as the end of a break from `now`; null when it does not parse. */
166export function parseBreak(text: string, now: number, offsetMin: number): number | null {
167 const until = text.trim().match(/^until\s+(\d{1,2})(?::(\d{2}))?$/i)
168 if (until) {
169 const target = Number(until[1]) * 60 + Number(until[2] ?? 0)
170 if (target >= 24 * 60) return null
171 const wait = (target - localMinute(now, offsetMin) + 24 * 60) % (24 * 60)
172 return now - (now % MINUTE) + (wait || 24 * 60) * MINUTE
173 }
174 const span = text.trim().match(/^(?:(\d+)h)?\s*(?:(\d+)m?)?$/i)
175 if (!span || (!span[1] && !span[2])) return null
176 const minutes = Number(span[1] ?? 0) * 60 + Number(span[2] ?? 0)
177 return minutes > 0 && minutes <= DAY / MINUTE ? now + minutes * MINUTE : null
178}
179
180/** `HH:MM`, local. */
181export function clockText(ms: number, offsetMin: number): string {
182 const m = localMinute(ms, offsetMin)
183 return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`
184}
185
186/** Whether `cwd` is under one of the side-work globs (`~/work/personal/**`, `/x/*`). */
187export function isSidePath(cwd: string, globs: readonly string[], home: string): boolean {
188 return globs.some(glob => {
189 const g = glob.trim().replace(/^~(?=\/|$)/, home)
190 if (!g) return false
191 const re = new RegExp(
192 `^${g
193 .replace(/[.+^${}()|[\]\\]/g, '\\$&')
194 .replace(/\/\*\*$/, '(?:/.*)?')
195 .replace(/\*\*/g, '.*')
196 .replace(/(?<!\.)\*/g, '[^/]*')}$`,
197 )
198 return re.test(cwd.replace(/\/$/, ''))
199 })
200}
201hooks/ledger.ts 136 lines1// What each session did, day by day, kept in the plugin's store for the stats
2// and the evolution. A session writes only its own key (`ledger:<id>`), so
3// sessions running side by side never overwrite each other; readers add them up.
4
5export type Counters = {
6 /** Main-loop turns. */
7 turns: number
8 /** Turns run inside a rest window: the pet's sleep disturbed. */
9 restTurns: number
10 /** Requests that fed the cache. */
11 meals: number
12 /** After a gap of over five minutes, whether the cache was still there. */
13 cacheHits: number
14 cacheMisses: number
15 careMistakes: number
16 /** Main-loop turns that used a tool: training. */
17 toolTurns: number
18 /** Time the main loop spent on turns. */
19 activeMs: number
20 /** Research calls that answered: Read, Grep, WebFetch … */
21 research: number
22 /** Lines written by Edit / Write. */
23 linesWritten: number
24 /** Test, build, type-check and lint runs that passed. */
25 checksPassed: number
26 /** Requests that ran two or more tools at once. */
27 parallelSteps: number
28 prompts: number
29 /** The context filling past 85%. */
30 overfeeds: number
31 /** A check gone red and green again within the turn; one still red when the turn ended is lost. */
32 battlesWon: number
33 battlesLost: number
34 /** Commits made and pull requests opened, as their output proves. */
35 trophies: number
36 /** Subagents sent. */
37 summons: number
38 /** Risky moves: a turn interrupted, a push forced, a hook skipped, a hard reset, an `rm -rf` outside a temp folder. */
39 chaos: number
40 /** Turns under a minute that used a tool and answered. */
41 quickTurns: number
42 /** `/digi pet`. */
43 pats: number
44}
45
46export type Ledger = Record<string, Counters>
47
48export const ZERO: Counters = {
49 turns: 0,
50 restTurns: 0,
51 meals: 0,
52 cacheHits: 0,
53 cacheMisses: 0,
54 careMistakes: 0,
55 toolTurns: 0,
56 activeMs: 0,
57 research: 0,
58 linesWritten: 0,
59 checksPassed: 0,
60 parallelSteps: 0,
61 prompts: 0,
62 overfeeds: 0,
63 battlesWon: 0,
64 battlesLost: 0,
65 trophies: 0,
66 summons: 0,
67 chaos: 0,
68 quickTurns: 0,
69 pats: 0,
70}
71
72type Store = {
73 get: (key: string) => Promise<unknown>
74 set: (key: string, value: unknown) => Promise<void>
75 keys: () => Promise<string[]>
76}
77
78export const keyOf = (sessionId: string) => `ledger:${sessionId}`
79
80export function asLedger(kept: unknown): Ledger {
81 if (!kept || typeof kept !== 'object') return {}
82 const out: Ledger = {}
83 for (const [day, value] of Object.entries(kept as Record<string, unknown>)) {
84 const v = (value ?? {}) as Partial<Record<keyof Counters, unknown>>
85 out[day] = Object.fromEntries(Object.keys(ZERO).map(k => [k, Number(v[k as keyof Counters]) || 0])) as Counters
86 }
87 return out
88}
89
90/** Adds `delta` to the session's counters for `day`. */
91export function add(ledger: Ledger, day: string, delta: Partial<Counters>): Ledger {
92 const was = ledger[day] ?? ZERO
93 const now = { ...was }
94 for (const [k, n] of Object.entries(delta)) now[k as keyof Counters] += n ?? 0
95 return { ...ledger, [day]: now }
96}
97
98/** Every session's counters added up. */
99export async function totals(store: Store): Promise<Counters> {
100 const sum = { ...ZERO }
101 for (const day of Object.values(await byDay(store))) {
102 for (const k of Object.keys(sum) as (keyof Counters)[]) sum[k] += day[k]
103 }
104 return sum
105}
106
107/** Every session's counters added up day by day. */
108export async function byDay(store: Store): Promise<Ledger> {
109 let days: Ledger = {}
110 for (const key of await store.keys()) {
111 if (!key.startsWith('ledger:')) continue
112 for (const [day, counters] of Object.entries(asLedger(await store.get(key)))) days = add(days, day, counters)
113 }
114 return days
115}
116
117/** One writer per session: each bump reads, adds and writes after the one before it has. */
118export function writer(store: Store) {
119 let chain: Promise<unknown> = Promise.resolve()
120 return {
121 bump(sessionId: string, day: string, delta: Partial<Counters>): Promise<Ledger> {
122 const run = chain.then(async () => {
123 const next = add(asLedger(await store.get(keyOf(sessionId))), day, delta)
124 await store.set(keyOf(sessionId), next)
125 return next
126 })
127 chain = run.catch(() => undefined)
128 return run
129 },
130 async read(sessionId: string): Promise<Ledger> {
131 await chain
132 return asLedger(await store.get(keyOf(sessionId)))
133 },
134 }
135}
136hooks/render.ts 888 lines1// Everything digi-pet draws, as cell grids: the band above the prompt, the
2// pane, and the text of its alerts. Pure, shared by the mod and scripts/preview.ts.
3import { blank, canvas, cells, draw, plot, put, textWidth } from './cells'
4import type { Canvas, Grid, Style } from './cells'
5import { hasPose, pixels } from './sprite'
6import type { Pose, Sprite } from './sprite'
7import type { DigiForecast } from '../types'
8
9export type Hunger = 'full' | 'peckish' | 'hungry' | 'starving' | 'cold'
10export type Mood = 'normal' | 'asleep' | 'sick' | 'eating' | 'happy'
11/** What the model is doing while a turn runs. */
12export type Act = 'think' | 'tool' | 'say' | 'ask'
13export type Attribute = 'Vaccine' | 'Data' | 'Virus'
14
15/** What the band needs: the pet, its hunger, and the way to the next stage. */
16export type PetView = {
17 sprite: Sprite
18 hunger: Hunger
19 /** Minutes until this session's cache goes cold. */
20 minutesLeft: number
21 ttlMinutes: number
22 mood: Mood
23 attribute: Attribute | null
24 /** The session's project, as alerts name it. */
25 project: string
26 /** `day 3/5 → Champion` and how far along, 0..1; null at the last stage, absent while nothing tracks it. */
27 next?: { label: string; ratio: number } | null
28 /** A digivolution playing: the sprite it becomes and the frame since it began. */
29 evolving?: { to: Sprite; t: number }
30 /** Why the pet is not minding the cache (`💤 resting until 08:00`), shown in place of its hunger. */
31 quiet?: string
32 /** A turn is running and this is what it does; absent between turns. */
33 act?: Act
34 /** The tool the turn is running (`Bash: npm test`), for the bubble. */
35 tool?: string
36 /** Subagents running (or just finished), each a small Digimon fighting beside the pet. */
37 allies?: AllyView[]
38}
39
40/** A subagent as an ally: its Digimon, its task in a few words, and whether it is leaving (done or failed). */
41export type AllyView = { sprite: Sprite; label: string; leaving?: 'done' | 'failed' }
42
43export const STAT_NAMES = ['STA', 'INT', 'ATK', 'DEF', 'SPD', 'SYN'] as const
44export type StatName = (typeof STAT_NAMES)[number]
45
46/** What the pane adds: the stat sheet, the record and the evolution log. */
47export type PaneView = PetView & {
48 careMistakes: number
49 /** Risky moves this stage; enough of them send the pet down the Virus branch. */
50 chaos?: number
51 /** More lines under the record: today's turns, the last cache hit. */
52 facts?: string[]
53 stats?: Record<StatName, number>
54 battles?: { won: number; lost: number }
55 trophies?: number
56 /** Grams, from the context the pet carries. */
57 weight?: number
58 ageDays?: number
59 log?: { name: string; day: string }[]
60 forecast?: DigiForecast | null
61}
62
63const STAGES: Record<string, string> = {
64 egg: 'Digitama',
65 baby1: 'Baby I',
66 baby2: 'Baby II',
67 rookie: 'Rookie',
68 champion: 'Champion',
69 ultimate: 'Ultimate',
70 mega: 'Mega',
71 superMega: 'Super Ultimate',
72}
73
74export const EVOLVE_FRAMES = 14
75
76const LCD = '#dfe6cf'
77const LCD_NIGHT = '#2b3027'
78const INK = '#120c00'
79const TEXT = '#e6e6e6'
80const DIM = '#8a8a8a'
81export const HUNGER_COLOR: Record<Hunger, string> = {
82 full: '#7bd88f',
83 peckish: '#e5c07b',
84 hungry: '#ff8f40',
85 starving: '#ff5f57',
86 cold: DIM,
87}
88const ATTRIBUTE_COLOR: Record<Attribute, string> = { Vaccine: '#61afef', Data: '#98c379', Virus: '#c678dd' }
89
90// ---- icons on the LCD ----------------------------------------------------
91
92const MEAT = ['.mm...', 'mmmm..', 'mmmm..', '.mmb..', '...bb.', '....bb']
93const MEAT_BITES = [MEAT, ['......', '..mm..', '.mmm..', '.mmb..', '...bb.', '....bb'], ['......', '......', '......', '...b..', '...bb.', '....bb']]
94const MEAT_COLORS = { m: '#b5562e', b: '#8c826a' }
95const BANG = ['b', 'b', 'b', '.', 'b']
96const ZZZ = ['zzzz', '..z.', '.z..', 'zzzz']
97const SKULL = ['.sss.', 's.s.s', 'sssss', '.s.s.']
98const SPARKLE = ['..y..', '..y..', 'yyyyy', '..y..', '..y..']
99const HEART = ['h.h', 'hhh', '.h.']
100
101/** `color` taken `f` of the way toward `to`. */
102function mix(color: string, to: string, f: number): string {
103 const n = (s: string, i: number) => parseInt(s.slice(1 + i * 2, 3 + i * 2), 16)
104 return `#${[0, 1, 2].map(i => Math.round(n(color, i) + (n(to, i) - n(color, i)) * f).toString(16).padStart(2, '0')).join('')}`
105}
106
107type Paint = { flip?: boolean; tint?: { to: string; f: number }; silhouette?: string; pose?: Pose }
108
109function drawSprite(cv: Canvas, sprite: Sprite, frame: number, x: number, y: number, paint: Paint = {}): void {
110 pixels(sprite, frame, paint.pose).forEach((row, j) =>
111 row.forEach((color, i) => {
112 if (!color) return
113 const shade = paint.silhouette ?? (paint.tint ? mix(color, paint.tint.to, paint.tint.f) : color)
114 plot(cv, x + (paint.flip ? 15 - i : i), y + j, shade)
115 }),
116 )
117}
118
119function fill(cv: Canvas, color: string): void {
120 cv.px.fill(color)
121}
122
123/**
124 * The V-Pet screen: `w` pixels across, 16 down, the pet and what it is
125 * showing at frame `t`. `walk` moves it about as a V-Pet's idle does.
126 */
127function lcdCanvas(v: PetView, t: number, w: number, walk: boolean): Canvas {
128 const cv = canvas(w, 8)
129 const night = v.mood === 'asleep'
130 fill(cv, night ? LCD_NIGHT : LCD)
131 const icon = w - 7
132 const x = walk ? walkX(t, w - 16) : 0
133 const flip = walk && walkFlip(t, w - 16)
134 const frame = Math.floor(t)
135
136 if (v.evolving) {
137 const e = v.evolving.t
138 // Flash the old form against its silhouette, then the two silhouettes, then the new form.
139 if (e < 6) drawSprite(cv, v.sprite, frame, x, 0, e % 2 ? { silhouette: INK } : {})
140 else if (e < 10) drawSprite(cv, e % 2 ? v.evolving.to : v.sprite, 0, x, 0, { silhouette: INK })
141 else {
142 drawSprite(cv, v.evolving.to, frame, x, 0)
143 if (e % 2 === 0) {
144 draw(cv, icon, 1, SPARKLE, { y: '#e5a50a' })
145 draw(cv, icon + 1, 9, SPARKLE, { y: '#e5a50a' })
146 }
147 }
148 return cv
149 }
150
151 if (night) {
152 // Its own sleeping frames where the sheet has them, else the first idle one.
153 drawSprite(cv, v.sprite, hasPose(v.sprite, 'sleep') ? frame : 0, x, 0, { tint: { to: LCD_NIGHT, f: 0.6 }, pose: 'sleep' })
154 draw(cv, icon, 1 + (frame % 3), ZZZ, { z: '#9aa58c' })
155 return cv
156 }
157
158 switch (v.mood === 'normal' ? v.hunger : v.mood) {
159 case 'eating': {
160 drawSprite(cv, v.sprite, frame % 2, x, 0, { pose: 'eat' })
161 const bite = Math.min(frame, MEAT_BITES.length)
162 if (bite < MEAT_BITES.length) draw(cv, icon, 8, MEAT_BITES[bite]!, MEAT_COLORS)
163 else draw(cv, icon + 1, 2, HEART, { h: '#e0245e' })
164 break
165 }
166 case 'happy':
167 drawSprite(cv, v.sprite, frame, x, 0, { pose: 'happy' })
168 if (frame % 2 === 0) draw(cv, icon + 1, 2, HEART, { h: '#e0245e' })
169 break
170 case 'sick':
171 drawSprite(cv, v.sprite, hasPose(v.sprite, 'hurt') ? frame : 0, x + (frame % 2), 0, { pose: 'hurt' })
172 if (frame % 2 === 0) draw(cv, icon, 1, SKULL, { s: '#6b4f9e' })
173 break
174 case 'cold':
175 drawSprite(cv, v.sprite, 0, x, 0, { tint: { to: LCD, f: 0.45 } })
176 break
177 case 'hungry':
178 // Stamping about: a pixel either way, every frame.
179 drawSprite(cv, v.sprite, frame, x + (frame % 2 ? 1 : 0), 0, { flip, pose: 'angry' })
180 if (frame % 2 === 0) draw(cv, icon, 8, MEAT, MEAT_COLORS)
181 break
182 case 'starving':
183 drawSprite(cv, v.sprite, frame, x + (frame % 2 ? 1 : -1) + 1, 0, { flip, pose: 'angry' })
184 draw(cv, icon, 8, MEAT, MEAT_COLORS)
185 if (frame % 2 === 0) draw(cv, icon + 2, 1, BANG, { b: '#d0021b' })
186 break
187 default:
188 if (v.act) {
189 working(cv, v, frame, w, icon)
190 break
191 }
192 // A subagent still out between turns: its ally waits at the left of the screen, the pet walking in front of it.
193 const out = v.allies?.find(a => !a.leaving)
194 if (out) plotHalf(cv, out.sprite, frame, frame % 4 === 0 ? 'attack' : 'idle', 1, 8)
195 // An egg has one frame: it rocks instead.
196 drawSprite(cv, v.sprite, frame, x + (v.sprite.stage === 'egg' ? frame % 2 : 0), 0, { flip })
197 }
198 return cv
199}
200
201export function lcd(v: PetView, t: number, w: number, walk = false): Grid {
202 return cells(lcdCanvas(v, t, w, walk))
203}
204
205/** The device's shell: steel blue, as a Digivice. A pet that needs you colours it instead (loudBand). */
206const SHELL = '#55607a'
207const SHELL_ASLEEP = '#3e4248'
208const BUTTON = '#c9ced6'
209/** Rows a Digivice takes: the screen's eight and half a row of shell above and below it. */
210export const DEVICE_ROWS = 9
211
212/**
213 * The screen in its Digivice: a shell a pixel thick all round (half a row top
214 * and bottom), lit along the top and shaded along the bottom, its corners
215 * rounded, and the A, B and C buttons down the right side. `w` + 5 cells
216 * across, DEVICE_ROWS down: a row less than a box-drawn frame.
217 */
218export function digivice(v: PetView, t: number, w: number, walk: boolean, shell: string): Grid {
219 const screen = lcdCanvas(v, t, w, walk)
220 const W = w + 5
221 const cv = canvas(W, DEVICE_ROWS)
222 const H = cv.h
223 cv.px.fill(shell)
224 for (let x = 0; x < W; x++) {
225 plot(cv, x, 0, mix(shell, '#ffffff', 0.25))
226 plot(cv, x, H - 1, mix(shell, '#000000', 0.35))
227 }
228 for (const [x, y] of [[0, 0], [W - 1, 0], [0, H - 1], [W - 1, H - 1]] as const) cv.px[y * W + x] = undefined
229 for (let y = 0; y < screen.h; y++) for (let x = 0; x < w; x++) plot(cv, 1 + x, 1 + y, screen.px[y * w + x] ?? LCD)
230 // A, B, C: each a pixel pair, lit on top.
231 for (const by of [4, 8, 12]) {
232 plot(cv, w + 2, by, BUTTON)
233 plot(cv, w + 3, by, BUTTON)
234 plot(cv, w + 2, by + 1, mix(BUTTON, '#000000', 0.3))
235 plot(cv, w + 3, by + 1, mix(BUTTON, '#000000', 0.3))
236 }
237 return cells(cv)
238}
239
240const SHOT = ['.oo.', 'oooo', '.oo.']
241const QUESTION = ['qqq.', '...q', '.qq.', '....', '.q..']
242
243/** The pet while a turn runs, as a V-Pet's training screen: pacing as it thinks, firing at the wall as tools run. */
244function working(cv: Canvas, v: PetView, frame: number, w: number, icon: number): void {
245 const room = w - 16
246 switch (v.act) {
247 case 'tool': {
248 // Faces the wall on the left from the right edge, a shot crossing the screen each loop.
249 drawSprite(cv, v.sprite, frame, room, frame % 2 ? 0 : 1, { pose: 'attack' })
250 const shot = room - 4 - ((frame * 3) % Math.max(4, room - 2))
251 if (shot >= 0) draw(cv, shot, 6, SHOT, { o: '#e5a50a' })
252 break
253 }
254 case 'ask':
255 drawSprite(cv, v.sprite, 0, 0, 0)
256 if (frame % 2 === 0) draw(cv, icon + 1, 1, QUESTION, { q: '#d0021b' })
257 break
258 case 'say':
259 drawSprite(cv, v.sprite, frame, Math.floor(room / 2), 0)
260 break
261 default:
262 // Thinking: pacing across the screen.
263 drawSprite(cv, v.sprite, frame, walkX(frame, room), 0, { flip: walkFlip(frame, room) })
264 }
265}
266
267/** The pet's x on an idle walk across `room` pixels: there and back, a step a frame. */
268function walkX(t: number, room: number): number {
269 const p = Math.floor(t) % (room * 2)
270 return p < room ? room - p : p - room
271}
272function walkFlip(t: number, room: number): boolean {
273 return Math.floor(t) % (room * 2) >= room
274}
275
276function paste(g: Grid, part: Grid, x: number, y: number): void {
277 part.forEach((row, j) =>
278 row.forEach((cell, i) => {
279 if (g[y + j] && x + i < g[y + j]!.length) g[y + j]![x + i] = cell
280 }),
281 )
282}
283
284/** `text` broken into lines of at most `w` cells. */
285function wrap(text: string, w: number): string[] {
286 const lines: string[] = []
287 let line = ''
288 for (const word of text.split(' ')) {
289 const next = line ? `${line} ${word}` : word
290 if (textWidth(next) > w && line) {
291 lines.push(line)
292 line = word
293 } else line = next
294 }
295 if (line) lines.push(line)
296 return lines
297}
298
299// ---- words ---------------------------------------------------------------
300
301export function hearts(v: Pick<PetView, 'minutesLeft' | 'ttlMinutes' | 'hunger'>): string {
302 const n = v.hunger === 'cold' ? 0 : Math.max(1, Math.ceil((4 * v.minutesLeft) / v.ttlMinutes))
303 return '♥'.repeat(n) + '♡'.repeat(4 - n)
304}
305
306function face(v: PetView, t: number): string {
307 if (v.evolving) return v.evolving.t % 2 ? '(☆o☆)' : '(*o*)'
308 if (v.mood === 'asleep') return '(-_-)zZ'
309 if (v.mood === 'eating') return '(^ω^)'
310 if (v.mood === 'happy') return '(^‿^)'
311 if (v.mood === 'sick') return '(x_x)'
312 return { full: '(^▽^)', peckish: '(o_o)', hungry: '(>_<)', starving: Math.floor(t) % 2 ? '(T□T)' : '(>□<)', cold: '(-.-)' }[v.hunger]
313}
314
315function stageOf(sprite: Sprite): string {
316 return STAGES[sprite.stage] ?? sprite.stage
317}
318
319/** What the pet says, or null when it has nothing to say. */
320export function speech(v: PetView): string | null {
321 if (v.evolving) return v.evolving.t < 10 ? `${v.sprite.name} is digivolving…` : `${v.sprite.name} digivolved to ${v.evolving.to.name}!`
322 if (v.mood === 'asleep') return null
323 if (v.mood === 'eating') return 'Nom nom! The cache is warm again.'
324 if (v.mood === 'happy') return '♥ Thanks for the pat!'
325 if (v.mood === 'sick') return 'I got sick waiting… the cache went cold and had to be rebuilt.'
326 if (v.hunger === 'hungry') return `I'm hungry! ${v.project}'s cache goes cold in ${Math.ceil(v.minutesLeft)}m.`
327 if (v.hunger === 'starving') return `STARVING!! ${Math.ceil(v.minutesLeft)}m until ${v.project}'s cache goes cold!`
328 return null
329}
330
331/** The macOS notification for a hunger warning; null for states that send none. */
332export function notification(v: PetView): { title: string; body: string } | null {
333 if (v.hunger !== 'hungry' && v.hunger !== 'starving') return null
334 const left = Math.ceil(v.minutesLeft)
335 return v.hunger === 'hungry'
336 ? { title: `${v.sprite.name} is hungry`, body: `${v.project}: the cache goes cold in ${left}m. Send a prompt to feed it, or /digi sleep if you're done.` }
337 : { title: `${v.sprite.name} is starving!`, body: `${v.project}: ${left}m until the cache goes cold and has to be rebuilt.` }
338}
339
340export function toast(v: PetView): string | null {
341 const n = notification(v)
342 return n ? `${n.title} · ${n.body}` : null
343}
344
345/** The status-line countdown, while it matters. */
346export function statusLine(v: PetView): string | undefined {
347 if (v.hunger !== 'starving' || v.mood === 'asleep') return undefined
348 return `🍖 ${v.sprite.name}: cache cold in ${Math.ceil(v.minutesLeft)}m`
349}
350
351// ---- the band ------------------------------------------------------------
352
353/** Whether the band shows the screen: something is happening the person should see. */
354export function isLoud(v: PetView): boolean {
355 if (v.evolving || v.mood === 'eating' || v.mood === 'sick') return true
356 return v.mood !== 'asleep' && (v.hunger === 'hungry' || v.hunger === 'starving')
357}
358
359const LOUD_ROWS = DEVICE_ROWS
360
361const WORK_ROWS = 8
362/** The widest the band's block grows; it sits at the band's right edge. */
363export const BAND_WIDTH = 72
364
365/**
366 * The band above the prompt: the screen in its Digivice and a bubble when the pet has
367 * something to say, the bare screen while a turn runs, one row otherwise or
368 * when the band has not the room.
369 */
370export function band(v: PetView, t: number, width: number, maxRows = LOUD_ROWS): Grid {
371 if (isLoud(v) && maxRows >= LOUD_ROWS && width >= 60) return loudBand(v, t, width)
372 if (maxRows >= WORK_ROWS && width >= 50) return screenBand(v, t, width, maxRows >= DEVICE_ROWS)
373 return quietBand(v, t, width)
374}
375
376/** What the bubble says: what the turn is doing, or between turns the pet's own state. */
377function workSpeech(v: PetView): string {
378 if (!v.act) {
379 if (v.mood === 'asleep') return 'Zzz…'
380 if (v.mood === 'happy') return '♥ Thanks for the pat!'
381 const out = v.allies?.find(a => !a.leaving)
382 if (out) return `${out.sprite.name} is still out: ${out.label}`
383 if (v.hunger === 'cold') return 'The cache went cold. Send a prompt to warm it up.'
384 return v.hunger === 'peckish' ? 'Getting a little peckish…' : ''
385 }
386 switch (v.act) {
387 case 'tool':
388 return v.tool ?? 'working…'
389 case 'ask':
390 return 'Waiting for your OK!'
391 case 'say':
392 return 'Writing the answer…'
393 default:
394 return 'Thinking…'
395 }
396}
397
398/** The pet's screen and a few lines beside it: at work during a turn, idling between turns. */
399function screenBand(v: PetView, t: number, width: number, isDevice: boolean): Grid {
400 const g = blank(width, isDevice ? DEVICE_ROWS : WORK_ROWS)
401 const isIdle = !v.act && v.mood === 'normal' && v.hunger !== 'cold'
402 // In its Digivice when the band has the row to spare, else the bare screen.
403 if (isDevice) paste(g, digivice(v, t, 24, isIdle, v.mood === 'asleep' ? SHELL_ASLEEP : SHELL), width - 29, 0)
404 else paste(g, lcd(v, t, 24, isIdle), width - 25, 0)
405 const right = width - (isDevice ? 31 : 27)
406 const color = v.act === 'ask' ? '#ff5f57' : v.act === 'tool' ? '#e5c07b' : '#61afef'
407 putRight(g, right, 1, [[v.sprite.name, { c: TEXT, b: true }], [` · ${stageOf(v.sprite)}`, { c: DIM }]])
408 wrap(workSpeech(v), right - 3)
409 .slice(0, 2)
410 .forEach((line, i) => putRight(g, right, 3 + i, [[`${i === 0 ? '“' : ''}${line}`, { c: color }]]))
411 hungerRows(v, right).forEach((row, i) => putRight(g, right, 6 + i, row))
412 return g
413}
414
415function quietBand(v: PetView, t: number, width: number): Grid {
416 const g = blank(width, 1)
417 const hungerColor = HUNGER_COLOR[v.hunger]
418 const asleep = v.mood === 'asleep'
419 const parts: [string, Style, number][] = [
420 // [text, style, priority: lower is dropped last]
421 [`${face(v, t)} `, { c: asleep ? DIM : hungerColor }, 0],
422 [v.sprite.name, { c: TEXT, b: true }, 0],
423 [` · ${stageOf(v.sprite)}`, { c: DIM }, 2],
424 ...(v.attribute ? ([[` · ${v.attribute}`, { c: ATTRIBUTE_COLOR[v.attribute] }, 3]] as [string, Style, number][]) : []),
425 ...((v.quiet
426 ? [[` ${v.quiet}`, { c: DIM }, 0]]
427 : [
428 [` ${hearts(v)}`, { c: asleep ? DIM : hungerColor, b: v.hunger === 'starving' && Math.floor(t) % 2 === 0 }, 0],
429 [asleep ? ' asleep' : v.hunger === 'cold' ? ' cache cold' : ` ${Math.ceil(v.minutesLeft)}m`, { c: asleep ? DIM : hungerColor }, 1],
430 ]) as [string, Style, number][]),
431 ...(v.next ? ([[` ${v.next.label} ${bar(v.next.ratio, 6)}`, { c: DIM }, 2]] as [string, Style, number][]) : []),
432 ]
433 // Drop the least needed parts until the rest fits.
434 let kept = parts
435 for (const level of [3, 2, 1]) {
436 if (kept.reduce((w, [text]) => w + textWidth(text), 1) <= width) break
437 kept = kept.filter(([, , p]) => p < level)
438 }
439 let x = 0
440 for (const [text, style] of kept) {
441 put(g, x, 0, text, style)
442 x += textWidth(text)
443 }
444 // Only the cells it used, so the row can sit at the band's right edge.
445 return [g[0]!.slice(0, x)]
446}
447
448function loudBand(v: PetView, t: number, width: number): Grid {
449 const g = blank(width, LOUD_ROWS)
450 const color = v.evolving ? '#e5a50a' : v.mood === 'sick' ? '#6b4f9e' : HUNGER_COLOR[v.hunger]
451 paste(g, digivice(v, t, 24, false, color), width - 29, 0)
452 const right = width - 31
453 const shown = v.evolving && v.evolving.t >= 10 ? v.evolving.to : v.sprite
454 putRight(g, right, 1, [[shown.name, { c: TEXT, b: true }], [` · ${stageOf(shown)}${v.attribute ? ` · ${v.attribute}` : ''}`, { c: DIM }]])
455 wrap(speech(v) ?? '', right - 3)
456 .slice(0, 3)
457 .forEach((line, i) => putRight(g, right, 3 + i, [[`${i === 0 ? '“' : ''}${line}`, { c: color, b: v.hunger === 'starving' && !v.evolving }]]))
458 if (!v.evolving) {
459 hungerRows(v, right).forEach((row, i) => putRight(g, right, 7 + i, row))
460 if (v.hunger === 'hungry' || v.hunger === 'starving') putRight(g, right, 8, [['Prompt to feed · /digi sleep if done', { c: DIM, d: true }]])
461 }
462 return g
463}
464
465/** `Hunger ♥♥♡♡ 12m left`, or why the pet is not minding the cache: in `max` rows of `room` at most, so it never runs into the screen. */
466function hungerRows(v: PetView, room: number, max = 2): [string, Style][][] {
467 if (v.quiet) return quietRows(v.quiet, room, max).map(row => [[row, { c: DIM }]])
468 return [
469 [
470 [`Hunger ${hearts(v)}`, { c: HUNGER_COLOR[v.hunger] }],
471 [v.hunger === 'cold' ? ' cache cold' : ` ${Math.ceil(v.minutesLeft)}m left`, { c: DIM }],
472 ],
473 ]
474}
475
476/** A quiet line in `max` rows of `room`: broken at its ` · ` first, its leading parts dropped when the rows cannot hold the rest. */
477function quietRows(text: string, room: number, max: number): string[] {
478 const rowsOf = (parts: string[]) =>
479 parts.reduce<string[]>((rows, part) => {
480 const last = rows[rows.length - 1]
481 return last !== undefined && textWidth(`${last} · ${part}`) <= room ? [...rows.slice(0, -1), `${last} · ${part}`] : [...rows, ...wrap(part, room)]
482 }, [])
483 let parts = text.split(' · ')
484 while (parts.length > 1 && rowsOf(parts).length > max) parts = parts.slice(1)
485 return rowsOf(parts).slice(0, max)
486}
487
488/** Runs written so the last ends at column `right`: the text leans on the pet's screen. */
489function putRight(g: Grid, right: number, y: number, parts: [string, Style][]): void {
490 let x = Math.max(0, right - parts.reduce((w, [text]) => w + textWidth(text), 0))
491 for (const [text, style] of parts) {
492 put(g, x, y, text, style)
493 x += textWidth(text)
494 }
495}
496
497function bar(ratio: number, w: number): string {
498 const n = Math.round(Math.max(0, Math.min(1, ratio)) * w)
499 return '▓'.repeat(n) + '░'.repeat(w - n)
500}
501
502// ---- the pane ------------------------------------------------------------
503
504/** The `/digi` pane: the walking pet on its screen, the stat sheet, the record and the log. */
505export function pane(v: PaneView, t: number, width: number): Grid {
506 const screen = digivice(v, t, 32, !v.evolving && v.mood === 'normal' && v.hunger !== 'cold', v.mood === 'asleep' ? SHELL_ASLEEP : SHELL)
507 const info = paneInfo(v)
508 const side = width >= 37 + 3 + 34
509 const statRows = v.stats ? statLines(v.stats, width - 2) : []
510 const logLines = v.log?.length ? wrap(`Evolution: ${v.log.map(e => `${e.name} (${e.day})`).join(' → ')}`, width - 2) : []
511 const factLines = (v.facts ?? []).flatMap(fact => wrap(fact, width - 2))
512 const forecastRows = v.forecast ? forecastLines(v.forecast, width - 2) : []
513 const h =
514 (side ? Math.max(screen.length, info.length + 1) : screen.length + 1 + info.length) + 1 + statRows.length + (forecastRows.length ? 1 + forecastRows.length : 0) + 1 + logLines.length + factLines.length
515 const g = blank(width, h)
516 paste(g, screen, 1, 0)
517 const infoX = side ? 40 : 1
518 const infoY = side ? 1 : screen.length + 1
519 info.forEach(([text, style], i) => put(g, infoX, infoY + i, text, style))
520 let y = (side ? Math.max(screen.length, info.length + 1) : infoY + info.length) + 1
521 // The stat bars, then a row apart the branches.
522 for (const row of [...statRows, ...(forecastRows.length ? [[], ...forecastRows] : [])]) {
523 let x = 1
524 for (const [text, style] of row) {
525 put(g, x, y, text, style)
526 x += textWidth(text)
527 }
528 y++
529 }
530 factLines.forEach(line => put(g, 1, y++, line, { c: DIM }))
531 y++
532 logLines.forEach(line => put(g, 1, y++, line, { c: DIM }))
533 return g
534}
535
536function paneInfo(v: PaneView): [string, Style][] {
537 const lines: [string, Style][] = [
538 [v.sprite.name, { c: TEXT, b: true }],
539 [`${stageOf(v.sprite)}${v.attribute ? ` · ${v.attribute}` : ''}${v.ageDays === undefined ? '' : ` · day ${v.ageDays}`}${v.weight === undefined ? '' : ` · ${v.weight}g`}`, { c: v.attribute ? ATTRIBUTE_COLOR[v.attribute] : DIM }],
540 ['', {}],
541 v.quiet
542 ? [`Hunger ${v.quiet}`, { c: DIM }]
543 : [`Hunger ${hearts(v)} ${v.mood === 'asleep' ? 'asleep' : v.hunger === 'cold' ? 'cache cold' : `${Math.ceil(v.minutesLeft)}m left`}`, { c: HUNGER_COLOR[v.hunger] }],
544 [`Care mistakes ${v.careMistakes}`, { c: v.careMistakes ? '#e5c07b' : DIM }],
545 ]
546 if (v.chaos !== undefined) lines.push([`Chaos ${v.chaos} this stage`, { c: v.chaos ? '#e5c07b' : DIM }])
547 if (v.battles) {
548 const fought = v.battles.won + v.battles.lost
549 lines.push([`Battles ${v.battles.won}W ${v.battles.lost}L${fought ? ` (${Math.round((100 * v.battles.won) / fought)}%)` : ''}`, { c: DIM }])
550 }
551 if (v.trophies !== undefined) lines.push([`Trophies ${v.trophies}`, { c: DIM }])
552 if (v.next !== undefined) lines.push([v.next ? `Next ${v.next.label} ${bar(v.next.ratio, 8)}` : 'Fully digivolved', { c: DIM }])
553 return lines
554}
555
556const NEED_LABEL: Record<string, string> = {
557 careMistakes: 'care mistakes',
558 training: 'training',
559 overfeed: 'overfeed',
560 sleepDisturbances: 'sleep disturbances',
561 battles: 'battles',
562 winRatio: 'win',
563}
564const MET = '#7bd88f'
565
566/** A requirement as the pane says it: `training 4/48`, `care mistakes 0 (≤3)`, `training 4 (8–31)`. */
567function needText(n: DigiForecast['branches'][number]['needs'][number]): string {
568 const unit = n.key === 'winRatio' ? '%' : ''
569 const range = n.max === null ? `/${n.min}${unit}` : n.min === 0 ? ` (≤${n.max}${unit})` : ` (${n.min}–${n.max}${unit})`
570 return `${NEED_LABEL[n.key] ?? n.key} ${n.have}${unit}${range}${n.isMet ? ' ✓' : ''}`
571}
572
573/** The branches under the stats: one per way on, `▸` on the one the counts lead to now, the Virus branch's Chaos last. */
574function forecastLines(f: DigiForecast, width: number): [string, Style][][] {
575 const nameStyle = (attribute: string): Style => ({ c: ATTRIBUTE_COLOR[attribute as Attribute] ?? TEXT, b: true })
576 const rows: [string, Style][][] = [[['Branches', { c: TEXT, b: true }], [' ▸ where it goes now', { c: DIM }]]]
577 const line = (lead: [string, Style][], units: [string, Style][]) => {
578 let row = [...lead]
579 let x = lead.reduce((w, [text]) => w + textWidth(text), 0)
580 units.forEach(([text, style], i) => {
581 const sep = i ? ' · ' : ' '
582 if (i && x + textWidth(sep + text) > width) {
583 rows.push(row)
584 row = [[' ', {}]]
585 x = 4
586 } else {
587 row.push([sep, { c: DIM }])
588 x += textWidth(sep)
589 }
590 row.push([text, style])
591 x += textWidth(text)
592 })
593 rows.push(row)
594 }
595 for (const b of f.branches) {
596 const units: [string, Style][] = b.needs.map(n => [needText(n), { c: n.isMet ? MET : DIM }])
597 if (b.isCatchAll) units.push(['if none fits', { c: DIM }])
598 line([[b.isNow ? '▸ ' : ' ', { c: TEXT, b: true }], [b.name, nameStyle(b.attribute)]], units)
599 }
600 if (f.virus) {
601 line(
602 [[' ', {}], [f.virus.name, nameStyle('Virus')]],
603 [
604 [`chaos ${f.virus.have}/${f.virus.need}`, { c: f.virus.have ? '#e5c07b' : DIM }],
605 ['overrides the rest', { c: DIM }],
606 ],
607 )
608 }
609 return rows
610}
611
612const STAT_COLOR: Record<StatName, string> = {
613 STA: '#e06c75',
614 INT: '#61afef',
615 ATK: '#ff8f40',
616 DEF: '#98c379',
617 SPD: '#e5c07b',
618 SYN: '#c678dd',
619}
620
621/** The empty part of a stat bar: one grey for every stat, so it never reads as that stat's colour. */
622const STAT_TRACK = '#3e4248'
623
624/** The six stats as bars, two to a row when there is room. */
625function statLines(stats: Record<StatName, number>, width: number): [string, Style][][] {
626 const perRow = width >= 70 ? 2 : 1
627 const barW = Math.max(6, Math.min(20, Math.floor(width / perRow) - 12))
628 // Against the strongest stat, so the pet's shape shows from its first day; the number says how big.
629 const top = Math.max(...STAT_NAMES.map(s => Math.round(stats[s])))
630 const one = (name: StatName): [string, Style][] => {
631 const n = Math.round(stats[name])
632 const filled = top ? Math.round((n / top) * barW) : 0
633 return [
634 [`${name} `, { c: TEXT, b: true }],
635 ['█'.repeat(filled), { c: STAT_COLOR[name] }],
636 ['░'.repeat(barW - filled), { c: STAT_TRACK }],
637 [` ${String(n).padStart(3)} `, { c: DIM }],
638 ]
639 }
640 const rows: [string, Style][][] = []
641 for (let i = 0; i < STAT_NAMES.length; i += perRow) rows.push(STAT_NAMES.slice(i, i + perRow).flatMap(one))
642 return rows
643}
644
645// ---- the mini screen ----------------------------------------------------
646
647/**
648 * The pet at half size for a band too short for the full screen: each 2×2
649 * block of the sprite becomes one pixel of its commonest colour (clear when
650 * mostly clear), drawn in half-blocks on the LCD, 10 cells across and 4 rows.
651 */
652/** A 16-pixel frame at 8: each 2×2 block its commonest colour, clear where under two of its pixels are set. */
653function halfSize(px: (string | undefined)[][]): (string | undefined)[][] {
654 return Array.from({ length: 8 }, (_, y) =>
655 Array.from({ length: 8 }, (_, x) => {
656 const block = [px[2 * y]?.[2 * x], px[2 * y]?.[2 * x + 1], px[2 * y + 1]?.[2 * x], px[2 * y + 1]?.[2 * x + 1]].filter((c): c is string => !!c)
657 if (block.length < 2) return undefined
658 const counts = new Map<string, number>()
659 for (const c of block) counts.set(c, (counts.get(c) ?? 0) + 1)
660 return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]![0]
661 }),
662 )
663}
664
665/** A sprite at half size, its top left at (`x`, `y`), each colour through `paint`. */
666function plotHalf(cv: Canvas, sprite: Sprite, frame: number, pose: Pose, x: number, y: number, paint: (c: string) => string = c => c): void {
667 halfSize(pixels(sprite, frame, pose)).forEach((row, j) => row.forEach((c, i) => c && plot(cv, x + i, y + j, paint(c))))
668}
669
670export function miniLcd(v: PetView, t: number): Grid {
671 const night = v.mood === 'asleep'
672 const bg = night ? LCD_NIGHT : LCD
673 const small = halfSize(pixels(v.sprite, night ? 0 : Math.floor(t))).map(row => row.map(c => (c && night ? mix(c, LCD_NIGHT, 0.6) : c)))
674 const g = blank(10, 4)
675 for (let r = 0; r < 4; r++) {
676 g[r]![0] = { ch: ' ', bg }
677 g[r]![9] = { ch: ' ', bg }
678 for (let x = 0; x < 8; x++) {
679 const top = small[2 * r]![x]
680 const bottom = small[2 * r + 1]![x]
681 g[r]![x + 1] = top || bottom ? { ch: '▀', c: top ?? bg, bg: bottom ?? bg } : { ch: ' ', bg }
682 }
683 }
684 return g
685}
686
687const MINI_ROWS = 4
688
689/** The band in four rows: the mini screen at the right, name, words and hunger beside it. */
690export function miniBand(v: PetView, t: number, width: number): Grid {
691 const g = blank(width, MINI_ROWS)
692 paste(g, miniLcd(v, t), width - 10, 0)
693 const right = width - 12
694 putRight(g, right, 0, [[v.sprite.name, { c: TEXT, b: true }], [` · ${stageOf(v.sprite)}`, { c: DIM }]])
695 const say = isLoud(v) ? (speech(v) ?? '') : workSpeech(v)
696 const color = isLoud(v) ? HUNGER_COLOR[v.hunger] : v.act === 'ask' ? '#ff5f57' : v.act === 'tool' ? '#e5c07b' : '#61afef'
697 if (say) putRight(g, right, 1, [[`“${say.length > right - 2 ? `${say.slice(0, right - 3)}…` : say}`, { c: color }]])
698 putRight(g, right, 3, hungerRows(v, right, 1)[0]!)
699 return g
700}
701
702export const MINI_BAND_ROWS = MINI_ROWS
703
704/** The tool running, on a row of its own under the pet, ending two columns in from its right edge: `⏵ Bash npm test 12s`. Its command is cut first; no time when `secs` is absent. */
705export function toolRow(run: { tool: string; text: string }, width: number, secs?: number): Grid {
706 const g = blank(width, 1)
707 const time = secs === undefined ? '' : ` ${secs}s`
708 const room = width - 2 - textWidth(`⏵ ${run.tool} `) - textWidth(time)
709 const chars = [...run.text]
710 const text = textWidth(run.text) <= room ? run.text : room > 1 ? `${chars.slice(0, room - 1).join('')}…` : ''
711 putRight(g, width - 2, 0, [
712 ['⏵ ', { c: '#e5c07b' }],
713 [run.tool, { c: '#e5c07b', b: true }],
714 [text ? ` ${text}` : '', { c: TEXT }],
715 [time, { c: DIM }],
716 ])
717 return g
718}
719
720// ---- the arena -------------------------------------------------------------
721
722const ENEMIES: { art: string[]; colors: Record<string, string>; flies: boolean }[] = [
723 {
724 // A virus blob.
725 art: ['..v..v..', '.vvvvvv.', 'vvwvvwvv', 'vvkvvkvv', 'vvvvvvvv', '.vvmmvv.', '.v.vv.v.', 'v..vv..v'],
726 colors: { v: '#9b59b6', w: '#ffffff', k: '#1d2418', m: '#4a235a' },
727 flies: false,
728 },
729 {
730 // A bug.
731 art: ['.a....a.', '..a..a..', '.gggggg.', 'ggwggwgg', 'ggkggkgg', 'gggggggg', '.g.gg.g.', 'g..gg..g'],
732 colors: { a: '#8c826a', g: '#c0392b', w: '#ffffff', k: '#1d2418' },
733 flies: false,
734 },
735 {
736 // A ghost, up in the air.
737 art: ['..ssss..', '.ssssss.', 'sswsswss', 'ssksskss', 'ssssssss', 'ssssssss', 's.ss.ss.', '........'],
738 colors: { s: '#a9b4c2', w: '#ffffff', k: '#1d2418' },
739 flies: true,
740 },
741]
742const BURST = [
743 ['...y...', '..yry..', '.yrrry.', 'yrrwrry', '.yrrry.', '..yry..', '...y...'],
744 ['y..y..y', '.y.r.y.', '..rwr..', 'yrw.wry', '..rwr..', '.y.r.y.', 'y..y..y'],
745 ['y.....y', '...y...', '.......', 'y.....y', '.......', '...y...', 'y.....y'],
746]
747const BURST_COLORS = { y: '#f1c40f', r: '#e67e22', w: '#ffffff' }
748const BOLT = ['.oo.', 'oooo', '.oo.']
749
750/**
751 * The fight across the band while a turn runs: monsters come in from the left
752 * and the pet, just off the right edge, shoots them down. A function of the
753 * frame alone: `t` steps the fight, `w` is its width in cells.
754 */
755/** The real fight behind the skirmish: checks red right now, battles won this stage, and a win just now. */
756export type Fight = { open: number; won: number; flash: boolean }
757
758/** A red check, as the field shows it: the virus blob at twice the size, in red. */
759const BOSS = {
760 art: ENEMIES[0]!.art.flatMap(row => {
761 const wide = [...row].map(ch => ch + ch).join('')
762 return [wide, wide]
763 }),
764 colors: { v: '#d0021b', w: '#ffffff', k: '#1d2418', m: '#5c0010' },
765}
766const WIN_FRAMES = 12
767/** A minion's fight, in frames of the arena (120 ms): it runs in STEP pixels a frame, the pet fires, the fireball lands. */
768const FIRE_AT = 2
769const FLIGHT = 5
770const STEP = 2
771
772export function arena(t: number, w: number, act: Act, fight: Fight = { open: 0, won: 0, flash: false }, allies: readonly AllyView[] = []): Grid {
773 const cv = canvas(w, 8)
774 for (let x = 0; x < w; x += 2) plot(cv, x, 15, '#3a4034')
775 const bossX = Math.max(0, Math.floor(w * 0.25) - 8)
776 if (fight.flash && t < WIN_FRAMES) {
777 // A check gone green again: the boss blows up.
778 for (let i = 0; i < 3; i++) draw(cv, bossX + 2 + i * 4, 2 + ((i * 3) % 6), BURST[Math.min(BURST.length - 1, Math.floor(t / 2) + i) % BURST.length]!, BURST_COLORS)
779 const labels = drawAllies(cv, t, w, allies)
780 const g = withLabels(cells(cv), labels)
781 put(g, Math.min(w - 5, bossX + 18), 2, 'WIN!', { c: '#e5a50a', b: true })
782 return withScore(g, w, fight)
783 }
784 if (fight.open > 0 && act !== 'say') {
785 // A check is red: the boss stands its ground and takes the shots.
786 const frame = act === 'ask' ? 0 : t
787 draw(cv, bossX + (frame % 4 === 0 ? 1 : 0), 0, BOSS.art, BOSS.colors)
788 // A fireball every five frames, across the field in three.
789 const p = frame % 5
790 const from = w - 4
791 const to = bossX + 16
792 if (p < 3) draw(cv, from - Math.floor(((from - to) * p) / 3), 6, BOLT, { o: '#e5a50a' })
793 else if (p === 3) draw(cv, to - 2, 4, BURST[0]!, BURST_COLORS)
794 const labels = drawAllies(cv, t, w, allies)
795 const g = withLabels(cells(cv), labels)
796 const red = w >= 40 ? `${fight.open} check${fight.open > 1 ? 's' : ''} red` : `${fight.open} red`
797 // Under the score, right of the boss.
798 put(g, w - textWidth(red) - 1, 1, red, { c: '#d0021b', b: true })
799 return withScore(g, w, fight)
800 }
801 if (act === 'say') {
802 // The answer is being written: the field is clear, sparkles drift by.
803 for (let i = 0; i < 4; i++) {
804 const x = Math.floor((t * 2 + i * (w / 4)) % w)
805 draw(cv, x, 2 + ((i * 5 + t) % 9), SPARKLE, { y: i % 2 ? '#e5a50a' : '#61afef' })
806 }
807 const labels = drawAllies(cv, t, w, allies)
808 return withLabels(cells(cv), labels)
809 }
810 const frame = act === 'ask' ? 0 : t
811 const lanes = act === 'tool' ? 2 : 1
812 // A monster walks in; the pet fires once it is on the field, and the fireball crosses in FLIGHT frames, whatever the width.
813 const hit = FIRE_AT + FLIGHT
814 const cycle = hit + BURST.length + 2
815 for (let lane = 0; lane < lanes; lane++) {
816 const at = frame + Math.floor((lane * cycle) / 2)
817 const round = Math.floor(at / cycle)
818 const p = at % cycle
819 const enemy = ENEMIES[(round + lane) % ENEMIES.length]!
820 const y = enemy.flies || lane === 1 ? 0 : 7
821 const ex = -8 + STEP * p
822 if (p < hit) {
823 draw(cv, ex, y + (p % 2 && !enemy.flies ? -1 : 0), enemy.art, enemy.colors)
824 if (p >= FIRE_AT) {
825 const from = w - 4
826 const to = -8 + STEP * hit + 8
827 draw(cv, from - Math.floor(((from - to) * (p - FIRE_AT)) / FLIGHT), y + 3, BOLT, { o: '#e5a50a' })
828 }
829 } else if (p < hit + BURST.length) {
830 draw(cv, -8 + STEP * hit, y, BURST[p - hit]!, BURST_COLORS)
831 }
832 }
833 const labels = drawAllies(cv, t, w, allies)
834 return withScore(withLabels(cells(cv), labels), w, fight)
835}
836
837const PELLET = ['bb', 'bb']
838const GREY = '#8a8a8a'
839/** One ally's place on the field: 12 columns each, from just in front of the pet. */
840const ALLY_STEP = 12
841const MAX_ALLIES = 3
842
843/**
844 * The allies on the field's floor in front of the pet, each with its task above it. Running, one bobs
845 * and shoots a pellet every six frames; done, it cheers in a shower of sparkles; failed, it lies grey.
846 * As many as fit, up to three, and `+n` for the rest.
847 */
848function drawAllies(cv: Canvas, t: number, w: number, allies: readonly AllyView[]): [number, string, string][] {
849 const fits = Math.min(MAX_ALLIES, Math.max(0, Math.floor((w - 20) / ALLY_STEP)))
850 const shown = allies.slice(0, fits)
851 const labels: [number, string, string][] = []
852 shown.forEach((a, i) => {
853 const x = w - 13 - i * ALLY_STEP
854 if (a.leaving === 'failed') {
855 plotHalf(cv, a.sprite, 0, 'hurt', x, 9, c => mix(mix(c, '#808080', 0.8), '#000000', 0.2))
856 labels.push([x - 1, '✗ failed', '#c0392b'])
857 return
858 }
859 if (a.leaving === 'done') {
860 plotHalf(cv, a.sprite, 0, 'happy', x, 8 - (t % 4 < 2 ? 1 : 0))
861 draw(cv, x - 4 + (t % 3), 4 + (t % 2) * 4, SPARKLE, { y: t % 2 ? '#f1c40f' : '#61afef' })
862 draw(cv, x + 8 - (t % 3), 6 - (t % 2) * 3, SPARKLE, { y: t % 2 ? '#61afef' : '#f1c40f' })
863 labels.push([x - 1, '✓ done', '#98c379'])
864 return
865 }
866 const p = (t + i * 2) % 6
867 plotHalf(cv, a.sprite, Math.floor(t / 3), 'idle', x, 8 + (p === 3 ? -1 : 0))
868 if (p >= 1) draw(cv, x - 3 - (p - 1) * 6, 11, PELLET, { b: '#61afef' })
869 labels.push([x, [...a.label].slice(0, ALLY_STEP - 2).join(''), GREY])
870 })
871 const rest = allies.length - shown.length
872 if (rest > 0 && shown.length) labels.push([w - 13 - shown.length * ALLY_STEP + 6, `+${rest}`, GREY])
873 return labels
874}
875
876/** The allies' words, on the row above them. */
877function withLabels(g: Grid, labels: readonly [number, string, string][]): Grid {
878 for (const [x, text, c] of labels) put(g, Math.max(0, x), 3, text, { c })
879 return g
880}
881
882/** The stage's real wins, top right. */
883function withScore(g: Grid, w: number, fight: Fight): Grid {
884 const score = `WINS ${fight.won}`
885 if (w > 16) put(g, w - textWidth(score) - 1, 0, score, { c: DIM })
886 return g
887}
888hooks/run.tsx 32 lines1// The tool running, on its row under the pet: what it runs and, once a second,
2// how long it has been at it. Keyed by the call, so the time starts with it;
3// with reduced motion, no time.
4import type { ClientModule } from 'claude-code'
5
6import { segments } from './cells'
7import { toolRow } from './render'
8
9export type RunProps = { width: number; tool: string; text: string; isStill: boolean }
10
11const Run: ClientModule<RunProps, number> = (props, surface) => {
12 const { Text } = surface.elements
13 if (surface.state === undefined) {
14 let secs = 0
15 if (!props.isStill) surface.every(1000, () => surface.setState(++secs))
16 surface.setState(0)
17 }
18 const width = Math.max(20, Math.min(surface.columns || props.width, props.width))
19 const [row] = toolRow(props, width, props.isStill ? undefined : (surface.state ?? 0))
20 return (
21 <Text wrap="truncate">
22 {segments(row!).map(seg => (
23 <Text color={seg.c} backgroundColor={seg.bg} bold={seg.b} dimColor={seg.d}>
24 {seg.text}
25 </Text>
26 ))}
27 </Text>
28 )
29}
30
31export default Run
32hooks/screen.tsx 42 lines1// The pet's screen, animated on the drawing thread: the loud band (a hungry pet,
2// a meal, a digivolution) and the pane. A frame every half second, as a V-Pet's;
3// with reduced motion, the first frame only.
4import type { ClientModule } from 'claude-code'
5
6import { segments } from './cells'
7import { band, pane } from './render'
8import { paneView, petView } from './view'
9import type { PaneJson, ViewJson } from './view'
10
11export const FRAME_MS = 500
12
13export type ScreenProps = ({ kind: 'band'; width: number; maxRows: number; view: ViewJson } | { kind: 'pane'; width: number; view: PaneJson }) & { isStill: boolean }
14
15const Screen: ClientModule<ScreenProps, number> = (props, surface) => {
16 const { Box, Text } = surface.elements
17 if (surface.state === undefined) {
18 let tick = 0
19 if (!props.isStill) surface.every(FRAME_MS, () => surface.setState(++tick))
20 surface.setState(0)
21 }
22 const t = surface.state ?? 0
23 const width = props.kind === 'band' ? props.width : Math.max(40, Math.min(surface.columns || props.width, props.width))
24 const rows = props.kind === 'pane' ? pane(paneView(props.view, t), t, width) : band(petView(props.view, t), t, width, props.maxRows)
25
26 return (
27 <Box flexDirection="column">
28 {rows.map(row => (
29 <Text wrap="truncate">
30 {segments(row).map(seg => (
31 <Text color={seg.c} backgroundColor={seg.bg} bold={seg.b} dimColor={seg.d}>
32 {seg.text}
33 </Text>
34 ))}
35 </Text>
36 ))}
37 </Box>
38 )
39}
40
41export default Screen
42hooks/signals.ts 97 lines1// What a tool call tells the pet, pure: research, code written, and the checks
2// (tests, builds, type checks, linters) whose red-to-green runs are its battles.
3
4const RESEARCH = new Set(['Read', 'Grep', 'Glob', 'WebSearch', 'WebFetch', 'LSP'])
5const LINES_PER_CALL = 200
6
7/** A test, build, type-check or lint command, named by its runner (`jest`, `npm test`, `tsc` …). */
8const CHECK =
9 /\b(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|build|lint|typecheck|check)|pytest|jest|vitest|tsc|eslint|ruff|mypy|go test|cargo (?:test|check|build|clippy)|claude plugin test|make test|xcodebuild)\b/
10
11/**
12 * A run that failed, read from its output: a check's output is mostly piped
13 * (`… 2>&1 | tail`), so the exit code is the pipe's and no error is reported.
14 */
15const FAILED =
16 /\b[1-9]\d* (?:failed|failing|fail)\b|^\s*FAIL\b|\(fail\)|error TS\d+|Found [1-9]\d* errors?|\([1-9]\d* errors?,|npm ERR!|error\[E\d+\]|\bFAILED\b|BUILD FAILED|Command failed|exited with code [1-9]/m
17
18/** `git`, with any options before the subcommand (`git -C <dir> commit`). */
19const GIT = String.raw`\bgit(?:\s+(?:-[Cc]\s+\S+|--[\w-]+(?:=\S+)?))*\s+`
20const COMMIT = new RegExp(String.raw`${GIT}commit\b`)
21/** A commit's output (`[main 1a2b3c4] …`): git prints it only for a commit made, so it is proof even when a later step of the call fails. */
22const COMMITTED = /^\[[^\]\n]+ [0-9a-f]{7,40}\]/gm
23/** A quiet commit (`-q`, `--quiet`, `-aqm`), which prints nothing when it is made. */
24const QUIET_COMMIT = new RegExp(String.raw`${GIT}commit\b[^\n;&|]*\s(?:-[a-zA-Z]*q[a-zA-Z]*|--quiet)\b`, 'g')
25/** What git says when a commit found nothing to make. */
26const NOTHING = /\b(?:nothing to commit|nothing added to commit|no changes added to commit)\b/
27/**
28 * What opens a pull or merge request: the forges' CLIs (GitHub, GitLab, Gitea,
29 * Forgejo, Azure DevOps), a push to GitLab with `merge_request.create`, and a
30 * push to Gerrit's `refs/for/`.
31 */
32const PR_CREATE = new RegExp(
33 String.raw`\b(?:gh\s+pr\s+(?:create|new)|glab\s+mr\s+(?:create|new)|tea\s+(?:pr|pulls?)\s+create|fj\s+pr\s+create|az\s+repos\s+pr\s+create)\b|${GIT}push\b[^\n;&|]*(?:merge_request\.create|refs\/for\/)`,
34)
35/**
36 * The new request's URL as the forge prints it: `/pull/7` (GitHub), `/-/merge_requests/12`
37 * (GitLab), `/pulls/3` (Gitea, Forgejo), `/pullRequests/7` (Azure DevOps), a Gerrit change marked [NEW].
38 */
39const PR_OPENED = /https?:\/\/\S+?\/(?:pull|pulls|-\/merge_requests|pullrequests?)\/\d+\b|https?:\/\/\S+\/c\/\S+\/\+\/\d+\b.*\[NEW\]/i
40/** gh and glab print the open request's URL when they refuse to open another. */
41const PR_EXISTS = /already exists/i
42/** An MCP tool that opens a pull or merge request (`mcp__gitlab__create_merge_request`, `…__repo_create_pull_request`). */
43const PR_TOOL = /^mcp__.+__(?:\w+_)?create_(?:pull|merge)_request$/
44/** Risky commands; `--force-with-lease` is the careful push and is not one. */
45const RISKY = /\bgit\s+push\b[^\n;&|]*(?:--force(?![-\w])|\s-f\b)|--no-verify\b|\bgit\s+reset\s+--hard\b/
46/** `rm -rf` of anything but a temp folder or build output; a `$VAR` path names no folder we can judge. */
47const RM_RF = /\brm\s+-(?:[a-zA-Z]*r[a-zA-Z]*f|[a-zA-Z]*f[a-zA-Z]*r)[a-zA-Z]*((?:\s+[^\s;&|]+)+)/g
48const DISPOSABLE = /^["']?(?:\/tmp\/|\/private\/tmp\/|\/var\/folders\/|\$TMPDIR|\$\{?TMPDIR|(?:\.\/)?(?:node_modules|dist|build|out|coverage|\.next|\.cache|tmp)(?:\/|$|["']))/
49
50export type ToolSignal =
51 | { kind: 'research' }
52 | { kind: 'write'; lines: number }
53 | { kind: 'check'; runner: string; isPass: boolean }
54 | { kind: 'summon' }
55 | { kind: 'other' }
56
57/**
58 * The trophies a call won: each commit made and a pull or merge request opened, as the output proves, on any forge.
59 * A quiet commit has no output to prove it: a call that answered without an error is taken as one.
60 */
61export function trophiesOf(tool: string, input: Record<string, unknown>, isError: boolean, text: string): number {
62 if (PR_TOOL.test(tool)) return isError ? 0 : 1
63 if (tool !== 'Bash') return 0
64 const command = String(input.command ?? '')
65 const proven = COMMIT.test(command) ? (text.match(COMMITTED)?.length ?? 0) : 0
66 const quiet = !isError && !NOTHING.test(text) ? (command.match(QUIET_COMMIT)?.length ?? 0) : 0
67 const opened = !isError && PR_CREATE.test(command) && PR_OPENED.test(text) && !PR_EXISTS.test(text)
68 return proven + quiet + Number(opened)
69}
70
71/** Whether a shell command is a risky move (Chaos). */
72export function isRisky(command: string): boolean {
73 if (RISKY.test(command)) return true
74 for (const m of command.matchAll(RM_RF)) {
75 const targets = m[1]!.trim().split(/\s+/).filter(a => !a.startsWith('-') && !/^["']?\$/.test(a))
76 if (targets.some(a => !DISPOSABLE.test(a))) return true
77 }
78 return false
79}
80
81/** The call's input, and what came back: `isError` as the engine set it, `text` as the model read it. */
82export function signalOf(tool: string, input: Record<string, unknown>, isError: boolean, text: string): ToolSignal {
83 if (RESEARCH.has(tool)) return isError ? { kind: 'other' } : { kind: 'research' }
84 if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') {
85 if (isError) return { kind: 'other' }
86 const body = String(input.new_string ?? input.content ?? input.new_source ?? '')
87 return { kind: 'write', lines: Math.min(LINES_PER_CALL, body ? body.split('\n').length : 0) }
88 }
89 if ((tool === 'Agent' || tool === 'Task') && !isError) return { kind: 'summon' }
90 if (tool === 'Bash' && !input.run_in_background) {
91 const command = String(input.command ?? '')
92 const runner = command.match(CHECK)?.[0]
93 if (runner) return { kind: 'check', runner: runner.replace(/\s+run\s+/, ' '), isPass: !isError && !FAILED.test(text) }
94 }
95 return { kind: 'other' }
96}
97hooks/species.gen.ts 102 lines1// Generated by scripts/build-species.ts from data/dmc.json: do not edit.
2// Digital Monster Color evolution rules, via humulos.com (Digitama Hatchery).
3import type { SpeciesRules } from './growth'
4
5export const SPECIES: Record<string, SpeciesRules> = {
6 "egg1": {"name":"Ver.1 Digitama","stage":"egg","attribute":"Free","power":null,"rules":[{"to":"bota"}]},
7 "egg2": {"name":"Ver.2 Digitama","stage":"egg","attribute":"Free","power":null,"rules":[{"to":"puni"}]},
8 "egg3": {"name":"Ver.3 Digitama","stage":"egg","attribute":"Free","power":null,"rules":[{"to":"poyo"}]},
9 "egg4": {"name":"Ver.4 Digitama","stage":"egg","attribute":"Free","power":null,"rules":[{"to":"yura"}]},
10 "egg5": {"name":"Ver.5 Digitama","stage":"egg","attribute":"Free","power":null,"rules":[{"to":"zuru"}]},
11 "bota": {"name":"Botamon","stage":"baby1","attribute":"Free","power":null,"rules":[{"to":"koro"}]},
12 "koro": {"name":"Koromon","stage":"baby2","attribute":"Free","power":null,"rules":[{"to":"agu","careMistakes":{"min":0,"max":3}},{"to":"beta","careMistakes":{"min":4,"max":null}}]},
13 "agu": {"name":"Agumon","stage":"rookie","attribute":"Vaccine","power":30,"rules":[{"to":"grey","careMistakes":{"min":0,"max":3},"training":{"min":32,"max":null}},{"to":"tyrano","careMistakes":{"min":4,"max":null},"training":{"min":5,"max":15},"overfeed":{"min":3,"max":null},"sleepDisturbances":{"min":4,"max":5}},{"to":"devi","careMistakes":{"min":0,"max":3},"training":{"min":0,"max":31}},{"to":"mera","careMistakes":{"min":4,"max":null},"training":{"min":16,"max":null},"overfeed":{"min":3,"max":null},"sleepDisturbances":{"min":6,"max":null}},{"to":"nume","careMistakes":{"min":4,"max":null},"training":{"min":0,"max":4}},{"to":"nume","careMistakes":{"min":4,"max":null},"overfeed":{"min":0,"max":2}},{"to":"nume","careMistakes":{"min":4,"max":null},"training":{"min":5,"max":15},"overfeed":{"min":3,"max":null},"sleepDisturbances":{"min":0,"max":3}},{"to":"nume","careMistakes":{"min":4,"max":null},"training":{"min":5,"max":15},"overfeed":{"min":3,"max":null},"sleepDisturbances":{"min":6,"max":null}},{"to":"nume","careMistakes":{"min":4,"max":null},"training":{"min":16,"max":null},"overfeed":{"min":3,"max":null},"sleepDisturbances":{"min":0,"max":5}}]},
14 "beta": {"name":"Betamon","stage":"rookie","attribute":"Virus","power":25,"rules":[{"to":"airdra","careMistakes":{"min":4,"max":null},"training":{"min":8,"max":31},"overfeed":{"min":0,"max":3},"sleepDisturbances":{"min":9,"max":null}},{"to":"seadra","careMistakes":{"min":4,"max":null},"training":{"min":8,"max":31},"overfeed":{"min":4,"max":null},"sleepDisturbances":{"min":0,"max":8}},{"to":"devi","careMistakes":{"min":0,"max":3},"training":{"min":48,"max":null}},{"to":"mera","careMistakes":{"min":0,"max":3},"training":{"min":0,"max":47}},{"to":"nume","careMistakes":{"min":4,"max":null},"training":{"min":0,"max":7}},{"to":"nume","careMistakes":{"min":4,"max":null},"training":{"min":32,"max":null}},{"to":"nume","careMistakes":{"min":4,"max":null},"training":{"min":8,"max":31},"overfeed":{"min":4,"max":null},"sleepDisturbances":{"min":9,"max":null}},{"to":"nume","careMistakes":{"min":4,"max":null},"training":{"min":8,"max":31},"overfeed":{"min":0,"max":3},"sleepDisturbances":{"min":0,"max":8}}]},
15 "grey": {"name":"Greymon","stage":"champion","attribute":"Vaccine","power":50,"rules":[{"to":"metalgrey_vi","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
16 "devi": {"name":"Devimon","stage":"champion","attribute":"Virus","power":50,"rules":[{"to":"metalgrey_vi","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
17 "airdra": {"name":"Airdramon","stage":"champion","attribute":"Vaccine","power":50,"rules":[{"to":"metalgrey_vi","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
18 "nume": {"name":"Numemon","stage":"champion","attribute":"Virus","power":40,"rules":[{"to":"monzae","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
19 "tyrano": {"name":"Tyrannomon","stage":"champion","attribute":"Data","power":45,"rules":[{"to":"mame","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
20 "mera": {"name":"Meramon","stage":"champion","attribute":"Data","power":45,"rules":[{"to":"mame","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
21 "seadra": {"name":"Seadramon","stage":"champion","attribute":"Data","power":45,"rules":[{"to":"mame","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
22 "metalgrey_vi": {"name":"MetalGreymon","stage":"ultimate","attribute":"Virus","power":100,"rules":[{"to":"blitzgrey","careMistakes":{"min":0,"max":1},"battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
23 "monzae": {"name":"Monzaemon","stage":"ultimate","attribute":"Vaccine","power":100,"rules":[{"to":"shinmonzae","careMistakes":{"min":0,"max":1},"battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
24 "mame": {"name":"Mamemon","stage":"ultimate","attribute":"Data","power":85,"rules":[{"to":"banchomame","careMistakes":{"min":0,"max":1},"battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
25 "blitzgrey": {"name":"BlitzGreymon","stage":"mega","attribute":"Virus","power":170,"rules":[{"to":"omega_a","jogress":"Cres Garurumon"}]},
26 "shinmonzae": {"name":"ShinMonzaemon","stage":"mega","attribute":"Vaccine","power":170,"rules":[]},
27 "banchomame": {"name":"BanchoMamemon","stage":"mega","attribute":"Data","power":150,"rules":[]},
28 "cresgaruru": {"name":"CresGarurumon","stage":"mega","attribute":"Data","power":150,"rules":[{"to":"omega_a","jogress":"Blitz Greymon"}]},
29 "omega_a": {"name":"Omnimon Alter-S","stage":"superMega","attribute":"Virus","power":200,"rules":[]},
30 "puni": {"name":"Punimon","stage":"baby1","attribute":"Free","power":null,"rules":[{"to":"tuno"}]},
31 "tuno": {"name":"Tsunomon","stage":"baby2","attribute":"Free","power":null,"rules":[{"to":"gabu","careMistakes":{"min":0,"max":3}},{"to":"elec","careMistakes":{"min":4,"max":null}}]},
32 "gabu": {"name":"Gabumon","stage":"rookie","attribute":"Data","power":30,"rules":[{"to":"kabuteri","careMistakes":{"min":0,"max":3},"training":{"min":48,"max":null}},{"to":"garuru","careMistakes":{"min":4,"max":null},"training":{"min":5,"max":31},"overfeed":{"min":3,"max":null},"sleepDisturbances":{"min":4,"max":5}},{"to":"ange","careMistakes":{"min":0,"max":3},"training":{"min":0,"max":47}},{"to":"yukidaru","careMistakes":{"min":4,"max":null},"training":{"min":32,"max":null},"overfeed":{"min":3,"max":null},"sleepDisturbances":{"min":6,"max":null}},{"to":"vegi","careMistakes":{"min":4,"max":null},"training":{"min":0,"max":4}},{"to":"vegi","careMistakes":{"min":4,"max":null},"overfeed":{"min":0,"max":2}},{"to":"vegi","careMistakes":{"min":4,"max":null},"training":{"min":5,"max":31},"overfeed":{"min":3,"max":null},"sleepDisturbances":{"min":0,"max":3}},{"to":"vegi","careMistakes":{"min":4,"max":null},"training":{"min":5,"max":31},"overfeed":{"min":3,"max":null},"sleepDisturbances":{"min":6,"max":null}},{"to":"vegi","careMistakes":{"min":4,"max":null},"training":{"min":32,"max":null},"overfeed":{"min":3,"max":null},"sleepDisturbances":{"min":0,"max":5}}]},
33 "elec": {"name":"Elecmon","stage":"rookie","attribute":"Data","power":25,"rules":[{"to":"birdra","careMistakes":{"min":0,"max":3},"training":{"min":0,"max":47}},{"to":"wha","careMistakes":{"min":4,"max":null},"training":{"min":32,"max":null},"overfeed":{"min":6,"max":null},"sleepDisturbances":{"min":4,"max":null}},{"to":"ange","careMistakes":{"min":0,"max":3},"training":{"min":48,"max":null}},{"to":"yukidaru","careMistakes":{"min":4,"max":null},"training":{"min":8,"max":31},"overfeed":{"min":0,"max":5},"sleepDisturbances":{"min":6,"max":null}},{"to":"vegi","careMistakes":{"min":4,"max":null},"training":{"min":0,"max":7}},{"to":"vegi","careMistakes":{"min":4,"max":null},"training":{"min":32,"max":null},"overfeed":{"min":0,"max":5}},{"to":"vegi","careMistakes":{"min":4,"max":null},"training":{"min":8,"max":31},"overfeed":{"min":6,"max":null}},{"to":"vegi","careMistakes":{"min":4,"max":null},"training":{"min":32,"max":null},"sleepDisturbances":{"min":0,"max":3}},{"to":"vegi","careMistakes":{"min":4,"max":null},"training":{"min":8,"max":31},"sleepDisturbances":{"min":0,"max":5}}]},
34 "kabuteri": {"name":"Kabuterimon","stage":"champion","attribute":"Vaccine","power":50,"rules":[{"to":"skullgrey","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
35 "ange": {"name":"Angemon","stage":"champion","attribute":"Vaccine","power":50,"rules":[{"to":"skullgrey","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
36 "birdra": {"name":"Birdramon","stage":"champion","attribute":"Vaccine","power":50,"rules":[{"to":"skullgrey","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
37 "vegi": {"name":"Vegiemon","stage":"champion","attribute":"Virus","power":40,"rules":[{"to":"vade","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
38 "garuru": {"name":"Garurumon","stage":"champion","attribute":"Vaccine","power":45,"rules":[{"to":"metalmame","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
39 "yukidaru": {"name":"Frigimon","stage":"champion","attribute":"Vaccine","power":45,"rules":[{"to":"metalmame","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
40 "wha": {"name":"Whamon","stage":"champion","attribute":"Vaccine","power":45,"rules":[{"to":"metalmame","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
41 "skullgrey": {"name":"SkullGreymon","stage":"ultimate","attribute":"Virus","power":100,"rules":[{"to":"skullmam","careMistakes":{"min":0,"max":1},"battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
42 "vade": {"name":"Vademon","stage":"ultimate","attribute":"Virus","power":100,"rules":[{"to":"ebe","careMistakes":{"min":0,"max":1},"battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
43 "metalmame": {"name":"MetalMamemon","stage":"ultimate","attribute":"Data","power":85,"rules":[{"to":"cresgaruru","careMistakes":{"min":0,"max":1},"battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
44 "skullmam": {"name":"SkullMammothmon","stage":"mega","attribute":"Vaccine","power":170,"rules":[]},
45 "ebe": {"name":"Ebemon","stage":"mega","attribute":"Virus","power":170,"rules":[]},
46 "poyo": {"name":"Poyomon","stage":"baby1","attribute":"Free","power":null,"rules":[{"to":"toko"}]},
47 "toko": {"name":"Tokomon","stage":"baby2","attribute":"Free","power":0,"rules":[{"to":"pata","careMistakes":{"min":0,"max":3}},{"to":"kune","careMistakes":{"min":4,"max":null}}]},
48 "pata": {"name":"Patamon","stage":"rookie","attribute":"Data","power":30,"rules":[{"to":"uni","careMistakes":{"min":0,"max":3},"training":{"min":32,"max":null}},{"to":"cental","careMistakes":{"min":4,"max":null},"training":{"min":5,"max":23},"overfeed":{"min":2,"max":null},"sleepDisturbances":{"min":0,"max":2}},{"to":"ogre","careMistakes":{"min":0,"max":3},"training":{"min":0,"max":31}},{"to":"bake","careMistakes":{"min":4,"max":null},"training":{"min":24,"max":null},"overfeed":{"min":2,"max":null},"sleepDisturbances":{"min":0,"max":4}},{"to":"scu","careMistakes":{"min":4,"max":null},"training":{"min":0,"max":4}},{"to":"scu","careMistakes":{"min":4,"max":null},"overfeed":{"min":0,"max":1}},{"to":"scu","careMistakes":{"min":4,"max":null},"sleepDisturbances":{"min":5,"max":null}},{"to":"scu","careMistakes":{"min":4,"max":null},"training":{"min":5,"max":23},"sleepDisturbances":{"min":3,"max":4}}]},
49 "kune": {"name":"Kunemon","stage":"rookie","attribute":"Virus","power":25,"rules":[{"to":"shell","careMistakes":{"min":0,"max":3},"training":{"min":0,"max":7}},{"to":"shell","careMistakes":{"min":0,"max":3},"training":{"min":24,"max":31}},{"to":"shell","careMistakes":{"min":0,"max":3},"training":{"min":8,"max":23},"overfeed":{"min":4,"max":null}},{"to":"shell","careMistakes":{"min":0,"max":3},"training":{"min":8,"max":23},"sleepDisturbances":{"min":0,"max":2}},{"to":"drimoge","careMistakes":{"min":4,"max":null},"training":{"min":24,"max":null},"overfeed":{"min":4,"max":null},"sleepDisturbances":{"min":0,"max":2}},{"to":"ogre","careMistakes":{"min":0,"max":3},"training":{"min":32,"max":null}},{"to":"bake","careMistakes":{"min":0,"max":3},"training":{"min":8,"max":23},"overfeed":{"min":0,"max":3},"sleepDisturbances":{"min":3,"max":null}},{"to":"scu","careMistakes":{"min":4,"max":null},"training":{"min":0,"max":23}},{"to":"scu","careMistakes":{"min":4,"max":null},"overfeed":{"min":0,"max":3}},{"to":"scu","careMistakes":{"min":4,"max":null},"sleepDisturbances":{"min":3,"max":null}}]},
50 "uni": {"name":"Unimon","stage":"champion","attribute":"Vaccine","power":50,"rules":[{"to":"andro","training":{"min":0,"max":31},"battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}},{"to":"chimaira","training":{"min":32,"max":null},"battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
51 "ogre": {"name":"Ogremon","stage":"champion","attribute":"Virus","power":50,"rules":[{"to":"andro","training":{"min":0,"max":31},"battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}},{"to":"chimaira","training":{"min":32,"max":null},"battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
52 "shell": {"name":"Shellmon","stage":"champion","attribute":"Data","power":50,"rules":[{"to":"andro","training":{"min":0,"max":31},"battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}},{"to":"chimaira","training":{"min":32,"max":null},"battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
53 "scu": {"name":"Sukamon","stage":"champion","attribute":"Virus","power":40,"rules":[{"to":"ete","training":{"min":0,"max":39},"battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}},{"to":"chimaira","training":{"min":40,"max":null},"battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
54 "cental": {"name":"Centarumon","stage":"champion","attribute":"Data","power":45,"rules":[{"to":"giro","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
55 "bake": {"name":"Bakemon","stage":"champion","attribute":"Virus","power":45,"rules":[{"to":"giro","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
56 "drimoge": {"name":"Drimogemon","stage":"champion","attribute":"Data","power":45,"rules":[{"to":"giro","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
57 "andro": {"name":"Andromon","stage":"ultimate","attribute":"Vaccine","power":100,"rules":[{"to":"hiandro","careMistakes":{"min":0,"max":1},"battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
58 "ete": {"name":"Etemon","stage":"ultimate","attribute":"Virus","power":100,"rules":[{"to":"bancholeo","careMistakes":{"min":0,"max":1},"battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
59 "chimaira": {"name":"Kimeramon","stage":"ultimate","attribute":"Data","power":100,"rules":[{"to":"bancholeo","careMistakes":{"min":0,"max":1},"battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}},{"to":"millenniu","jogress":"Mugendramon"}]},
60 "giro": {"name":"Giromon","stage":"ultimate","attribute":"Vaccine","power":85,"rules":[{"to":"goku","careMistakes":{"min":0,"max":1},"battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
61 "hiandro": {"name":"HiAndromon","stage":"mega","attribute":"Vaccine","power":150,"rules":[]},
62 "bancholeo": {"name":"BanchoLeomon","stage":"mega","attribute":"Vaccine","power":180,"rules":[{"to":"chaos","jogress":"Darkdramon"}]},
63 "goku": {"name":"Reapermon","stage":"mega","attribute":"Virus","power":170,"rules":[]},
64 "darkdra": {"name":"Darkdramon","stage":"mega","attribute":"Virus","power":170,"rules":[{"to":"chaos","jogress":"Bancho Leomon"},{"to":"chaosdra","jogress":"Mugendramon"}]},
65 "chaos": {"name":"Chaosmon","stage":"superMega","attribute":"Vaccine","power":200,"rules":[]},
66 "mugendra": {"name":"Machinedramon","stage":"mega","attribute":"Virus","power":170,"rules":[{"to":"millenniu","jogress":"Chimairamon"},{"to":"chaosdra","jogress":"Darkdramon"}]},
67 "millenniu": {"name":"Millenniumon","stage":"superMega","attribute":"Virus","power":200,"rules":[]},
68 "yura": {"name":"Yuramon","stage":"baby1","attribute":"Free","power":null,"rules":[{"to":"tane"}]},
69 "tane": {"name":"Tanemon","stage":"baby2","attribute":"Free","power":0,"rules":[{"to":"piyo","careMistakes":{"min":0,"max":3}},{"to":"pal","careMistakes":{"min":4,"max":null}}]},
70 "piyo": {"name":"Biyomon","stage":"rookie","attribute":"Vaccine","power":30,"rules":[{"to":"monochro","careMistakes":{"min":0,"max":3},"training":{"min":32,"max":null}},{"to":"cockatri","careMistakes":{"min":4,"max":null},"training":{"min":5,"max":23},"overfeed":{"min":2,"max":null},"sleepDisturbances":{"min":0,"max":2}},{"to":"leo","careMistakes":{"min":0,"max":3},"training":{"min":0,"max":31}},{"to":"kuwaga","careMistakes":{"min":4,"max":null},"training":{"min":24,"max":null},"overfeed":{"min":2,"max":null},"sleepDisturbances":{"min":0,"max":4}},{"to":"nani","careMistakes":{"min":4,"max":null},"training":{"min":0,"max":4}},{"to":"nani","careMistakes":{"min":4,"max":null},"overfeed":{"min":0,"max":1}},{"to":"nani","careMistakes":{"min":4,"max":null},"sleepDisturbances":{"min":5,"max":null}},{"to":"nani","careMistakes":{"min":4,"max":null},"training":{"min":5,"max":23},"sleepDisturbances":{"min":3,"max":4}}]},
71 "pal": {"name":"Palmon","stage":"rookie","attribute":"Data","power":25,"rules":[{"to":"coela","careMistakes":{"min":0,"max":3},"training":{"min":0,"max":7}},{"to":"coela","careMistakes":{"min":0,"max":3},"training":{"min":24,"max":31}},{"to":"coela","careMistakes":{"min":0,"max":3},"training":{"min":8,"max":23},"overfeed":{"min":4,"max":null}},{"to":"coela","careMistakes":{"min":0,"max":3},"training":{"min":8,"max":23},"sleepDisturbances":{"min":0,"max":2}},{"to":"mojya","careMistakes":{"min":4,"max":null},"training":{"min":24,"max":null},"overfeed":{"min":4,"max":null},"sleepDisturbances":{"min":0,"max":2}},{"to":"leo","careMistakes":{"min":0,"max":3},"training":{"min":32,"max":null}},{"to":"kuwaga","careMistakes":{"min":0,"max":3},"training":{"min":8,"max":23},"overfeed":{"min":0,"max":3},"sleepDisturbances":{"min":3,"max":null}},{"to":"nani","careMistakes":{"min":4,"max":null},"training":{"min":0,"max":23}},{"to":"nani","careMistakes":{"min":4,"max":null},"overfeed":{"min":0,"max":3}},{"to":"nani","careMistakes":{"min":4,"max":null},"sleepDisturbances":{"min":3,"max":null}}]},
72 "monochro": {"name":"Monochromon","stage":"champion","attribute":"Data","power":50,"rules":[{"to":"megadra","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
73 "leo": {"name":"Leomon","stage":"champion","attribute":"Vaccine","power":50,"rules":[{"to":"megadra","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
74 "coela": {"name":"Coelamon","stage":"champion","attribute":"Data","power":50,"rules":[{"to":"megadra","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
75 "nani": {"name":"Nanimon","stage":"champion","attribute":"Virus","power":40,"rules":[{"to":"digitama","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
76 "cockatri": {"name":"Kokatorimon","stage":"champion","attribute":"Data","power":45,"rules":[{"to":"piccolo","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
77 "kuwaga": {"name":"Kuwagamon","stage":"champion","attribute":"Virus","power":45,"rules":[{"to":"piccolo","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
78 "mojya": {"name":"Mojyamon","stage":"champion","attribute":"Vaccine","power":45,"rules":[{"to":"piccolo","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
79 "megadra": {"name":"Megadramon","stage":"ultimate","attribute":"Virus","power":100,"rules":[{"to":"darkdra","careMistakes":{"min":0,"max":1},"battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
80 "digitama": {"name":"Digitamamon","stage":"ultimate","attribute":"Data","power":100,"rules":[{"to":"gankoo","careMistakes":{"min":0,"max":1},"battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
81 "piccolo": {"name":"Piximon","stage":"ultimate","attribute":"Data","power":85,"rules":[{"to":"bloomlord","careMistakes":{"min":0,"max":1},"battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
82 "gankoo": {"name":"Gankoomon","stage":"mega","attribute":"Data","power":170,"rules":[]},
83 "bloomlord": {"name":"BloomLordmon","stage":"mega","attribute":"Vaccine","power":150,"rules":[]},
84 "chaosdra": {"name":"Chaosdramon","stage":"superMega","attribute":"Virus","power":200,"rules":[]},
85 "zuru": {"name":"Zurumon","stage":"baby1","attribute":"Free","power":null,"rules":[{"to":"pagu"}]},
86 "pagu": {"name":"Pagumon","stage":"baby2","attribute":"Free","power":0,"rules":[{"to":"gazi","careMistakes":{"min":0,"max":3}},{"to":"giza","careMistakes":{"min":4,"max":null}}]},
87 "gazi": {"name":"Gazimon","stage":"rookie","attribute":"Virus","power":30,"rules":[{"to":"darktyrano","careMistakes":{"min":0,"max":3},"training":{"min":32,"max":null}},{"to":"cyclo","careMistakes":{"min":4,"max":null},"training":{"min":5,"max":23},"overfeed":{"min":2,"max":null},"sleepDisturbances":{"min":0,"max":2}},{"to":"devidra","careMistakes":{"min":0,"max":3},"training":{"min":0,"max":31}},{"to":"tusk","careMistakes":{"min":4,"max":null},"training":{"min":24,"max":null},"overfeed":{"min":2,"max":null},"sleepDisturbances":{"min":0,"max":4}},{"to":"rare","careMistakes":{"min":4,"max":null},"training":{"min":0,"max":4}},{"to":"rare","careMistakes":{"min":4,"max":null},"overfeed":{"min":0,"max":1}},{"to":"rare","careMistakes":{"min":4,"max":null},"sleepDisturbances":{"min":5,"max":null}},{"to":"rare","careMistakes":{"min":4,"max":null},"training":{"min":5,"max":23},"sleepDisturbances":{"min":3,"max":4}}]},
88 "giza": {"name":"Gizamon","stage":"rookie","attribute":"Virus","power":25,"rules":[{"to":"fly","careMistakes":{"min":0,"max":3},"training":{"min":0,"max":7}},{"to":"fly","careMistakes":{"min":0,"max":3},"training":{"min":24,"max":31}},{"to":"fly","careMistakes":{"min":0,"max":3},"training":{"min":8,"max":23},"overfeed":{"min":4,"max":null}},{"to":"fly","careMistakes":{"min":0,"max":3},"training":{"min":8,"max":23},"sleepDisturbances":{"min":0,"max":2}},{"to":"delta","careMistakes":{"min":4,"max":null},"training":{"min":24,"max":null},"overfeed":{"min":4,"max":null},"sleepDisturbances":{"min":0,"max":2}},{"to":"devidra","careMistakes":{"min":0,"max":3},"training":{"min":32,"max":null}},{"to":"tusk","careMistakes":{"min":0,"max":3},"training":{"min":8,"max":23},"overfeed":{"min":0,"max":3},"sleepDisturbances":{"min":3,"max":null}},{"to":"rare","careMistakes":{"min":4,"max":null},"training":{"min":0,"max":23}},{"to":"rare","careMistakes":{"min":4,"max":null},"overfeed":{"min":0,"max":3}},{"to":"rare","careMistakes":{"min":4,"max":null},"sleepDisturbances":{"min":3,"max":null}}]},
89 "darktyrano": {"name":"Dark Tyrannomon","stage":"champion","attribute":"Virus","power":50,"rules":[{"to":"metaltyrano","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
90 "devidra": {"name":"Devidramon","stage":"champion","attribute":"Virus","power":50,"rules":[{"to":"metaltyrano","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
91 "fly": {"name":"Flymon","stage":"champion","attribute":"Virus","power":50,"rules":[{"to":"metaltyrano","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
92 "rare": {"name":"Raremon","stage":"champion","attribute":"Virus","power":40,"rules":[{"to":"extyrano","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
93 "cyclo": {"name":"Cyclonemon","stage":"champion","attribute":"Virus","power":45,"rules":[{"to":"nano","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
94 "tusk": {"name":"Tuskmon","stage":"champion","attribute":"Virus","power":45,"rules":[{"to":"nano","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
95 "delta": {"name":"Deltamon","stage":"champion","attribute":"Virus","power":45,"rules":[{"to":"nano","battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
96 "metaltyrano": {"name":"MetalTyrannomon","stage":"ultimate","attribute":"Virus","power":100,"rules":[{"to":"mugendra","careMistakes":{"min":0,"max":1},"battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
97 "extyrano": {"name":"ExTyranomon","stage":"ultimate","attribute":"Vaccine","power":100,"rules":[{"to":"gaiou","careMistakes":{"min":0,"max":1},"battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
98 "nano": {"name":"Datamon","stage":"ultimate","attribute":"Virus","power":85,"rules":[{"to":"raiden","careMistakes":{"min":0,"max":1},"battles":{"min":15,"max":null},"winRatio":{"min":80,"max":null}}]},
99 "gaiou": {"name":"Samudramon","stage":"mega","attribute":"Virus","power":180,"rules":[]},
100 "raiden": {"name":"Raidenmon","stage":"mega","attribute":"Virus","power":150,"rules":[]},
101}
102hooks/view.ts 56 lines1// The pet as plain data, what the hooks hand a drawing (a Client module's props
2// must be JSON): the species by id instead of its sprite.
3import { EVOLVE_FRAMES } from './render'
4import type { Act, AllyView, Attribute, Hunger, Mood, PaneView, PetView } from './render'
5import { SPRITES } from './sprites.gen'
6import type { Sprite } from './sprite'
7
8export type ViewJson = {
9 species: string
10 hunger: Hunger
11 minutesLeft: number
12 ttlMinutes: number
13 mood: Mood
14 attribute: Attribute | null
15 project: string
16 quiet?: string
17 /** A digivolution to show (`/digi sim … evolving <to>`). */
18 evolvingTo?: string
19 act?: Act
20 tool?: string
21 next?: { label: string; ratio: number } | null
22 allies?: AllyJson[]
23}
24
25/** An ally as a drawing's props carry it: the species by id. */
26export type AllyJson = { species: string; label: string; leaving?: 'done' | 'failed' }
27
28export function allyViews(allies: readonly AllyJson[]): AllyView[] {
29 return allies.map(({ species, ...rest }) => ({ ...rest, sprite: spriteOf(species) }))
30}
31
32export type PaneJson = ViewJson & { careMistakes: number; facts: string[] } & Pick<PaneView, 'stats' | 'battles' | 'ageDays' | 'log' | 'trophies' | 'weight' | 'chaos' | 'forecast'>
33
34export function spriteOf(id: string): Sprite {
35 return (SPRITES as Record<string, Sprite>)[id] ?? SPRITES.bota
36}
37
38export function isSpecies(id: string): boolean {
39 return id in SPRITES
40}
41
42/** The view at frame `t`; a digivolution loops, holding its end a while. */
43export function petView(v: ViewJson, t: number): PetView {
44 const { species, evolvingTo, allies, ...rest } = v
45 return {
46 ...rest,
47 ...(allies ? { allies: allyViews(allies) } : {}),
48 sprite: spriteOf(species),
49 evolving: evolvingTo ? { to: spriteOf(evolvingTo), t: t % (EVOLVE_FRAMES + 8) } : undefined,
50 }
51}
52
53export function paneView(v: PaneJson, t: number): PaneView {
54 return { ...petView(v, t), careMistakes: v.careMistakes, facts: v.facts, stats: v.stats, battles: v.battles, ageDays: v.ageDays, log: v.log, trophies: v.trophies, weight: v.weight, chaos: v.chaos, forecast: v.forecast }
55}
56