SLOPSHOPPER

digi-pet

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…

newpanebandguardcommandtoast
★ 6v0.1.8MITupdated 2026-10-08trongtaiz/digi-pet/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · digi-pet
│ ┃ digi ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./screen.tsx │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /digi │ ⎿ digi-pet: Ver.3 Digitama (egg) · day 0 · 10g │ ⎿ digi-pet: Stats: STA 0 · INT 0 · ATK 0 · DEF 0 · SPD 1 · SYN 0 │ ⎿ digi-pet: This stage: 0 care mistakes · 1 training · 0 overfeed │ ⎿ digi-pet: Battles this stage: 0W 1L · win ratio 0% over every ba │ ⎿ digi-pet: Next: turns 1/3 → Baby I │ ⎿ digi-pet: Hunger: waiting for the first prompt │ │ ▣ client module ./screen.tsx ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ client module ./screen.tsx ⟨Claude Code's own drawing⟩
Pane · digi
▣ client module ./screen.tsx
README

digi-pet

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.

The band during a turn: monsters run in and Agumon shoots them down from its Digivice

A Ver.1 line from egg to Mega: Digitama, Botamon, Koromon, Agumon, Greymon, MetalGreymon, BlitzGreymon

Every Digimon and evolution chart of the five versions: Digitama Hatchery, Digital Monster Color.

Install

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

How it works

The screen

The pet sits in a small Digivice in the band above the prompt. What the band shows follows what Claude is doing:

  • Idle: the pet walks around its screen, with its name, stage, hunger hearts and progress to the next stage beside it.
  • Working: while a turn runs, the band becomes a fight (see Battles). With ten rows or more, the tool running gets a row of its own under the pet: the command, file or task it runs, and for how long (⏵ Bash npm test 12s).
  • Needs you: when it is hungry, starving, sick or digivolving, the Digivice shell takes that state's colour (orange, red, purple, gold) and the pet says why.

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.

The /digi pane: Agumon in its Digivice with hunger, care mistakes, battles, trophies, progress to Champion, six stat bars and its evolution so far

Hunger is the prompt cache

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 minfull
30 to 45 minpeckish
45 to 55 minhungry: a toast and a macOS notification
55 to 60 minstarving: another warning, with the minutes left
60 min and overthe 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:

  • in rest hours (restHours, lunch and evenings by default), unless you /digi wake it, which keeps it up until that window ends;
  • on a break (/digi break 90m, /digi break until 15:30) or after /digi sleep, until your next prompt;
  • in a side session (/digi side, or a folder listed in sidePaths), where it still shows when the cache goes cold (cache 42m left (cold at 10:00));
  • when the context is under 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 hungry pet: the Digivice turns orange and Agumon says the cache goes cold in 12 minutes

Care mistakes

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:

  • it is at most one care mistake per session per day;
  • coming back the next day is never one (the day starts at dayStartsHour, so a late night belongs to the evening before);
  • a cache that went cold in rest hours or on a break is never one.

A context over 85% full is an overfeed, which also counts at evolution time. Turns sent in rest hours are sleep disturbances.

Battles

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

  • When a check fails, a red boss appears with "1 check red" and stays.
  • If the same check passes again in the same turn, the boss blows up: a win.
  • If the turn ends with it still red, it is a loss.
  • An interrupted turn is neither.
A check is redIt goes green in the same turn
A red boss with "1 check red" while npm test failsThe boss blows up with WIN! and the score goes to 4

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.

Allies

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.

Stats

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.

StatGrows from
STAtime Claude spends working
INTresearch: reads, searches, web lookups
ATKlines written (Edit and Write)
DEFchecks that pass
SPDparallel tool calls and quick turns
SYNyour prompts, pats (/digi pet, three a day) and a streak of active days

Growing up

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

StageBefore it can digivolve
Egg3 turns
Baby I25 turns
Baby II2 active days and 100 turns
Rookie5 active days
Champion10 active days and 30 trophies
Ultimate21 active days, 60 trophies and 10 summons
Mega14 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:

  • Chaos. Risky commands (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.
  • A Rookie always digivolves. If no branch fits, it takes the chart's catch-all, as the device does.

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

Agumon digivolving: it flashes to a silhouette, then Greymon appears with sparkles

Jogress and eggs

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.

Commands

Command
/digi or /digi statsStats, hunger, and what the next evolution needs
/digi paneOpen the side pane: the Digivice, stats, branches and evolution log
/digi logEvolution history
/digi petPat it (the first three a day raise SYN)
/digi jogressFuse with a partner, where the chart allows
/digi sleepSleep until your next prompt
/digi wakeWake it: ends a sleep or a break, and keeps it up through the rest window it is in
/digi break 90m / until 15:30 / offA one-off rest window
/digi side [off]Mark this session as side work: no hunger
/digi sim <species> [state] / sim offPreview any species and state
/digi debug ttl <min> / offShorten the cache TTL to watch hunger play out

Options

Set them with claude plugin configure digi-pet@digi-pet:

OptionDefault
ttlMinutes60Your cache TTL: 60 on a 1-hour cache, 5 on the default one
restHours12:00-14:00,18:00-08:00Nap windows, local time
dayStartsHour4A late night counts as the evening before
sidePathsGlobs whose sessions are side sessions
minContextTokens20000Below this the cache is cheap to rebuild; no hunger
notifytruemacOS notifications when hungry or starving
pacenormalfast, normal or slow growth
reducedMotionfalseDraw everything still

Development

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.

License

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.

Source 16 files
hooks/register.tsx 877 lines
1// 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}
877
hooks/activity.ts 47 lines
1// 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}
47
hooks/cells.ts 116 lines
1// 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}
116
hooks/growth.ts 287 lines
1// 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}
287
hooks/hunger.ts 201 lines
1// 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}
201
hooks/ledger.ts 136 lines
1// 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}
136
hooks/render.ts 888 lines
1// 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}
888
hooks/run.tsx 32 lines
1// 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
32
hooks/screen.tsx 42 lines
1// 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
42
hooks/signals.ts 97 lines
1// 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}
97
hooks/species.gen.ts 102 lines
1// 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}
102
hooks/view.ts 56 lines
1// 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