SLOPSHOPPER

pets

A pixel pet that lives in a Claude Code pane: it wanders, reacts to your session, and grows as you work

newpaneguardcommandtoastprocess
★ 3v0.3.0MITupdated 2026-10-04uppinote20/claude-pets
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pets
│ ┃ Pet ✕ › fix the failing auth test and add an audit log call │ ┃ nice work! │ ┃ ▄ ▀ ▄ ▀ ⏺ 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. │ ┃ │ ┃ CAT ✻ Worked for 42s · done 4:20 PM │ ┃ Lv 1 15 / 25 xp │ ┃ ━━━━━━━━━━━━━━━━━━━━ › /pet │ ┃ curious ⎿ pets: The cat is out. │ ┃ tool calls 9 +1 │ ┃ turns 1 +5 │ ┃ pats 0 +2 │ ┃ output tokens 1.5K 1K +1 │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Pet
nice work! ▄ ▀ ▄ ▀ ▄▀▀ █▄ ▄█ ▀▄▄ ▄█▀██████▀█▄ ██▀▀████▀▀██ ▀▀▀██▀▀██▀▀▀ CAT Lv 1 15 / 25 xp ━━━━━━━━━━━━━━━━━━━━ curious tool calls 9 +1 turns 1 +5 pats 0 +2 output tokens 1.5K 1K +1
Pane · pets-run
The cat score 0 best 0 snacks 0 press j to start ███▄▄ ▄▄▄ ▄███ ▀▀▀▀▀▀ ▄██████▄ ▀▀▀▀▀ j: Jump r: Again
Pane · pets-quest
The cat stage 1/8 score 0 snacks 0 press j to start ▄█ ▄▄▄ ▄▀ ▄██████▄ j: Jump r: Retry n: Next
README

<h1 align="center"><img src="assets/banner.svg" width="880" alt="claude-pets"></h1>

A pixel pet that lives in a Claude Code pane. It wanders while you work, reacts to what your session does, and grows across sessions. Eight to choose from: <code>cat</code> · <code>chick</code> · <code>dog</code> · <code>slime</code> · <code>bunny</code> · <code>hamster</code> · <code>penguin</code> · <code>frog</code>.

[!NOTE] This is a Claude Code mod (a plugin of function hooks). The mod API is early access, so a new Claude Code release may break the pet until this plugin catches up.

Install

/plugin marketplace add uppinote20/claude-pets
/plugin install pets@claude-pets

Then type /pet. To try it from a clone: claude --plugin-dir /path/to/claude-pets.

Commands

CommandWhat it does
/petLet the pet out
/pet patPat it
/pet name <name>Name it (up to 20 characters)
/pet choose <species>Bring out another pet (each species is its own pet, with its own name and level)
`/pet size small\medium`Pane height: 6 or 9 rows (default medium)
/pet playPet Run: j jump · r again · Esc stop
/pet quest [stage]Pet Quest: j jump (again while rising: higher) · r retry · n next
/pet statusLevel, experience, nature, rhythm and counts
/pet byeClose the pane (or ctrl+x x)

The pane's ✕ is clickable in the fullscreen layout (/tui fullscreen) only; on the main screen the terminal reports no clicks.

How it grows

It reacts to your session: notes while tools run, a jump when a turn finishes, a nap after two quiet minutes, a tear when a rate-limit window passes 80%. Everything it lives through is experience, kept across sessions:

EventXP
Tool call1
Game snack (Pet Run, Pet Quest)1
Pat2
Finished turn5
1,000 output tokens1
Gift, lucky pat5–100

It counts whenever the plugin is loaded, whether or not the pane is open. Only output tokens count: input and cache reads grow with the conversation's length, not with the work done.

Level n + 1 takes 25 × n² XP (Lv 5 at 400, Lv 10 at 2,025, Lv 50 at 60,025), with no cap.

StageFromLooks
BabyLv 1As drawn
TeenLv 15A taller body, and its accessory (a ribbon, a flower, a scarf, a crown, a leaf, a knit hat)
AdultLv 40Evolves into the form of its nature (below), and twinkles. One in twenty takes the rare form instead

Games

<table> <tr> <td align="center"><img src="assets/run.gif" width="360" alt="Pet Run: the cat jumping bugs and catching snacks"><br><code>/pet play</code>: Pet Run</td> <td align="center"><img src="assets/quest.gif" width="320" alt="Pet Quest: the chick clearing stage 1"><br><code>/pet quest</code>: Pet Quest</td> </tr> </table>

Snacks from both games are experience. What a game earned stays with the pet that played it, even if you /pet choose another mid-game.

Pet Run

/pet play opens a runner in its own pane. The pet runs along the grass: jump the bugs (j, or the Jump button) and snap up the snacks floating over them. It gets faster as it goes. r runs again after a bug, Esc stops. Its best score is kept.

Pet Quest

/pet quest opens the next stage of an auto-running side-scroller. The pet runs on its own; j jumps, and a second j while rising takes it higher, even off the top of the view (an arrow shows where it will land). Stomp the bugs from above, knock the gift boxes from below for snacks, hop the stumps and pits, and reach the snack bowl. A wall stops it until it jumps.

Eight stages, each opened by clearing the one before; /pet quest 3 replays one already open. Stages are tile maps in hooks/quest.ts, one character per 4×4 tile (all eight take about 7 KB), and a test searches every stage for a way through.

/pet status counts the snacks of both games together. Off the terminal both games draw as SVG, with buttons to tap.

It takes after you

What you do most becomes its nature, and the yard grows that way: a building site, a library corner, a garden or an arcade, tinted to match, under your local sky.

Nature

Its nature is the side of you it sees most, once it has seen enough:

NatureFromHow it shows
workertool callsholds up a laptop while tools run; as an adult, swings a pickaxe
scholarturns and outputambles and stops to read; as an adult, a graduation cap
sweetiepatspauses for a heart; as an adult, a flower pin
gamergame snacksruns faster and kicks a ball; as an adult, a headset
curious(until one side stands out, or two tie)sniffs around

The nature follows its leaning: the shares of the four sides, which move once a day by 12% toward what that day brought. A long session changes nothing until the day is over, and old habits fade slowly: a new prop shows after a few days of a new habit, a new form after about two weeks. An adult whose leaning has clearly moved on (the new side well ahead, the old one faded) takes the new side's form; a toast says so. The rare form stays.

The yard

The yard shows the leaning. Each side fills it a prop at a time and tints the grass its way: a building site (cones, barriers, a warning sign), a library corner (a bookshelf, a lamp, a globe), a garden (tulips, a heart bush, a butterfly), an arcade (a cabinet, a trophy, a joystick). A mixed leaning makes a mixed yard.

It follows your local clock (read from date): the morning, midday and evening sun, and the moon and stars at night. Off the terminal, the card turns night blue.

Rhythm

Its rhythm comes from the hours your finished turns keep: night owl, early bird, daytimer or evening type. Nature and rhythm show in the stats and in /pet status, and the pet muses about them now and then.

The form is settled the moment it reaches Lv 40 (by any experience, games included) and kept from then on.

  • By nature: the cat has a drawing of its own for each (overalls, a scholar's robe and glasses, a hoodie, extra fluff). The others are their teen in their nature's gear until theirs are drawn.
  • Rare (one in twenty, with luck on): the celestial cat, the phoenix chick, the moon wolf, the king slime, the moon bunny, the golden hamster, the emperor penguin, the prince frog.
  • Gifts: one finished turn in ten turns up a gift: mostly a cookie (+5 XP), sometimes a toy (+15) or a treasure (+40), now and then the jackpot (+100), and one gift in a hundred is a sparkle stone.
  • Lucky pats: one pat in ten is worth three times as much.
  • Shiny pets: a pet met for the first time (your very first cat included) is shiny one time in 32, in colors of its own (✦ in the stats). A sparkle stone makes the pet that is out shiny; a second one is a jackpot.

Turn it all off with the plugin's luck setting (/config).

SizeSpriteTerminal rows
small8×8, at every stage6
medium12×129

The pane opens at that height; a height you drag it to yourself wins. With room (above a wide prompt, or docked), a stats panel sits beside or under the yard; otherwise the line above the pet shows the level.

SurfaceDrawn as
TerminalHalf-block cells (Raster)
Desktop, VS Code, mobileAn SVG card with a name tag, a speech bubble and a grass mound

The pet runs wherever Claude Code runs, so the plugin has to be loaded by the session the app is looking at:

  • Remote Control: install the plugin on your computer, run claude remote-control in the folder you work in, and open that session in the Claude Code app. Type /pet there.
  • Claude Code on the web: a cloud session loads the plugins its repository's .claude/settings.json enables (extraKnownMarketplaces and enabledPlugins), so add pets@claude-pets there and open that session in the app.

Adding a species

A species is drawn twice in hooks/species.ts, one character per pixel, facing left: big at 12×12 and mini at 8×8.

big: {
  rows: [
    '.o........o.',
    '.oo......oo.',
    '.opoooooopo.',
    'oooooooooooo',
    'oowkoooowkoo',   // eyes: 2×2, a white glint in the top corner
    'ookkooookkoo',
    'oppoommooppo',   // blush and a small mouth
    // ...12 rows of 12 characters
  ],
  eyesShut: { 4: 'oooooooooooo' },   // the glint row turns to fur: a closed, happy line
  tear: { 6: 'obpoommooppo' },
  feetApart: { 11: '..oo....oo..' },
},
ink: { o: 0xffd787, w: 0xfffaf0, p: 0xffafd7, k: 0x5f4b4b, m: 0xd08770, b: 0x87d7ff },

. is empty; every other character needs a color in ink. Keep the shared face marks (w glint, k eyes, p blush, b tear). Supply by row index the rows that replace the eyes, the cheek and the feet. A species also needs a teen sprite, an accessory for each size, its head position (where gear sits), shiny colors and a rare form: copy an existing species as a start. Then add it to the species list above and regenerate assets/. A test checks every row's width and colors.

Development

claude plugin validate .claude-plugin/plugin.json   # manifest and hooks module
claude plugin test .                                # tests/*.test.ts
node scripts/sprites.mjs && node scripts/demo.mjs && node scripts/showcase.mjs   # regenerate assets/ (Node 22.18+)

The drawing lives in hooks/scene.ts, which has no $, so tests draw exactly what the pane does. CI fails when assets/ is stale. Once Claude Code has loaded the plugin from this folder it lays its type declarations in .claude-plugin/types/, and tsc -p . type-checks the plugin.

License

MIT

Source 8 files
hooks/register.tsx 1086 lines
1/**
2 * The hooks module: state, the /pet command, session reactions and the Pane render.
3 * @handbook 1.1-mod-registration
4 * @handbook 1.2-state-layers
5 * @handbook 1.3-event-flow
6 * @handbook 2.1-defensive-store-read
7 * @handbook 2.2-grow-write-once
8 * @handbook 2.3-name-sanitizing
9 * @handbook 3.1-xp-level-curve
10 * @handbook 3.2-mood-state-machine
11 * @handbook 3.3-pet-command
12 * @handbook 3.4-pet-run
13 * @handbook 3.5-pet-quest
14 * @handbook 3.6-luck
15 * @handbook 3.7-nature-rhythm
16 * @handbook 4.2-pixel-pipeline
17 * @handbook 4.3-surface-branch
18 * @tested tests/pet.test.ts
19 */
20import { atom, read, update } from 'claude-code'
21import type { EngineInterface, Register } from 'claude-code'
22
23import type { Pet, PetProfile, PetSize, PetStats, QuestView, RunView } from '../types'
24import { DEFAULT_SIZE, SIZES, STAGE_LEVELS, cardWidthFor, lookOf, pixelsSvg, stageOf, minWidth, pack, paint, terminalRows, toCells, toSvg } from './scene'
25import { QUEST_HEIGHT, QUEST_TICK_MS, STAGES, advanceQuest, jumpQuest, newQuest, paintQuest, questScore } from './quest'
26import type { Quest } from './quest'
27import type { Form } from './scene'
28import { DAYPARTS, SIDES, daypartOf, driftLeaning, isKnown, lifetimeLeaning, musingOf, natureOf, reformOf, rhythmOf } from './nature'
29import type { Daypart, Nature, Side } from './nature'
30import { GIFT_CHANCE, LUCKY_PAT_CHANCE, RARE_CHANCE, SHINY_CHANCE, giftOf, worthOf } from './luck'
31import { RUN_HEIGHT, RUN_TICK_MS, advance, jump, newRun, paintRun, scoreOf } from './run'
32import type { Run } from './run'
33import { DEFAULT_SPECIES, SPECIES } from './species'
34import type { Species } from './species'
35
36const PANE = 'pets'
37const PLAY = 'pets-run'
38const PLAY_TITLE = 'Pet Run'
39const QUEST = 'pets-quest'
40const QUEST_TITLE = 'Pet Quest'
41/** The Pet Run course's width bounds, in pixels (terminal columns). */
42const RUN_MIN = 40
43const RUN_MAX = 80
44const TITLE = 'Pet'
45const PROFILE_KEY = 'profile'
46const NAME_LIMIT = 20
47const TICK_MS = 600
48const STEPS = 20
49const NAP_AFTER = 200
50const WORRY_PERCENT = 80
51/** The widest yard, in sprite pixels: room to roam above a wide prompt, and the SVG card's, where wider leaves the pet lost on it. */
52const MAX_YARD = 80
53/** The widest the SVG card grows to fill the slot, in sprite pixels. A tally line that needs more still widens it. */
54const MAX_CARD = 30
55/** The terminal's cell, in CSS pixels of a remote surface's code font, roughly. */
56const CELL_PX = 8
57const XP_PER_TURN = 5
58const XP_PER_PAT = 2
59const XP_PER_SNACK = 1
60const TOKENS_PER_XP = 1000
61const XP_CURVE = 25
62/** What follows `/pet`: the command's argument hint and its usage line are both built from this. */
63const VERBS = ['pat', 'name <name>', 'choose <species>', 'size <small|medium>', 'play', 'quest [stage]', 'status', 'bye'] as const
64const BAR_CELLS = 5
65/** The stats column beside the yard: its width, and the bar's inside it. */
66const PANEL = 28
67const PANEL_BAR = 20
68
69const PASTEL = { yellow: '#ffd787', pink: '#ffafd7', green: '#afd7af', ink: '#3a2a2a', gray: '#b2b2b2', dim: '#5f5f5f' } as const
70const NEWBORN: Pet = { x: 0, dir: 1, frame: 0, mood: 'walk', hold: 0, idle: 0 }
71const UNMET: PetStats = { name: '', pats: 0, tools: 0, turns: 0, tokens: 0, snacks: 0, best: 0, cleared: 0, gifts: 0, bonus: 0, shiny: false, hours: [0, 0, 0, 0], form: '', leaning: [], day: '', brought: [0, 0, 0, 0] }
72const STRANGER: PetProfile = { species: DEFAULT_SPECIES, size: DEFAULT_SIZE, pets: {} }
73
74const pet = atom({ plugin: 'pets', key: 'pet' } as const, NEWBORN)
75const profile = atom({ plugin: 'pets', key: 'profile' } as const, STRANGER)
76const isWorried = atom({ plugin: 'pets', key: 'isWorried' } as const, false)
77const runView = atom({ plugin: 'pets', key: 'run' } as const, { phase: 'ready', score: 0, snacks: 0 } as RunView)
78
79// Pet Run's course lives here rather than in $.state: it changes twenty times a second and the
80// terminal repaints it with $.ui.blit, without a redraw. `runView` carries what the text shows.
81let course: Run | null = null
82let ticker: { cancel: () => void } | null = null
83// Redraw on every tick when a surface without Raster (desktop, VS Code, mobile) is attached.
84let isRemote = false
85// The level last seen for the pet that is out, so a level-up is announced once, whether it came
86// from a turn, a pat or a run.
87let shownLevel = 1
88// The `luck` setting, as register last read it: the rare form is luck too.
89let isLuckOn = true
90const FORMS: readonly string[] = ['worker', 'scholar', 'sweetie', 'gamer', 'curious', 'rare'] satisfies readonly Form[]
91
92function isForm(value: string): value is Form {
93  return FORMS.includes(value)
94}
95
96/** An adult's form: as kept, else what it would become now. */
97function formOf(stats: PetStats): Form {
98  return isForm(stats.form) ? stats.form : natureOf(stats)
99}
100
101/** The leaning the yard shows: none until the pet knows you, then the kept one (or its life's). */
102function yardLeaning(stats: PetStats): readonly number[] {
103  if (!isKnown(stats)) {
104    return []
105  }
106
107  return stats.leaning.length === SIDES.length ? stats.leaning : lifetimeLeaning(stats)
108}
109
110/** `scholar`, or for the rare form its own name (`celestial`). */
111function formName(kind: Species, form: Form): string {
112  return form === 'rare' ? kind.rare.name : form
113}
114// The host's offset from UTC in minutes: the module's own clock is UTC, so `date` says.
115let utcOffset = 0
116// The engine's clock as of the last tick (tests move it with mock.clock).
117let clockNow = Date.now()
118
119/** The person's local date, `YYYY-MM-DD`: the leaning moves once a day. */
120function dayNow(): string {
121  return new Date(clockNow + utcOffset * 60_000).toISOString().slice(0, 10)
122}
123
124/**
125 * Tallies what something brought into the day's count; when the day has changed, first moves
126 * the leaning toward the day before and lets an adult follow it.
127 */
128function tally(stats: PetStats, brought: Readonly<Record<Side, number>>, today: string): PetStats {
129  const isNewDay = stats.day !== '' && stats.day !== today
130  const start = stats.leaning.length === SIDES.length ? stats.leaning : lifetimeLeaning(stats)
131  const leaning = isNewDay
132    ? driftLeaning(start, Object.fromEntries(SIDES.map((side, at) => [side, stats.brought[at] ?? 0])) as Record<Side, number>)
133    : start
134  const kept = isNewDay ? [0, 0, 0, 0] : stats.brought
135
136  return {
137    ...stats,
138    leaning,
139    form: isNewDay ? reformOf(stats.form, leaning) : stats.form,
140    day: today,
141    brought: SIDES.map((side, at) => (kept[at] ?? 0) + brought[side]),
142  }
143}
144
145/** The quarter of the day it is where the person is. */
146function daypartNow(): Daypart {
147  return daypartOf(new Date(clockNow + utcOffset * 60_000).getUTCHours())
148}
149// Pet Quest's stage in play, as Pet Run's course.
150let quest: Quest | null = null
151let questTicker: { cancel: () => void } | null = null
152const questView = atom({ plugin: 'pets', key: 'quest' } as const, { stage: 0, phase: 'ready', score: 0, snacks: 0 } as QuestView)
153
154/** The species of the pet that is out, in its shiny colors when it is one. */
155function speciesOf(who: PetProfile): Species {
156  const kind = SPECIES[who.species] ?? SPECIES[DEFAULT_SPECIES]!
157
158  return who.pets[who.species]?.shiny === true ? { ...kind, ink: { ...kind.ink, ...kind.shiny } } : kind
159}
160
161/** The pet that is out: a species never chosen before starts from nothing. */
162function statsOf(who: PetProfile): PetStats {
163  return who.pets[who.species] ?? UNMET
164}
165
166function withStats(who: PetProfile, change: (stats: PetStats) => PetStats): PetProfile {
167  return { ...who, pets: { ...who.pets, [who.species]: change(statsOf(who)) } }
168}
169
170function calledOf(who: PetProfile): string {
171  const { name } = statsOf(who)
172
173  return name === '' ? `The ${speciesOf(who).label}` : name
174}
175
176/** `초코 the cat`, or `The cat` while it has no name. */
177function titleOf(who: PetProfile): string {
178  const { name, shiny } = statsOf(who)
179  const label = `${shiny ? 'shiny ' : ''}${speciesOf(who).label}`
180
181  return name === '' ? `The ${label}` : `${name} the ${label}`
182}
183
184/**
185 * A tool call is 1, a pat 2, a finished turn 5, a game snack 1, every thousand output tokens 1,
186 * and whatever luck brought (gifts, lucky pats).
187 * Output only: input and cache reads grow with the conversation's length, not with the work done.
188 */
189function xpOf(stats: PetStats): number {
190  return (
191    stats.tools +
192    stats.turns * XP_PER_TURN +
193    stats.pats * XP_PER_PAT +
194    Math.floor(stats.tokens / TOKENS_PER_XP) +
195    stats.snacks * XP_PER_SNACK +
196    stats.bonus
197  )
198}
199
200/**
201 * Level 1 at birth, level `n + 1` once it has `XP_CURVE * n²` experience.
202 * Quadratic, so an evening reaches level 5 and a year of heavy use stays in the low hundreds.
203 */
204function levelOf(stats: PetStats): number {
205  return 1 + Math.floor(Math.sqrt(xpOf(stats) / XP_CURVE))
206}
207
208/** How far it is from this level to the next, 0 to 1. */
209function progressOf(stats: PetStats): number {
210  const level = levelOf(stats)
211  const from = XP_CURVE * (level - 1) ** 2
212  const to = XP_CURVE * level ** 2
213
214  return (xpOf(stats) - from) / (to - from)
215}
216
217function count(value: unknown): number {
218  return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0
219}
220
221function isRecord(value: unknown): value is Record<string, unknown> {
222  return typeof value === 'object' && value !== null && !Array.isArray(value)
223}
224
225function toStats(fields: Record<string, unknown>): PetStats {
226  return {
227    name: typeof fields.name === 'string' ? cleanName(fields.name) : '',
228    pats: count(fields.pats),
229    tools: count(fields.tools),
230    turns: count(fields.turns),
231    tokens: count(fields.tokens),
232    snacks: count(fields.snacks),
233    best: count(fields.best),
234    cleared: count(fields.cleared),
235    gifts: count(fields.gifts),
236    bonus: count(fields.bonus),
237    shiny: fields.shiny === true,
238    hours: DAYPARTS.map((_, at) => count(Array.isArray(fields.hours) ? fields.hours[at] : 0)),
239    form: typeof fields.form === 'string' && isForm(fields.form) ? fields.form : '',
240    leaning:
241      Array.isArray(fields.leaning) && fields.leaning.length === SIDES.length && fields.leaning.every(share => typeof share === 'number' && share >= 0 && share <= 1)
242        ? (fields.leaning as number[])
243        : [],
244    day: typeof fields.day === 'string' && /^\d{4}-\d\d-\d\d$/.test(fields.day) ? fields.day : '',
245    brought: SIDES.map((_, at) => (Array.isArray(fields.brought) && typeof fields.brought[at] === 'number' && fields.brought[at] >= 0 ? fields.brought[at] : 0)),
246  }
247}
248
249/** What `$.store` holds may be from another version, or nothing: read it field by field. */
250function toProfile(kept: unknown): PetProfile {
251  if (!isRecord(kept)) {
252    return STRANGER
253  }
254
255  const species = typeof kept.species === 'string' && kept.species in SPECIES ? kept.species : DEFAULT_SPECIES
256  const size = isSize(kept.size) ? kept.size : DEFAULT_SIZE
257
258  // 0.1.0 before per-pet levels kept one pet's fields beside `species`.
259  if (!isRecord(kept.pets)) {
260    return { species, size, pets: { [species]: toStats(kept) } }
261  }
262
263  const pets: Record<string, PetStats> = {}
264
265  for (const [kind, fields] of Object.entries(kept.pets)) {
266    if (kind in SPECIES && isRecord(fields)) {
267      pets[kind] = toStats(fields)
268    }
269  }
270
271  return { species, size, pets }
272}
273
274function isSize(value: unknown): value is PetSize {
275  return typeof value === 'string' && (SIZES as readonly string[]).includes(value)
276}
277
278function compact(tokens: number): string {
279  if (tokens >= 1e9) {
280    return `${(tokens / 1e9).toFixed(1)}B`
281  }
282  if (tokens >= 1e6) {
283    return `${(tokens / 1e6).toFixed(1)}M`
284  }
285
286  return tokens >= 1e3 ? `${(tokens / 1e3).toFixed(1)}K` : String(tokens)
287}
288
289function statusOf(who: PetProfile): string {
290  const stats = statsOf(who)
291  const level = levelOf(stats)
292  const others = Object.entries(who.pets)
293    .filter(([kind]) => kind !== who.species)
294    .map(([kind, other]) => `${other.name === '' ? kind : `${other.name} the ${kind}`} Lv ${levelOf(other)}`)
295
296  return [
297    `${titleOf(who)} · Lv ${level} · ${xpOf(stats)} xp (next at ${XP_CURVE * level * level})`,
298    `${stats.pats} pats, ${stats.turns} turns, ${stats.tools} tool calls, ${compact(stats.tokens)} output tokens`,
299    ...(stats.best === 0 ? [] : [`Pet Run best ${stats.best}`]),
300    ...(stats.cleared === 0 ? [] : [`Pet Quest ${stats.cleared}/${STAGES.length} stages cleared`]),
301    // Snacks from both games, one count.
302    ...(stats.snacks === 0 ? [] : [`${stats.snacks} snacks`]),
303    ...(stats.gifts === 0 ? [] : [`${stats.gifts} gifts found`]),
304    `${natureLine(stats)}`,
305    ...(others.length === 0 ? [] : [`Also: ${others.join(', ')}`]),
306  ].join(' · ')
307}
308
309/** Its nature, and its rhythm once it shows: `worker · night owl`. */
310function natureLine(stats: PetStats): string {
311  const rhythm = rhythmOf(stats.hours)
312
313  return `${natureOf(stats)}${rhythm === null ? '' : ` · ${rhythm}`}`
314}
315
316/** What a typed name is kept as: one line, no control characters, at most `NAME_LIMIT` characters. */
317function cleanName(typed: string): string {
318  return [...typed.replace(/\p{Cc}/gu, ' ').trim()].slice(0, NAME_LIMIT).join('')
319}
320
321function act(one: Pet, mood: Pet['mood'], hold: number): Pet {
322  return { ...one, mood, hold, idle: 0 }
323}
324
325/** One tick: a held mood runs out, a quiet session naps, else it paces. */
326/**
327 * One tick, by its nature: a scholar ambles and stops to read, a gamer runs, a sweetie
328 * pauses for a heart. Pauses keep counting toward its nap.
329 */
330function step(one: Pet, nature: Nature = 'curious'): Pet {
331  const frame = one.frame + 1
332
333  if (one.hold > 1) {
334    return { ...one, frame, hold: one.hold - 1 }
335  }
336  if (one.hold === 1) {
337    return { ...one, frame, hold: 0, mood: 'walk' }
338  }
339  if (one.mood === 'sleep') {
340    return { ...one, frame }
341  }
342  if (one.idle >= NAP_AFTER) {
343    return { ...one, frame, mood: 'sleep' }
344  }
345
346  const idle = one.idle + 1
347  if (nature === 'scholar' && frame % 40 === 0) {
348    return { ...one, frame, mood: 'work', hold: 6, idle }
349  }
350  if (nature === 'sweetie' && frame % 30 === 0) {
351    return { ...one, frame, mood: 'love', hold: 4, idle }
352  }
353  const pace = nature === 'scholar' ? (frame % 2 === 0 ? 1 : 0) : nature === 'gamer' && frame % 3 === 0 ? 2 : 1
354  const x = Math.min(STEPS, Math.max(0, one.x + one.dir * pace))
355  const dir = x === 0 ? 1 : x === STEPS ? -1 : one.dir
356
357  return { ...one, frame, x, dir, mood: 'walk', idle }
358}
359
360function saysOf(one: Pet, who: PetProfile, isSad: boolean): string {
361  const beat = one.frame % 2
362
363  switch (one.mood) {
364    case 'sleep':
365      return beat === 0 ? 'z' : 'z Z'
366    case 'happy':
367      return 'nice work!'
368    case 'work':
369      return beat === 0 ? '. .' : '. . .'
370    case 'love':
371      return `${speciesOf(who).purr} ×${statsOf(who).pats}`
372    case 'gift':
373      return 'a gift!'
374    case 'walk': {
375      if (isSad) {
376        return 'limits are close…'
377      }
378      // Now and then it muses, by its nature and the hour.
379      return one.frame % 40 < 6 && one.frame > 0 ? musingOf(natureOf(statsOf(who)), daypartNow(), Math.floor(one.frame / 40)) : ''
380    }
381  }
382}
383
384/** The bar beside the level: filled cells for the way to the next one. */
385function barOf(progress: number, cells = BAR_CELLS): [string, string] {
386  const filled = Math.min(cells, Math.max(0, Math.floor(progress * cells)))
387
388  return ['━'.repeat(filled), '━'.repeat(cells - filled)]
389}
390
391/** What it has lived through, as the stats rows show it: label, count, and what one is worth. */
392function tallyOf(stats: PetStats): [string, string, string][] {
393  return [
394    ['tool calls', String(stats.tools), '+1'],
395    ['turns', String(stats.turns), `+${XP_PER_TURN}`],
396    ['pats', String(stats.pats), `+${XP_PER_PAT}`],
397    ['output tokens', compact(stats.tokens), '1K +1'],
398  ]
399}
400
401/** The same in one line, for the SVG card. */
402function tallyLine(stats: PetStats): string {
403  return `${stats.tools} tools · ${stats.turns} turns · ${stats.pats} pats · ${compact(stats.tokens)} tokens`
404}
405
406/**
407 * Opens the pane, asking for the rows its size draws: the yard and the line above it.
408 * Resolves to what the reply should add: nothing once drawn, else why it waits undrawn
409 * (a surface that places no panes), so `/pet` never claims a pet nobody can see.
410 */
411async function openPane($: EngineInterface, who: PetProfile): Promise<string> {
412  const opened = await $.ui.open({ id: PANE, title: TITLE, rows: terminalRows(speciesOf(who), who.size) + 1 })
413
414  return opened.isPlaced ? '' : ` The pane is not on screen: ${opened.reason}`
415}
416
417/**
418 * Applies `change` to the pet that is out and keeps the result, with the species and size
419 * this session chose, across sessions.
420 * It reads the store first, so what another session saved meanwhile is added to, not overwritten.
421 */
422async function grow($: EngineInterface, change: (stats: PetStats) => PetStats): Promise<PetProfile> {
423  const { species, size } = await read($, profile)
424  // The form is settled the first time it is seen at Lv 40: its nature then, or by luck the rare one.
425  const settle = (stats: PetStats): PetStats =>
426    stats.form === '' && levelOf(stats) >= STAGE_LEVELS.adult
427      ? { ...stats, form: isLuckOn && Math.random() < RARE_CHANCE ? 'rare' : natureOf(stats) }
428      : stats
429  const now = withStats({ ...toProfile(await $.store.get(PROFILE_KEY)), species, size }, stats => settle(change(stats)))
430  await $.store.set(PROFILE_KEY, now)
431  await update($, profile, () => now)
432
433  return now
434}
435
436/**
437 * Grows the pet that is out and tallies what the change brought into today's leaning, as it is
438 * kept: pats, game snacks and turns each count the moment they are saved, so none is missed
439 * between turns or sessions. An adult that follows a new day's leaning says so.
440 */
441async function growTallied($: EngineInterface, change: (stats: PetStats) => PetStats, brought: Partial<Record<Side, number>>): Promise<PetProfile> {
442  let reformed = ''
443  const now = await grow($, stats => {
444    const tallied = tally(stats, { worker: 0, scholar: 0, sweetie: 0, gamer: 0, ...brought }, dayNow())
445    reformed = tallied.form !== stats.form ? tallied.form : ''
446
447    return change(tallied)
448  })
449  if (reformed !== '') {
450    await $.ui.toast(`${calledOf(now)} took after you: a ${reformed} ${speciesOf(now).label} now.`)
451  }
452
453  return now
454}
455
456/** The pet's own tick: a step by its nature. */
457async function tickPet($: EngineInterface): Promise<void> {
458  const nature = natureOf(statsOf(await read($, profile)))
459  clockNow = await $.clock.now()
460
461  await update($, pet, one => step(one, nature))
462}
463
464function viewOf(run: Run): RunView {
465  return { phase: run.phase, score: scoreOf(run), snacks: run.snacks }
466}
467
468/** Cells of the course for the terminal's Raster. */
469function runCells(run: Run, who: PetProfile): string {
470  return pack(toCells(paintRun(run, speciesOf(who), stageOf(levelOf(statsOf(who))), formOf(statsOf(who)))))
471}
472
473/**
474 * Keeps a finished (or abandoned) run: its snacks feed the pet, its score may be the best, and a
475 * level the snacks reach is announced like any other.
476 */
477async function keepRun($: EngineInterface, run: Run): Promise<PetProfile> {
478  const score = scoreOf(run)
479  const kept = await growTallied($, stats => ({ ...stats, snacks: stats.snacks + run.snacks, best: Math.max(stats.best, score) }), { gamer: run.snacks * XP_PER_SNACK })
480  await announce($, kept)
481
482  return kept
483}
484
485/**
486 * One tick of Pet Run: moves the course on, repaints it in place on the terminal, and
487 * updates the text when the score or the phase changes. A run that just ended is kept.
488 */
489async function tickRun($: EngineInterface): Promise<void> {
490  const run = course
491  if (run === null || run.phase !== 'running') {
492    return
493  }
494  const who = await read($, profile)
495  // Closed or started over while the profile was read: that course is no longer this one to move.
496  if (course !== run) {
497    return
498  }
499  const now = advance(run, speciesOf(who))
500  course = now
501
502  void $.ui.blit({ requestId: PLAY, key: 'run', cells: runCells(now, who) })
503  if (now.phase === 'over') {
504    await update($, runView, () => viewOf(now))
505    // Saved first: a toast that fails must not cost the run its snacks.
506    await keepRun($, now)
507    await $.ui.toast(`${calledOf(who)} ran ${scoreOf(now)} and ate ${now.snacks} snacks.`)
508  } else if (isRemote || now.tick % 10 === 0) {
509    await update($, runView, () => viewOf(now))
510  }
511}
512
513function questViewOf(stage: Quest): QuestView {
514  return { stage: stage.stage, phase: stage.phase, score: questScore(stage), snacks: stage.snacks }
515}
516
517function questCells(stage: Quest, who: PetProfile): string {
518  return pack(toCells(paintQuest(stage, speciesOf(who), stageOf(levelOf(statsOf(who))), formOf(statsOf(who)))))
519}
520
521/**
522 * Keeps what a stage gave: its snacks feed the pet, a cleared stage opens the next, and a level
523 * the snacks reach is announced like any other.
524 */
525async function keepQuest($: EngineInterface, stage: Quest): Promise<PetProfile> {
526  const kept = await growTallied(
527    $,
528    stats => ({
529      ...stats,
530      snacks: stats.snacks + stage.snacks,
531      cleared: stage.phase === 'clear' ? Math.max(stats.cleared, stage.stage + 1) : stats.cleared,
532    }),
533    { gamer: stage.snacks * XP_PER_SNACK },
534  )
535  await announce($, kept)
536
537  return kept
538}
539
540/**
541 * Keeps a run or a stage still going and sets it back to the start, before the pet that earned
542 * it stops being the one out.
543 */
544async function settleGames($: EngineInterface): Promise<void> {
545  const run = course
546  const stage = quest
547
548  if (run !== null && run.phase === 'running') {
549    course = newRun(run.width, Math.floor(Math.random() * 2 ** 31))
550    await update($, runView, () => viewOf(course ?? run))
551    await keepRun($, run)
552  }
553  if (stage !== null && stage.phase === 'running') {
554    quest = newQuest(stage.stage, stage.width)
555    await update($, questView, () => questViewOf(quest ?? stage))
556    await keepQuest($, stage)
557  }
558}
559
560/** One tick of Pet Quest, as `tickRun`: on, repainted in place, kept once it ends. */
561async function tickQuest($: EngineInterface): Promise<void> {
562  const stage = quest
563  if (stage === null || stage.phase !== 'running') {
564    return
565  }
566  const who = await read($, profile)
567  // Closed or started over while the profile was read: that stage is no longer this one to move.
568  if (quest !== stage) {
569    return
570  }
571  const now = advanceQuest(stage)
572  quest = now
573
574  void $.ui.blit({ requestId: QUEST, key: 'quest', cells: questCells(now, who) })
575  if (now.phase !== 'running') {
576    await update($, questView, () => questViewOf(now))
577    // As the run: saved before the toast.
578    await keepQuest($, now)
579    await $.ui.toast(
580      now.phase === 'clear'
581        ? `Stage ${now.stage + 1} clear! ${calledOf(who)} ate ${now.snacks} snacks.`
582        : `${calledOf(who)} will try stage ${now.stage + 1} again.`,
583    )
584  } else if (isRemote || now.tick % 10 === 0) {
585    await update($, questView, () => questViewOf(now))
586  }
587}
588
589/**
590 * Toasts a level reached since `shownLevel`, the level last seen: growing up and starting to shine
591 * say so. `shownLevel` moves before the toast is awaited, so a hook that runs meanwhile does not
592 * announce the same level again.
593 */
594async function announce($: EngineInterface, now: PetProfile): Promise<void> {
595  const level = levelOf(statsOf(now))
596  const shown = shownLevel
597  shownLevel = level
598
599  if (level > shown) {
600    const stage = stageOf(level)
601    const called = calledOf(now)
602
603    await $.ui.toast(
604      stage === stageOf(shown)
605        ? `${called} reached Lv ${level}!`
606        : stage === 'adult'
607          ? `${called} evolved into a ${formName(speciesOf(now), formOf(statsOf(now)))} ${speciesOf(now).label}! (Lv ${level})`
608          : `${called} grew into a teen! (Lv ${level})`,
609    )
610  }
611}
612
613export const register: Register = (on, options) => {
614  // Off, nothing is left to chance: no gifts, no lucky pats, no shiny pets, no rare forms.
615  const hasLuck = options.luck !== false
616  isLuckOn = hasLuck
617  const lucky = (chance: number) => hasLuck && Math.random() < chance
618
619  // Tool calls since the last save: the store is written once a turn, not on every call.
620  let unsavedTools = 0
621
622  on('session.start', async ($, e, next) => {
623    await $.command.register({
624      name: 'pet',
625      description: 'Let your pixel pet out',
626      argumentHint: `[${VERBS.map(form => form.split(' ')[0]).join('|')}]`,
627    })
628    const kept = toProfile(await $.store.get(PROFILE_KEY))
629    await update($, profile, () => kept)
630    // The pet out is met here when nothing was kept of it yet (the very first cat), so it gets the
631    // same one roll at being shiny that `/pet choose` gives a pet met for the first time.
632    const met = kept.species in kept.pets ? kept : await grow($, stats => (lucky(SHINY_CHANCE) ? { ...stats, shiny: true } : stats))
633    shownLevel = levelOf(statsOf(met))
634    try {
635      const { exitCode, stdout } = await $.process.run(['date', '+%z'], { timeoutMs: 2000 })
636      const offset = /^([+-])(\d\d)(\d\d)/.exec(stdout.trim())
637      if (exitCode === 0 && offset !== null) {
638        utcOffset = (offset[1] === '-' ? -1 : 1) * (Number(offset[2]) * 60 + Number(offset[3]))
639      }
640    } catch {
641      // No `date` (Windows): the hours are UTC's.
642    }
643    $.clock.every(TICK_MS, () => void tickPet($))
644
645    return next(e)
646  })
647
648  on('command.run', { command: 'pet' }, async ($, e) => {
649    const [verb = '', ...rest] = e.args.trim().split(/\s+/)
650    const who = await read($, profile)
651    const called = calledOf(who)
652
653    switch (verb) {
654      case '':
655        return { text: `${called} is out.${await openPane($, who)}` }
656      case 'pat': {
657        const isLucky = lucky(LUCKY_PAT_CHANCE)
658        await update($, pet, one => act(one, 'love', 8))
659        const now = await growTallied($, stats => ({ ...stats, pats: stats.pats + 1, bonus: stats.bonus + (isLucky ? XP_PER_PAT * 2 : 0) }), { sweetie: XP_PER_PAT })
660        await announce($, now)
661
662        return { text: `${called} is pleased.${isLucky ? ` Lucky pat! +${XP_PER_PAT * 3} xp` : ''}${await openPane($, now)}` }
663      }
664      case 'name': {
665        const given = cleanName(rest.join(' '))
666
667        if (given === '') {
668          return { text: `${called} is listening. Name it with /pet name <name>.` }
669        }
670
671        const unseen = await openPane($, await grow($, stats => ({ ...stats, name: given })))
672
673        return { text: `${called} is now ${given}.${unseen}` }
674      }
675      case 'choose': {
676        const wanted = rest[0] ?? ''
677
678        if (!(wanted in SPECIES)) {
679          return { text: `Choose one of: ${Object.keys(SPECIES).join(', ')}` }
680        }
681
682        // What a game in progress earned belongs to the pet that played it.
683        await settleGames($)
684        // A pet met for the first time may be a rare shiny.
685        const isNew = !(wanted in who.pets)
686        const isShiny = isNew && lucky(SHINY_CHANCE)
687        await update($, profile, last => ({ ...last, species: wanted }))
688        const now = await grow($, stats => (isShiny ? { ...stats, shiny: true } : stats))
689        shownLevel = levelOf(statsOf(now))
690
691        return { text: `${calledOf(now)} is out.${isShiny ? ` A shiny ${wanted}! It is rare: one in ${Math.round(1 / SHINY_CHANCE)}.` : ''}${await openPane($, now)}` }
692      }
693      case 'size': {
694        const wanted = rest[0] ?? ''
695
696        if (!isSize(wanted)) {
697          return { text: `${called} is ${who.size}. Choose one of: ${SIZES.join(', ')}` }
698        }
699
700        await update($, profile, last => ({ ...last, size: wanted }))
701        const unseen = await openPane($, await grow($, stats => stats))
702
703        return { text: `${called} is ${wanted} now.${unseen}` }
704      }
705      case 'status':
706        return { text: statusOf(who) }
707      case 'play': {
708        // A run still going is kept before a new one takes its place, as closing the pane would.
709        const going = course
710        ticker?.cancel()
711        ticker = null
712        course = newRun(course?.width ?? RUN_MIN, Math.floor(Math.random() * 2 ** 31))
713        if (going !== null && going.phase === 'running') {
714          await keepRun($, going)
715        }
716        isRemote = (await $.session.surfaces()).some(surface => surface !== 'terminal')
717        await update($, runView, () => viewOf(course ?? newRun(RUN_MIN)))
718        const opened = await $.ui.open({ id: PLAY, title: PLAY_TITLE, rows: RUN_HEIGHT / 2 + 2, focus: true, closeOnEscape: true })
719        // Only a placed pane can be closed, and closing is what stops the clock.
720        if (opened.isPlaced) {
721          ticker = $.clock.every(RUN_TICK_MS, () => void tickRun($))
722        }
723
724        return {
725          text: opened.isPlaced
726            ? `${called} is ready to run: j to jump, r to start over, Esc to stop.`
727            : `${called} is ready to run, but the pane is not on screen: ${opened.reason}`,
728        }
729      }
730      case 'quest': {
731        const cleared = statsOf(who).cleared
732        const open = Math.min(cleared, STAGES.length - 1)
733        const asked = rest[0] === undefined ? open + 1 : Number(rest[0])
734
735        if (!Number.isInteger(asked) || asked < 1 || asked > open + 1) {
736          return { text: `Stages open: 1 to ${open + 1} of ${STAGES.length}.` }
737        }
738        // A stage still going is kept before another takes its place, as closing the pane would.
739        const going = quest
740        questTicker?.cancel()
741        questTicker = null
742        quest = newQuest(asked - 1, quest?.width ?? RUN_MIN)
743        if (going !== null && going.phase === 'running') {
744          await keepQuest($, going)
745        }
746        isRemote = (await $.session.surfaces()).some(surface => surface !== 'terminal')
747        await update($, questView, () => questViewOf(quest ?? newQuest(0, RUN_MIN)))
748        const opened = await $.ui.open({ id: QUEST, title: QUEST_TITLE, rows: QUEST_HEIGHT / 2 + 2, focus: true, closeOnEscape: true })
749        // Only a placed pane can be closed, and closing is what stops the clock.
750        if (opened.isPlaced) {
751          questTicker = $.clock.every(QUEST_TICK_MS, () => void tickQuest($))
752        }
753
754        return {
755          text: opened.isPlaced
756            ? `Stage ${asked}: j to start and jump (again while rising to go higher), r to retry, n for the next stage, Esc to stop.`
757            : `Stage ${asked} is ready, but the pane is not on screen: ${opened.reason}`,
758        }
759      }
760      case 'bye':
761        await $.ui.close({ id: PANE })
762
763        return { text: `${called} went back inside.` }
764      default:
765        return { text: `Usage: ${['/pet', ...VERBS.map(form => `/pet ${form}`)].join(', ')}` }
766    }
767  })
768
769  on('tool.call', async ($, e, next) => {
770    unsavedTools += 1
771    await update($, pet, one => act(one, 'work', 8))
772    await update($, profile, who => withStats(who, stats => ({ ...stats, tools: stats.tools + 1 })))
773
774    return next(e)
775  })
776
777  // A subagent's turn adds its tokens; only the main conversation's counts as a turn.
778  on('turn.complete', async ($, e, next) => {
779    const isMain = e.agentId === undefined
780    const tools = unsavedTools
781    const tokens = e.usage?.output_tokens ?? 0
782    unsavedTools = 0
783
784    // Now and then a finished turn of the main conversation turns up a gift.
785    const gift = isMain && lucky(GIFT_CHANCE) ? giftOf(Math.random()) : null
786    const part = daypartNow()
787    let worth = { xp: 0, makesShiny: false }
788    // The turn adds its tools and its own work to the day's tally (pats and snacks count as they are kept).
789    const now = await growTallied(
790      $,
791      stats => {
792        worth = gift === null ? worth : worthOf(gift, stats.shiny)
793
794        return {
795          ...stats,
796          tools: stats.tools + tools,
797          turns: stats.turns + (isMain ? 1 : 0),
798          tokens: stats.tokens + tokens,
799          gifts: stats.gifts + (gift === null ? 0 : 1),
800          bonus: stats.bonus + worth.xp,
801          shiny: stats.shiny || worth.makesShiny,
802          hours: isMain ? stats.hours.map((count, at) => (DAYPARTS[at] === part ? count + 1 : count)) : stats.hours,
803        }
804      },
805      { worker: tools, scholar: (isMain ? XP_PER_TURN : 0) + tokens / TOKENS_PER_XP },
806    )
807
808    if (gift !== null) {
809      await update($, pet, one => act(one, 'gift', 12))
810      await $.ui.toast(
811        worth.makesShiny
812          ? `${calledOf(now)} found a sparkle stone and turned shiny!`
813          : `${calledOf(now)} found ${gift.kind === 'xp' ? gift.name : 'another sparkle stone'}! +${worth.xp} xp`,
814      )
815    } else if (isMain) {
816      await update($, pet, one => act(one, 'happy', 10))
817    }
818    await announce($, now)
819
820    return next(e)
821  })
822
823  // A rate-limit window running out is the one thing that makes it cry.
824  on('session.measure', async ($, e, next) => {
825    const isClose = e.rateLimits.some(limit => limit.percentUsed > WORRY_PERCENT)
826    await update($, isWorried, () => isClose)
827
828    return next(e)
829  })
830
831  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
832    const [one, who, isSad] = await Promise.all([read($, pet), read($, profile), read($, isWorried)])
833    const kind = speciesOf(who)
834    const stats = statsOf(who)
835    const says = saysOf(one, who, isSad)
836    const { unit } = lookOf(who.size)
837    const fewest = minWidth(kind, who.size)
838
839    if (e.surface === 'terminal') {
840      const { Box, Text, Raster } = $.ui.resolve(e)
841      // Stats go beside the yard above a wide prompt, under it in the tall, narrow dock,
842      // and nowhere when neither has room: then the level line carries them.
843      const isDocked = e.props.placement === 'dock'
844      const isBeside = !isDocked && e.props.bodyColumns >= fewest + PANEL + 4
845      const room = isBeside ? e.props.bodyColumns - PANEL - 4 : e.props.bodyColumns - 2
846      const columns = Math.max(fewest, Math.min(MAX_YARD, room))
847      const scene = paint(one, kind, who.size, columns, STEPS, { isSad, stage: stageOf(levelOf(stats)), form: formOf(stats), nature: natureOf(stats), daypart: daypartNow(), leaning: yardLeaning(stats) })
848      const cells = toCells(scene.pixels)
849      const width = cells[0]?.length ?? 0
850      const level = levelOf(stats)
851      const stage = stageOf(level)
852      const hasPanel = isDocked || isBeside
853      const progress = progressOf(stats)
854      const [filled, empty] = barOf(progress)
855      const [wide, rest] = barOf(progress, PANEL_BAR)
856      const yard = <Raster key="pet" columns={width} rows={cells.length} cells={pack(cells)} />
857      const panel = (
858        <Box flexDirection="column" width={PANEL} paddingLeft={isBeside ? 2 : 0}>
859          <Text color={PASTEL.gray} dimColor>{`${kind.label.toUpperCase()}${stats.shiny ? ' ✦' : ''}${stage === 'baby' ? '' : stage === 'teen' ? ' · TEEN' : ` · ${formName(kind, formOf(stats)).toUpperCase()}`}`}</Text>
860          <Box>
861            <Text color={PASTEL.pink} bold>{`Lv ${level}`}</Text>
862            <Text color={PASTEL.gray}>{`  ${xpOf(stats)} / ${XP_CURVE * level * level} xp`}</Text>
863          </Box>
864          <Box>
865            <Text color={PASTEL.pink}>{wide}</Text>
866            <Text color={PASTEL.dim}>{rest}</Text>
867          </Box>
868          {who.size !== 'small' && <Text color={PASTEL.pink} dimColor>{natureLine(stats)}</Text>}
869          {who.size === 'small' ? (
870            <>
871              <Text color={PASTEL.gray}>{`${stats.tools} tools · ${stats.turns} turns`}</Text>
872              <Text color={PASTEL.gray}>{`${stats.pats} pats · ${compact(stats.tokens)} tokens`}</Text>
873            </>
874          ) : (
875            tallyOf(stats).map(([label, value, worth]) => (
876              <Box key={label}>
877                <Text color={PASTEL.gray}>{label.padEnd(14)}</Text>
878                <Text>{value.padStart(6)}</Text>
879                <Text color={PASTEL.green}>{`  ${worth}`}</Text>
880              </Box>
881            ))
882          )}
883        </Box>
884      )
885
886      return (
887        <Box flexDirection="column">
888          <Box>
889            {stats.name !== '' && (
890              <Text backgroundColor={PASTEL.pink} color={PASTEL.ink} bold>
891                {` ${stats.name} `}
892              </Text>
893            )}
894            {!hasPanel && <Text color={PASTEL.gray}>{` Lv ${level} `}</Text>}
895            {!hasPanel && <Text color={PASTEL.pink}>{filled}</Text>}
896            {!hasPanel && <Text color={PASTEL.dim}>{empty}</Text>}
897            {says !== '' && <Text color={PASTEL.yellow}>{`  ${says}`}</Text>}
898          </Box>
899          {isBeside ? (
900            <Box>
901              {yard}
902              {panel}
903            </Box>
904          ) : (
905            yard
906          )}
907          {isDocked && <Box marginTop={1}>{panel}</Box>}
908        </Box>
909      )
910    }
911
912    // Desktop, VS Code and mobile draw an SVG card: half blocks in a proportional line
913    // height leave seams between rows, and an SVG scales to the slot's width.
914    const { Svg } = $.ui.resolve(e)
915    const tally = tallyLine(stats)
916    const across = Math.max(fewest, cardWidthFor(who.size, tally), Math.min(MAX_CARD, Math.floor((e.props.bodyColumns * CELL_PX) / unit) - 4))
917    const scene = paint(one, kind, who.size, across, STEPS, { isSad, withGrass: false, stage: stageOf(levelOf(stats)), form: formOf(stats), nature: natureOf(stats), daypart: daypartNow(), leaning: yardLeaning(stats) })
918    const caption = { name: stats.name, level: levelOf(stats), progress: progressOf(stats), says, tally, daypart: daypartNow() }
919    const alt = `${titleOf(who)}, Lv ${levelOf(stats)}${says === '' ? '' : `: ${says}`}`
920
921    return <Svg source={toSvg(scene, who.size, caption)} alt={alt} />
922  })
923
924  // Closing Pet Run stops its clock; a run still going is kept as it stands.
925  on('ui.close', { id: PLAY }, async ($, e, next) => {
926    ticker?.cancel()
927    ticker = null
928    if (course !== null && course.phase === 'running') {
929      await keepRun($, course)
930    }
931    course = null
932
933    return next(e)
934  })
935
936  on('ui.render', { component: 'Pane', requestId: PLAY }, async ($, e) => {
937    const who = await read($, profile)
938    const view = await read($, runView)
939    const fits = Math.max(RUN_MIN, Math.min(RUN_MAX, e.props.bodyColumns - 2))
940
941    // A course not yet started takes the pane's width; one under way keeps its own.
942    if (course === null || (course.phase === 'ready' && course.width !== fits)) {
943      course = newRun(fits, course?.seed ?? 1)
944    }
945    const run = course
946    // A run just over counts before it is kept, so both surfaces show the new best at once.
947    const best = Math.max(statsOf(who).best, view.phase === 'over' ? view.score : 0)
948    const hint = view.phase === 'ready' ? 'press j to start' : view.phase === 'over' ? 'ouch! r to run again' : ''
949    const onJump = () => {
950      if (course !== null) {
951        const before = course.phase
952        course = jump(course)
953        if (course.phase !== before) {
954          void update($, runView, () => viewOf(course ?? run))
955        }
956      }
957    }
958    const onRestart = () => {
959      if (course !== null && course.phase !== 'running') {
960        course = newRun(course.width, Math.floor(Math.random() * 2 ** 31))
961        void update($, runView, () => viewOf(course ?? run))
962      }
963    }
964    if (e.surface === 'terminal') {
965      const { Box, Text, Raster, Button } = $.ui.resolve(e)
966
967      return (
968        <Box flexDirection="column">
969          <Box>
970            <Text color={PASTEL.pink} bold>{`${calledOf(who)} `}</Text>
971            <Text color={PASTEL.gray}>{`score ${view.score}  best ${best}  snacks ${view.snacks}`}</Text>
972            {hint !== '' && <Text color={PASTEL.yellow}>{`  ${hint}`}</Text>}
973          </Box>
974          <Raster key="run" columns={run.width} rows={RUN_HEIGHT / 2} cells={runCells(run, who)} />
975          <Box gap={1}>
976            <Button key="jump" hotkey="j" plain onPress={onJump}>Jump</Button>
977            <Button key="restart" hotkey="r" plain onPress={onRestart}>Again</Button>
978          </Box>
979        </Box>
980      )
981    }
982
983    const { Box, Text, Svg, Button } = $.ui.resolve(e)
984
985    return (
986      <Box flexDirection="column">
987        <Text>{`${calledOf(who)} · score ${view.score} · best ${best} · snacks ${view.snacks}${hint === '' ? '' : ` · ${hint}`}`}</Text>
988        <Svg source={pixelsSvg(paintRun(run, speciesOf(who), stageOf(levelOf(statsOf(who))), formOf(statsOf(who))), 6)} alt={`Pet Run, score ${view.score}`} />
989        <Box gap={1}>
990          <Button key="jump" hotkey="j" onPress={onJump}>Jump</Button>
991          <Button key="restart" hotkey="r" onPress={onRestart}>Again</Button>
992        </Box>
993      </Box>
994    )
995  })
996
997  on('ui.close', { id: QUEST }, async ($, e, next) => {
998    questTicker?.cancel()
999    questTicker = null
1000    if (quest !== null && quest.phase === 'running') {
1001      await keepQuest($, quest)
1002    }
1003    quest = null
1004
1005    return next(e)
1006  })
1007
1008  on('ui.render', { component: 'Pane', requestId: QUEST }, async ($, e) => {
1009    const who = await read($, profile)
1010    const view = await read($, questView)
1011    const fits = Math.max(RUN_MIN, Math.min(RUN_MAX, e.props.bodyColumns - 2))
1012
1013    if (quest === null || (quest.phase === 'ready' && quest.width !== fits)) {
1014      quest = newQuest(quest?.stage ?? view.stage, fits)
1015    }
1016    const stage = quest
1017    const hasNext = view.phase === 'clear' && stage.stage + 1 < STAGES.length
1018    const hint =
1019      view.phase === 'ready'
1020        ? 'press j to start'
1021        : view.phase === 'over'
1022          ? 'oops! r to retry'
1023          : view.phase === 'clear'
1024            ? hasNext
1025              ? 'clear! n for the next stage'
1026              : 'all stages clear!'
1027            : ''
1028    const onJump = () => {
1029      if (quest !== null) {
1030        const before = quest.phase
1031        quest = jumpQuest(quest)
1032        if (quest.phase !== before) {
1033          void update($, questView, () => questViewOf(quest ?? stage))
1034        }
1035      }
1036    }
1037    const restart = (index: number) => {
1038      if (quest !== null && quest.phase !== 'running') {
1039        quest = newQuest(index, quest.width)
1040        void update($, questView, () => questViewOf(quest ?? stage))
1041      }
1042    }
1043    const onRetry = () => restart(quest?.stage ?? 0)
1044    const onNext = () => {
1045      if (quest !== null && quest.phase === 'clear' && quest.stage + 1 < STAGES.length) {
1046        restart(quest.stage + 1)
1047      }
1048    }
1049    const line = `stage ${view.stage + 1}/${STAGES.length}  score ${view.score}  snacks ${view.snacks}`
1050
1051    if (e.surface === 'terminal') {
1052      const { Box, Text, Raster, Button } = $.ui.resolve(e)
1053
1054      return (
1055        <Box flexDirection="column">
1056          <Box>
1057            <Text color={PASTEL.pink} bold>{`${calledOf(who)} `}</Text>
1058            <Text color={PASTEL.gray}>{line}</Text>
1059            {hint !== '' && <Text color={PASTEL.yellow}>{`  ${hint}`}</Text>}
1060          </Box>
1061          <Raster key="quest" columns={stage.width} rows={QUEST_HEIGHT / 2} cells={questCells(stage, who)} />
1062          <Box gap={1}>
1063            <Button key="jump" hotkey="j" plain onPress={onJump}>Jump</Button>
1064            <Button key="retry" hotkey="r" plain onPress={onRetry}>Retry</Button>
1065            <Button key="next" hotkey="n" plain onPress={onNext}>Next</Button>
1066          </Box>
1067        </Box>
1068      )
1069    }
1070
1071    const { Box, Text, Svg, Button } = $.ui.resolve(e)
1072
1073    return (
1074      <Box flexDirection="column">
1075        <Text>{`${calledOf(who)} · ${line}${hint === '' ? '' : ` · ${hint}`}`}</Text>
1076        <Svg source={pixelsSvg(paintQuest(stage, speciesOf(who), stageOf(levelOf(statsOf(who))), formOf(statsOf(who))), 5)} alt={`Pet Quest stage ${view.stage + 1}`} />
1077        <Box gap={1}>
1078          <Button key="jump" hotkey="j" onPress={onJump}>Jump</Button>
1079          <Button key="retry" hotkey="r" onPress={onRetry}>Retry</Button>
1080          <Button key="next" hotkey="n" onPress={onNext}>Next</Button>
1081        </Box>
1082      </Box>
1083    )
1084  })
1085}
1086
hooks/scene.ts 718 lines
1/**
2 * The yard as pixels, and the two ways it is drawn: half-block cells on the terminal,
3 * an SVG card on the surfaces that draw `Svg` (desktop, VS Code, mobile).
4 *
5 * Pure: no `$`, so a test's render is exactly what the pane draws.
6 *
7 * @handbook 4.2-pixel-pipeline
8 * @handbook 4.3-surface-branch
9 * @tested tests/pet.test.ts
10 */
11import type { Pet } from '../types'
12import type { Daypart, Nature } from './nature'
13import type { Accessory, Head, Species, Sprite } from './species'
14
15/** The four sides of a nature, in the order `PetStats.leaning` keeps them. Here, not in nature.ts, so scene.ts imports nothing at run time and scripts can load it. */
16export const SIDES = ['worker', 'scholar', 'sweetie', 'gamer'] as const
17export type Side = (typeof SIDES)[number]
18
19export type Size = 'small' | 'medium'
20
21export const SIZES: readonly Size[] = ['small', 'medium']
22export const DEFAULT_SIZE: Size = 'medium'
23
24/** How far a pet has grown: a baby, a teen with its accessory, an adult in its form. */
25export type Stage = 'baby' | 'teen' | 'adult'
26
27/** What an adult became at Lv 40: its nature then, or one time in twenty the rare form. */
28export type Form = Nature | 'rare'
29
30/** The levels each stage begins at. */
31export const STAGE_LEVELS = { teen: 15, adult: 40 } as const
32
33export function stageOf(level: number): Stage {
34  return level >= STAGE_LEVELS.adult ? 'adult' : level >= STAGE_LEVELS.teen ? 'teen' : 'baby'
35}
36
37type Look = {
38  sprite: 'mini' | 'big'
39  /** Pixel rows above the sprite: room to bounce and for the heart. */
40  headroom: number
41  /** Pixel rows of grass below it. */
42  ground: number
43  /** CSS pixels per sprite pixel on the surfaces that draw `Svg`. */
44  unit: number
45  /** The card's text size, in CSS pixels: what `cardWidthFor` measures and `toSvg` draws. */
46  font: number
47}
48
49const LOOKS: Readonly<Record<Size, Look>> = {
50  small: { sprite: 'mini', headroom: 1, ground: 1, unit: 6, font: 10 },
51  medium: { sprite: 'big', headroom: 2, ground: 2, unit: 8, font: 12 },
52}
53
54export const PALETTE = {
55  yellow: 0xffd787,
56  pink: 0xffafd7,
57  heart: 0xff87af,
58  sparkle: 0xffe98a,
59  grass: 0xafd7af,
60  tuft: 0xc9e8c4,
61  grassDeep: 0x8fc98f,
62  shadow: 0x6fa86f,
63  petal: 0xfff3dc,
64} as const
65
66const HEART = ['hh.hh', 'hhhhh', '.hhh.', '..h..'] as const
67/** The happy sparkle, and a star's twinkle. */
68const SPARKLE = ['.s.', 'sws', '.s.'] as const
69const GIFT = ['y.y..', '.y...', 'ppypp', 'ppypp', 'ppypp'] as const
70
71/** What each nature holds up while a tool runs. */
72const PROPS: Readonly<Record<Nature, { rows: readonly string[]; ink: Readonly<Record<string, number>> } | null>> = {
73  // An adult worker swings its pickaxe instead.
74  worker: { rows: ['kkkkk', 'kbbbk', 'kbbbk', 'kkkkk', '.kkk.'], ink: { k: 0x8a8a9a, b: 0x87d7ff } },
75  scholar: { rows: ['bbwbb', 'bbwbb', 'bbwbb'], ink: { b: 0x6fa8dc, w: 0xfffaf0 } },
76  sweetie: { rows: ['h.h', 'hhh', '.h.'], ink: { h: 0xff87af } },
77  gamer: { rows: ['.....', 'kkkkk', 'kgkrk', 'kkkkk'], ink: { k: 0x8a8a9a, g: 0x7cc576, r: 0xff6b8a } },
78  curious: null,
79}
80
81const SKY: Readonly<Record<Daypart, { rows: readonly string[]; ink: Readonly<Record<string, number>> }>> = {
82  night: { rows: ['.mm', 'm..', 'm..', '.mm'], ink: { m: 0xfff3b0 } },
83  morning: { rows: ['.s.', 'sss', '.s.'], ink: { s: 0xffc46b } },
84  day: { rows: ['.ss.', 'ssss', 'ssss', '.ss.'], ink: { s: 0xffe36e } },
85  evening: { rows: ['.ss.', 'ssss'], ink: { s: 0xff9a76 } },
86}
87const STARS = [3, 11, 19, 27, 35] as const
88
89type Prop = { rows: readonly string[]; ink: Readonly<Record<string, number>>; lift?: number }
90
91/**
92 * What fills the yard as a side of its nature grows, big and small: a building site, a
93 * library corner, a garden, an arcade. Each side's props, in the order they appear.
94 */
95const CONE = { o: 0xff7a2a, O: 0xd9531a, w: 0xfffaf0, W: 0xd8d4cc, k: 0x4a4a58 } as const
96
97const YARD: Readonly<Record<(typeof SIDES)[number], { big: readonly Prop[]; mini: readonly Prop[] }>> = {
98  worker: {
99    big: [
100      // A traffic cone: a narrow tip, two white bands, a dark square base.
101      { rows: ['...o...', '..ooO..', '..wwW..', '.oooOO.', '.wwwwW.', 'kkkkkkk'], ink: CONE },
102      { rows: ['wowow', 'wowow', 'k...k', 'k...k'], ink: { w: 0xfffaf0, o: 0xff8a3d, k: 0x8a8a9a } },
103      { rows: ['..gg..', '.gGgg.', 'gGgggG'], ink: { g: 0xb0b0c0, G: 0x80808f } },
104      { rows: ['yyyyy', 'ykyky', 'yyyyy', '..k..', '..k..'], ink: { y: 0xffd447, k: 0x5f5f5f } },
105    ],
106    mini: [
107      { rows: ['..o..', '.wwW.', '.ooO.', 'kkkkk'], ink: CONE },
108      { rows: ['.gg', 'gGg'], ink: { g: 0xb0b0c0, G: 0x80808f } },
109    ],
110  },
111  scholar: {
112    big: [
113      { rows: ['bbbbbb', 'brguyb', 'bbbbbb', 'byrgub', 'bbbbbb'], ink: { b: 0x8a5a3a, r: 0xe06b6b, g: 0x7cc576, u: 0x6fa8dc, y: 0xffd447 } },
114      { rows: ['.yyy.', 'yyyyy', '..k..', '..k..', '.kkk.'], ink: { y: 0xffe9a0, k: 0x8a8a9a } },
115      { rows: ['.ugu.', 'uggug', '.ugu.', '..k..', '.kkk.'], ink: { u: 0x6fa8dc, g: 0x7cc576, k: 0x8a8a9a } },
116      { rows: ['rrrr', 'gggg', 'uuuu'], ink: { r: 0xe06b6b, g: 0x7cc576, u: 0x6fa8dc } },
117    ],
118    mini: [
119      { rows: ['bbbb', 'brgb', 'bbbb'], ink: { b: 0x8a5a3a, r: 0xe06b6b, g: 0x7cc576 } },
120      { rows: ['yyy', '.k.', 'kkk'], ink: { y: 0xffe9a0, k: 0x8a8a9a } },
121    ],
122  },
123  sweetie: {
124    big: [
125      { rows: ['p.p', 'ppp', '.g.', 'gg.', '.g.'], ink: { p: 0xff87af, g: 0x7cc576 } },
126      { rows: ['gg.gg', 'gpgpg', 'ggggg', '.ggg.'], ink: { g: 0x6fbf6f, p: 0xff87af } },
127      { rows: ['.p.', 'pyp', '.p.', 'ttt', '.t.'], ink: { p: 0xffafd7, y: 0xffe36e, t: 0xd0805a } },
128      { rows: ['p.p', '.k.', 'p.p'], ink: { p: 0xffafd7, k: 0x5f4b4b }, lift: 6 },
129    ],
130    mini: [
131      { rows: ['p.p', 'ppp', '.g.'], ink: { p: 0xff87af, g: 0x7cc576 } },
132      { rows: ['.p.', 'pyp', '.g.'], ink: { p: 0xffafd7, y: 0xffe36e, g: 0x7cc576 } },
133    ],
134  },
135  gamer: {
136    big: [
137      { rows: ['kkkk', 'kuuk', 'kuuk', 'kkkk', 'krbk', 'kkkk'], ink: { k: 0x6a5aa8, u: 0x7fe0ff, r: 0xff6b8a, b: 0xffd447 } },
138      { rows: ['yyyyy', '.yyy.', '..y..', '.yyy.'], ink: { y: 0xffd447 } },
139      { rows: ['.y.', 'yYy', '.y.'], ink: { y: 0xffd447, Y: 0xfff3b0 }, lift: 5 },
140      { rows: ['.r.', '.k.', 'kkk'], ink: { r: 0xff6b8a, k: 0x6a5aa8 } },
141    ],
142    mini: [
143      { rows: ['kkk', 'kuk', 'krk'], ink: { k: 0x6a5aa8, u: 0x7fe0ff, r: 0xff6b8a } },
144      { rows: ['yyy', '.y.', 'yyy'], ink: { y: 0xffd447 } },
145    ],
146  },
147}
148
149/** What the grass turns toward as a side takes over: dirt, a wooden floor, lush green, an arcade carpet. */
150const GROUND_TINT: Readonly<Record<(typeof SIDES)[number], number>> = {
151  worker: 0xc9a27a,
152  scholar: 0xb58a64,
153  sweetie: 0x9fe08f,
154  gamer: 0x8f7fd0,
155}
156
157/** Where the largest of `values` is, or -1 when it is shared: a tie singles nothing out. */
158export function topOf(values: readonly number[]): number {
159  const best = Math.max(...values)
160  const at = values.indexOf(best)
161
162  return values.indexOf(best, at + 1) === -1 ? at : -1
163}
164
165function blend(from: number, to: number, amount: number): number {
166  const mix = (shift: number) => Math.round(((from >> shift) & 0xff) * (1 - amount) + ((to >> shift) & 0xff) * amount)
167
168  return (mix(16) << 16) | (mix(8) << 8) | mix(0)
169}
170
171/**
172 * How many props each side gets of `slots`, by its share of the leaning (largest remainder),
173 * so the yard fills one prop at a time as a side grows, and empties the same way as it fades.
174 */
175export function yardOf(leaning: readonly number[], slots: number): number[] {
176  const wanted = SIDES.map((_, at) => (leaning[at] ?? 0) * slots)
177  const counts = wanted.map(Math.floor)
178  const left = slots - counts.reduce((sum, count) => sum + count, 0)
179  const order = wanted.map((want, at) => [want - Math.floor(want), at] as const).sort((a, b) => b[0] - a[0])
180
181  for (let i = 0; i < left && i < order.length; i += 1) {
182    const at = order[i]?.[1] ?? 0
183    if ((wanted[at] ?? 0) > 0) {
184      counts[at] = (counts[at] ?? 0) + 1
185    }
186  }
187
188  return counts
189}
190
191/** Pixels laid over the sprite, facing left, from its top left. */
192export type Overlay = { rows: readonly string[]; x: number; y: number; ink: Readonly<Record<string, number>> }
193
194/** The marks that are eyes, which gear must never cover. */
195const EYE_MARKS = new Set(['k', 'e', 'w'])
196
197const GEAR_INK = {
198  k: 0x4f5584, // cap, headset band
199  h: 0x8a90b8, // the cap's top and the headset's band, catching the light
200  y: 0xffd447, // tassel
201  r: 0xff6b8a, // headset cups
202  g: 0xc0c4d0, // pickaxe head
203  b: 0x9a6a44, // pickaxe handle
204  p: 0xffafd7, // hairpin petals
205  c: 0xffe36e, // hairpin heart
206} as const
207
208/**
209 * What its nature puts on it, by where its head is: the worker's pickaxe held out in front
210 * (raised and lowered while tools run), the scholar's cap, the gamer's headset, the
211 * sweetie's flower pin. Nothing for the curious.
212 */
213export function gearOf(nature: Nature, head: Head, isBig: boolean, isWorking: boolean, frame: number, rows: readonly string[] = []): Overlay[] {
214  const middle = Math.round((head.left + head.right) / 2)
215
216  switch (nature) {
217    case 'worker': {
218      const swing = isWorking && frame % 2 === 1 ? -1 : 0
219      const rows = isBig ? ['.ggg.', 'gg.gg', 'g.b.g', '..b..', '..b..'] : ['ggg', 'g.g', '.b.']
220
221      return [{ rows, x: head.left - (isBig ? 4 : 3), y: head.eye + swing + (isBig ? 1 : 0), ink: GEAR_INK }]
222    }
223    case 'scholar': {
224      // The mini gets the board alone, two rows above its eyes.
225      const rows = isBig ? ['...h...', 'hhhhhhh', '.kkkkky', '......y'] : ['.hhh.', 'hhhhh']
226      const width = rows[1]?.length ?? 0
227
228      return [{ rows, x: middle - Math.floor(width / 2), y: head.top - 2, ink: GEAR_INK }]
229    }
230    case 'gamer': {
231      // Cups on the sides of the head, inside its edge unless an eye is there, then just outside.
232      const cup = isBig ? 2 : 1
233      const eyes = rows[head.eye] ?? ''
234      const isEyeAt = (from: number) => [...eyes.slice(Math.max(0, from), from + cup)].some(mark => EYE_MARKS.has(mark))
235      const isInside = !isEyeAt(head.left) && !isEyeAt(head.right - cup + 1)
236      const left = isInside ? head.left : head.left - cup
237      const across = (isInside ? head.right - head.left + 1 : head.right - head.left + 1 + cup * 2) - cup * 2
238      const band = '.'.repeat(cup) + 'h'.repeat(across) + '.'.repeat(cup)
239      const side = '.'.repeat(cup - 1) + 'h' + '.'.repeat(across) + 'h' + '.'.repeat(cup - 1)
240      const cups = 'r'.repeat(cup) + '.'.repeat(across) + 'r'.repeat(cup)
241      const reach = Math.max(0, head.eye - head.top)
242      const lines = [band, ...Array.from({ length: reach }, () => side), cups, ...(isBig ? [cups] : [])]
243
244      return [{ rows: lines, x: left, y: head.top - 1, ink: GEAR_INK }]
245    }
246    case 'sweetie':
247      return [{ rows: isBig ? ['p.p', '.c.', 'p.p'] : ['pc'], x: head.right - (isBig ? 3 : 2), y: head.top - 1, ink: GEAR_INK }]
248    case 'curious':
249      return []
250  }
251}
252
253/** An overlay's rows and left edge for the way the pet faces: mirrored with it. */
254function facing(overlay: Overlay, spriteWidth: number, dir: Pet['dir']): Overlay {
255  if (dir !== 1) {
256    return overlay
257  }
258  const across = overlay.rows[0]?.length ?? 0
259
260  return { ...overlay, rows: overlay.rows.map(row => [...row].reverse().join('')), x: spriteWidth - overlay.x - across }
261}
262const BALL = ['rw', 'wr'] as const
263const GIFT_INK = { p: 0xff9ec4, y: 0xffe36e } as const
264/** Columns kept free beside the sprite for the heart or sparkle. */
265const BADGE = 6
266
267export type Pixels = (number | null)[][]
268
269/** What `paint` drew: the pixels, where the sprite sits in them, and the shade its leaning gives the grass. */
270export type Scene = { pixels: Pixels; left: number; top: number; spriteWidth: number; ground: number; shade: (color: number) => number }
271
272export function lookOf(size: Size): Look {
273  return LOOKS[size]
274}
275
276/** The big sprite for a stage: the baby, the teen, or an adult's own drawing where it has one. */
277export function bodyOf(kind: Species, stage: Stage, form: Form): Sprite {
278  if (stage === 'baby') {
279    return kind.big
280  }
281  if (stage === 'adult' && form !== 'rare') {
282    return kind.adults?.[form] ?? kind.teen
283  }
284
285  return kind.teen
286}
287
288function spriteOf(kind: Species, size: Size): Sprite {
289  return kind[LOOKS[size].sprite]
290}
291
292/** Pixel rows of the yard: headroom, sprite and grass. Even, so it folds into whole cells. */
293export function sceneHeight(kind: Species, size: Size): number {
294  const look = LOOKS[size]
295  const rows = look.headroom + spriteOf(kind, size).rows.length + look.ground
296
297  return rows + (rows % 2)
298}
299
300/** Terminal rows of the yard at `size`. */
301export function terminalRows(kind: Species, size: Size): number {
302  return sceneHeight(kind, size) / 2
303}
304
305/** The narrowest yard, in sprite pixels: the sprite and its badge. */
306export function minWidth(kind: Species, size: Size): number {
307  return (spriteOf(kind, size).rows[0]?.length ?? 0) + BADGE
308}
309
310/** The sprite's pixel rows for this tick: eyes, tear and feet by mood, flipped to face its way. */
311export function spriteRows(one: Pet, sprite: Sprite, isSad: boolean): string[] {
312  const rows: string[] = [...sprite.rows]
313  const isBlinking = one.mood === 'walk' && one.frame % 9 === 0
314  const swap = (with_: Readonly<Record<number, string>>) => {
315    for (const [at, row] of Object.entries(with_)) {
316      rows[Number(at)] = row
317    }
318  }
319
320  if (one.mood === 'sleep' || one.mood === 'happy' || one.mood === 'love' || one.mood === 'gift' || isBlinking) {
321    swap(sprite.eyesShut)
322  }
323  if (one.mood === 'walk' && isSad) {
324    swap(sprite.tear)
325  }
326  if (one.mood !== 'sleep' && one.frame % 2 === 1) {
327    swap(sprite.feetApart)
328  }
329
330  // Sprites are drawn facing left; mirrored when it walks right.
331  return one.dir === 1 ? rows.map(row => [...row].reverse().join('')) : rows
332}
333
334/** How the yard is painted beyond the pet and its size. */
335export type PaintOptions = {
336  /** Its tear when a rate limit is nearly used up. */
337  isSad?: boolean
338  /** The SVG card draws its own grass, so it asks for none. */
339  withGrass?: boolean
340  stage?: Stage
341  /** An adult's form; ignored before Lv 40. */
342  form?: Form
343  /** What it carries while it works, and what stands in its corner of the yard. */
344  nature?: Nature
345  /** The sun, the evening sun, or the moon and stars, by the local hour. */
346  daypart?: Daypart
347  /** Its leaning (worker, scholar, sweetie, gamer shares): how much of the yard each side fills. */
348  leaning?: readonly number[]
349}
350
351/** The accessory's rows and left edge for the way the pet faces: mirrored with it. */
352export function worn(accessory: Accessory, spriteWidth: number, dir: Pet['dir']): { rows: string[]; x: number } {
353  if (dir !== 1) {
354    return { rows: [...accessory.rows], x: accessory.x }
355  }
356  const across = accessory.rows[0]?.length ?? 0
357
358  return { rows: accessory.rows.map(row => [...row].reverse().join('')), x: spriteWidth - accessory.x - across }
359}
360
361/**
362 * The yard, `width` pixels across: the pet at step `one.x` of `steps` along it, what it wears
363 * at its stage, its heart or sparkle, and a strip of grass with two flowers and its shadow.
364 */
365/** The form an adult's gear is drawn for: its own, unless it is rare or its drawing has gear. */
366function dressedAsOf(sprite: Sprite, stage: Stage, form: Form): Nature {
367  return stage === 'adult' && form !== 'rare' && sprite.hasOwnGear !== true ? form : 'curious'
368}
369
370/**
371 * What a pet wears over its sprite, facing its way and placed from the sprite's top left: a
372 * teen's accessory, an adult's gear in its form (a cap, a headset or a pin takes the place of a
373 * head accessory), and on the big sprite a rare adult's mark. The pane and both games draw it
374 * from here, so a pet looks the same wherever it is.
375 */
376export function dressingOf(kind: Species, sprite: Sprite, isBig: boolean, stage: Stage, form: Form, dir: Pet['dir'], isWorking: boolean, frame: number): Overlay[] {
377  const spriteWidth = sprite.rows[0]?.length ?? 0
378  const isRare = isBig && stage === 'adult' && form === 'rare'
379  const dressedAs = dressedAsOf(sprite, stage, form)
380  const gear = gearOf(dressedAs, sprite.head ?? kind.head[isBig ? 'big' : 'mini'], isBig, isWorking, frame, sprite.rows)
381  const isHeadCovered = sprite.hasOwnGear === true || dressedAs === 'scholar' || dressedAs === 'gamer' || dressedAs === 'sweetie'
382  const onTop: Overlay[] = []
383
384  if (isRare) {
385    const { rows, x } = worn(kind.rare.overlay, spriteWidth, dir)
386
387    onTop.push({ rows, x, y: kind.rare.overlay.y, ink: kind.rare.overlay.ink })
388  } else if (stage !== 'baby' && !(isHeadCovered && kind.accessory.slot === 'head')) {
389    // An adult drawn on its own sits its accessory where the teen does.
390    const accessory = (isBig ? sprite.accessory ?? kind.teen.accessory : undefined) ?? kind.accessory[isBig ? 'big' : 'mini']
391    const { rows, x } = worn(accessory, spriteWidth, dir)
392
393    onTop.push({ rows, x, y: accessory.y, ink: accessory.ink })
394  }
395
396  return [...onTop, ...gear.map(overlay => facing(overlay, spriteWidth, dir))]
397}
398
399export function paint(one: Pet, kind: Species, size: Size, width: number, steps: number, options: PaintOptions = {}): Scene {
400  const { isSad = false, withGrass = true, stage = 'baby', nature = 'curious', daypart = 'day', form = 'curious', leaning = [] } = options
401  const look = LOOKS[size]
402  // The small pane keeps the mini sprite at every stage; the big one grows and takes its form.
403  const isBig = look.sprite === 'big'
404  const isRare = isBig && stage === 'adult' && form === 'rare'
405  const sprite = isBig ? bodyOf(kind, stage, form) : spriteOf(kind, size)
406  const ink = { ...kind.ink, ...(sprite.ink ?? {}), ...(isRare ? kind.rare.ink : {}) }
407  const height = sceneHeight(kind, size)
408  const spriteWidth = sprite.rows[0]?.length ?? 0
409  const pixels: Pixels = Array.from({ length: height }, () => Array.from({ length: width }, () => null))
410  const put = (x: number, y: number, color: number) => {
411    const line = pixels[y]
412
413    if (line !== undefined && x >= 0 && x < width) {
414      line[x] = color
415    }
416  }
417  const stamp = (rows: readonly string[], ink: Readonly<Record<string, number>>, left: number, top: number) => {
418    rows.forEach((row, y) => {
419      ;[...row].forEach((mark, x) => {
420        const color = ink[mark]
421
422        if (color !== undefined) {
423          put(left + x, top + y, color)
424        }
425      })
426    })
427  }
428
429  // Grass: a tufted top row over a deeper one, a pink and a yellow flower.
430  const grassTop = height - look.ground
431  // The side it leans to most tints the grass, more the further it leans; a tie tints nothing.
432  const leading = topOf(leaning)
433  const tint = leading < 0 ? 0 : Math.max(0, Math.min(0.6, ((leaning[leading] ?? 0) - 0.3) * 1.2))
434  const shade = (color: number) => (tint > 0 ? blend(color, GROUND_TINT[SIDES[leading] ?? 'worker'], tint) : color)
435  if (withGrass) {
436    for (let x = 0; x < width; x += 1) {
437      put(x, grassTop, shade(x % 4 === 1 ? PALETTE.tuft : PALETTE.grass))
438      for (let y = grassTop + 1; y < height; y += 1) {
439        put(x, y, shade(PALETTE.grassDeep))
440      }
441    }
442    put(2, grassTop, PALETTE.pink)
443    put(width - 6, grassTop, PALETTE.yellow)
444  }
445
446  // The sky in the top right corner, and at night a few stars that twinkle.
447  const sky = SKY[daypart]
448  stamp(sky.rows, sky.ink, width - (sky.rows[0]?.length ?? 0) - 1, 0)
449  if (daypart === 'night') {
450    STARS.forEach((x, at) => {
451      if ((one.frame + at) % 5 !== 0 && x < width - 6) {
452        put(x, at % 2, 0xfffaf0)
453      }
454    })
455  }
456
457  // The props of its leaning, in slots spread along the yard, behind the pet.
458  const slots = isBig ? 4 : 2
459  const counts = yardOf(leaning, slots)
460  const props = SIDES.flatMap((side, at) => YARD[side][isBig ? 'big' : 'mini'].slice(0, counts[at] ?? 0))
461  props.forEach((prop, slot) => {
462    const across = prop.rows[0]?.length ?? 0
463    const x = Math.round(((slot + 0.5) / slots) * (width - 8)) + 1 - Math.floor(across / 2)
464
465    stamp(prop.rows, prop.ink, x, grassTop - prop.rows.length - (prop.lift ?? 0))
466  })
467
468  const left = Math.round((one.x / steps) * Math.max(0, width - spriteWidth - BADGE))
469  const isBouncy = one.mood === 'walk' || one.mood === 'happy' || one.mood === 'gift'
470  const rest = grassTop - sprite.rows.length
471  const top = isBouncy && one.frame % 2 === 1 ? rest - 1 : rest
472
473  // Its shadow on the grass, narrower than it, so the feet stand on something.
474  if (withGrass) {
475    for (let x = left + 2; x < left + spriteWidth - 2; x += 1) {
476      put(x, grassTop, PALETTE.shadow)
477    }
478  }
479  stamp(spriteRows(one, sprite, isSad), ink, left, top)
480  const dressedAs = dressedAsOf(sprite, stage, form)
481  for (const overlay of dressingOf(kind, sprite, isBig, stage, form, one.dir, one.mood === 'work', one.frame)) {
482    stamp(overlay.rows, overlay.ink, left + overlay.x, top + overlay.y)
483  }
484  // An adult twinkles beside its head every other tick, high then low, unless a heart or a
485  // sparkle already shows there. The head is on the side it faces; with no room there, at the
486  // yard's left edge, it takes the badge columns on the right.
487  if (stage === 'adult' && (one.mood === 'walk' || one.mood === 'work' || one.mood === 'sleep') && one.frame % 2 === 0) {
488    const ahead = left - SPARKLE[0].length - 1
489    const x = one.dir === -1 && ahead >= 0 ? ahead : left + spriteWidth + 1
490
491    stamp(SPARKLE, { s: PALETTE.sparkle, w: PALETTE.petal }, x, one.frame % 4 === 0 ? top : top + 3)
492  }
493
494  if (one.mood === 'love') {
495    stamp(HEART, { h: PALETTE.heart }, left + spriteWidth + 1, Math.max(0, top - 1 + (one.frame % 2)))
496  }
497  const prop = PROPS[nature]
498  if (one.mood === 'work' && prop !== null && dressedAs !== 'worker') {
499    stamp(prop.rows, prop.ink, left + spriteWidth + 1, Math.max(0, top + 1))
500  }
501  // A gamer kicks a ball along ahead of it.
502  if (nature === 'gamer' && one.mood === 'walk') {
503    const ahead = one.dir === 1 ? left + spriteWidth + 1 : left - 3
504    stamp(one.frame % 2 === 0 ? BALL : [BALL[1], BALL[0]], { r: 0xff6b8a, w: 0xfffaf0 }, ahead, grassTop - 2)
505  }
506  if (one.mood === 'gift') {
507    // The gift it found, bobbing beside it.
508    stamp(GIFT, GIFT_INK, left + spriteWidth + 1, Math.max(0, top - 1 + (one.frame % 2)))
509  }
510  if (one.mood === 'happy') {
511    const isLeft = one.frame % 2 === 0
512
513    stamp(SPARKLE, { s: PALETTE.sparkle, w: PALETTE.petal }, isLeft ? left - 3 : left + spriteWidth, isLeft ? top + 1 : top - 1)
514  }
515
516  return { pixels, left, top, spriteWidth, ground: look.ground, shade }
517}
518
519export type Cell = { glyph: string; fg: number | null; bg: number | null }
520
521/** Folds each pair of pixel rows into one row of half-block cells. */
522export function toCells(pixels: Pixels): Cell[][] {
523  const lines: Cell[][] = []
524
525  for (let y = 0; y + 1 < pixels.length; y += 2) {
526    const upper = pixels[y] ?? []
527    const lower = pixels[y + 1] ?? []
528
529    lines.push(
530      upper.map((top, x) => {
531        const bottom = lower[x] ?? null
532
533        if (top === null) {
534          return bottom === null ? { glyph: ' ', fg: null, bg: null } : { glyph: '▄', fg: bottom, bg: null }
535        }
536
537        return top === bottom ? { glyph: '█', fg: top, bg: null } : { glyph: '▀', fg: top, bg: bottom }
538      }),
539    )
540  }
541
542  return lines
543}
544
545const DEFAULT_COLOR = 0x01000000
546
547/** `Raster`'s packing: little-endian u32 triplets `[codePoint, foreground, background]`, base64. */
548export function pack(lines: Cell[][]): string {
549  const words = new Uint32Array(
550    lines.flat().flatMap(cell => [cell.glyph.codePointAt(0) ?? 0x20, cell.fg ?? DEFAULT_COLOR, cell.bg ?? DEFAULT_COLOR]),
551  )
552
553  // The environment has Uint8Array.prototype.toBase64; the es2023 lib does not declare it yet.
554  const bytes = new Uint8Array(words.buffer) as Uint8Array & { toBase64(): string }
555
556  return bytes.toBase64()
557}
558
559export function hex(color: number): string {
560  return `#${color.toString(16).padStart(6, '0')}`
561}
562
563/** What the SVG card shows besides the yard: the name tag, what the pet says, and a line of counts. */
564export type Caption = { name: string; level: number; progress: number; says: string; tally: string; daypart?: Daypart }
565
566const CARD = { fill: '#fffaf3', edge: '#f3e2d2', ink: '#6b4f4f', tag: '#f48fb1', bubble: '#ffffff', grass: '#b5dcae', shadow: '#8fbf8a' } as const
567
568/** The card's backdrop and the tally's color through the day: cream, a warm morning, peach, night blue. */
569const CARD_SKY: Readonly<Record<Daypart, { fill: string; edge: string; tally: string }>> = {
570  morning: { fill: '#fff6e6', edge: '#f3e2c8', tally: CARD.ink },
571  day: { fill: CARD.fill, edge: CARD.edge, tally: CARD.ink },
572  evening: { fill: '#ffe8de', edge: '#f2cfc0', tally: CARD.ink },
573  night: { fill: '#2b3050', edge: '#3d4470', tally: '#d8dcf0' },
574}
575
576function escapeXml(text: string): string {
577  return text.replace(/[&<>"']/g, mark => `&#${mark.codePointAt(0)};`)
578}
579
580/** A rough width for `text` at `fontSize`: wide (CJK, Hangul) characters take 1 em, the rest 0.6 em. */
581function textWidth(text: string, fontSize: number): number {
582  return [...text].reduce((sum, mark) => sum + (/[ᄀ-ᅟ⺀-꓏가-힣豈-﫿＀-⦆]/u.test(mark) ? 1 : 0.6), 0) * fontSize
583}
584
585/** The narrowest yard, in sprite pixels, whose card fits `line` under the grass. */
586export function cardWidthFor(size: Size, line: string): number {
587  const { unit, font } = LOOKS[size]
588
589  return Math.ceil(textWidth(line, font) / unit) + 2
590}
591
592/**
593 * The scene as an SVG card, as the remote surfaces draw it: crisp pixels on a cream card,
594 * a pink name tag with the level and its progress, and a speech bubble over the pet.
595 */
596export function toSvg(scene: Scene, size: Size, caption: Caption): string {
597  const { unit, font } = LOOKS[size]
598  const pad = unit * 2
599  const band = font * 2 + 8
600  const cols = scene.pixels[0]?.length ?? 0
601  const width = cols * unit + pad * 2
602  const foot = font + 10
603  const height = band + scene.pixels.length * unit + pad + foot
604  const parts: string[] = []
605  const dots: string[] = []
606  const text = (x: number, y: number, body: string, attrs: string) =>
607    `<text x="${x}" y="${y}" font-family="ui-rounded, 'SF Pro Rounded', system-ui, sans-serif" font-size="${font}" ${attrs}>${escapeXml(body)}</text>`
608
609  const backdrop = CARD_SKY[caption.daypart ?? 'day']
610  parts.push(`<rect x="0.5" y="0.5" width="${width - 1}" height="${height - 1}" rx="${unit * 3}" fill="${backdrop.fill}" stroke="${backdrop.edge}"/>`)
611
612  // A rounded mound of grass with a scalloped top, three flowers, and the pet's shadow.
613  const groundTop = band + (scene.pixels.length - scene.ground) * unit
614  const bump = unit * 1.1
615  // The card draws its own grass, in the shade its leaning gives the pane's.
616  const grass = hex(scene.shade(Number.parseInt(CARD.grass.slice(1), 16)))
617  const mound: string[] = [`<rect x="${pad}" y="${groundTop}" width="${cols * unit}" height="${scene.ground * unit + unit}" rx="${unit}" fill="${grass}"/>`]
618  for (let x = pad + bump; x < pad + cols * unit - bump / 2; x += bump * 2) {
619    mound.push(`<circle cx="${x}" cy="${groundTop + 1}" r="${bump}" fill="${grass}"/>`)
620  }
621  const flower = (x: number, petal: string) =>
622    `<circle cx="${x}" cy="${groundTop + unit * 0.6}" r="${unit * 0.7}" fill="${petal}"/><circle cx="${x}" cy="${groundTop + unit * 0.6}" r="${unit * 0.28}" fill="#ffd36e"/>`
623  mound.push(flower(pad + unit * 3, hex(PALETTE.pink)), flower(pad + cols * unit * 0.62, '#ffffff'), flower(pad + cols * unit - unit * 4, hex(PALETTE.pink)))
624  mound.push(
625    `<ellipse cx="${pad + (scene.left + scene.spriteWidth / 2) * unit}" cy="${groundTop + unit * 0.4}" rx="${(scene.spriteWidth / 2 - 1) * unit}" ry="${unit * 0.7}" fill="${CARD.shadow}"/>`,
626  )
627  parts.push(...mound)
628
629  // Pixels, each row merged into runs of one color so the markup stays small.
630  scene.pixels.forEach((row, y) => {
631    let x = 0
632
633    while (x < row.length) {
634      const color = row[x]
635      let end = x + 1
636
637      while (end < row.length && row[end] === color) {
638        end += 1
639      }
640      if (color !== null && color !== undefined) {
641        dots.push(`<rect x="${pad + x * unit}" y="${band + y * unit}" width="${(end - x) * unit}" height="${unit}" fill="${hex(color)}"/>`)
642      }
643      x = end
644    }
645  })
646  parts.push(`<g shape-rendering="crispEdges">${dots.join('')}</g>`)
647
648  // The tag: name and level, and a thin bar of the way to the next level.
649  const label = caption.name === '' ? `Lv ${caption.level}` : `${caption.name}  Lv ${caption.level}`
650  const tagWidth = textWidth(label, font) + 16
651  const tagHeight = font + 8
652  parts.push(`<rect x="${pad}" y="6" width="${tagWidth}" height="${tagHeight}" rx="${tagHeight / 2}" fill="${CARD.tag}"/>`)
653  parts.push(text(pad + 8, 6 + font + 1, label, 'fill="#ffffff" font-weight="700"'))
654  const barLeft = pad + tagWidth + 6
655  const barWidth = Math.min(48, Math.max(0, width - barLeft - pad))
656  if (barWidth >= 16) {
657    parts.push(`<rect x="${barLeft}" y="${6 + tagHeight / 2 - 2}" width="${barWidth}" height="4" rx="2" fill="${CARD.edge}"/>`)
658    parts.push(
659      `<rect x="${barLeft}" y="${6 + tagHeight / 2 - 2}" width="${Math.round(barWidth * Math.min(1, Math.max(0, caption.progress)))}" height="4" rx="2" fill="${CARD.tag}"/>`,
660    )
661  }
662
663  // The bubble, centred over the pet and kept inside the card.
664  if (caption.says !== '') {
665    const bubbleWidth = textWidth(caption.says, font) + 14
666    const bubbleHeight = font + 8
667    const centre = pad + (scene.left + scene.spriteWidth / 2) * unit
668    const x = Math.min(width - pad - bubbleWidth, Math.max(pad, centre - bubbleWidth / 2))
669    const y = Math.max(tagHeight + 10, band + scene.top * unit - bubbleHeight - 6)
670    const tail = Math.min(x + bubbleWidth - 8, Math.max(x + 8, centre))
671
672    parts.push(`<path d="M${tail - 4} ${y + bubbleHeight - 1} L${tail} ${y + bubbleHeight + 5} L${tail + 4} ${y + bubbleHeight - 1} Z" fill="${CARD.bubble}" stroke="${CARD.edge}"/>`)
673    parts.push(`<rect x="${x}" y="${y}" width="${bubbleWidth}" height="${bubbleHeight}" rx="${bubbleHeight / 2}" fill="${CARD.bubble}" stroke="${CARD.edge}"/>`)
674    parts.push(`<rect x="${tail - 3.5}" y="${y + bubbleHeight - 2}" width="7" height="2" fill="${CARD.bubble}"/>`)
675    parts.push(text(x + 7, y + font + 1, caption.says, `fill="${CARD.ink}" font-weight="600"`))
676  }
677
678  // The counts under the grass, centred.
679  parts.push(text(width / 2, height - pad / 2 - 4, caption.tally, `fill="${backdrop.tally}" fill-opacity="0.7" text-anchor="middle"`))
680
681  return [
682    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
683    ...parts,
684    '</svg>',
685  ].join('')
686}
687
688/** Pixels as an SVG on a dark rounded backdrop, `unit` CSS pixels each: Pet Run off the terminal. */
689export function pixelsSvg(pixels: Pixels, unit: number): string {
690  const width = (pixels[0]?.length ?? 0) * unit
691  const height = pixels.length * unit
692  const dots: string[] = []
693
694  pixels.forEach((row, y) => {
695    let x = 0
696
697    while (x < row.length) {
698      const color = row[x]
699      let end = x + 1
700
701      while (end < row.length && row[end] === color) {
702        end += 1
703      }
704      if (color !== null && color !== undefined) {
705        dots.push(`<rect x="${x * unit}" y="${y * unit}" width="${(end - x) * unit}" height="${unit}" fill="${hex(color)}"/>`)
706      }
707      x = end
708    }
709  })
710
711  return [
712    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
713    `<rect width="${width}" height="${height}" rx="${unit * 2}" fill="#1e1e2e"/>`,
714    `<g shape-rendering="crispEdges">${dots.join('')}</g>`,
715    '</svg>',
716  ].join('')
717}
718
hooks/quest.ts 518 lines
1/**
2 * Pet Quest: an auto-running side-scroller. The pet runs on its own; a press jumps, a second
3 * press while rising jumps higher. Stomp the bugs, snap up the snacks, knock the gift boxes
4 * from below, hop the stumps, and reach the snack bowl.
5 *
6 * Stages are tile maps, one character per 4×4 tile, as the sprites are pixels: the eight
7 * below are plain data. Pure, like run.ts: `advanceQuest` and `paintQuest` are all there is.
8 *
9 * @handbook 3.5-pet-quest
10 * @tested tests/pet.test.ts
11 */
12import type { Pet } from '../types'
13import { PALETTE, dressingOf, spriteRows } from './scene'
14import type { Form, Pixels, Stage } from './scene'
15import type { Species } from './species'
16
17export const QUEST_TICK_MS = 50
18const TILE = 4
19/** Rows of tiles in every stage; the view is this many tiles high. */
20export const QUEST_ROWS = 7
21export const QUEST_HEIGHT = QUEST_ROWS * TILE
22const SPEED = 1
23const GRAVITY = 0.45
24const FALL_MAX = 4
25const LIFT = 3.4
26const BOOST = 1.5
27const BOUNCE = 2.8
28const FOE_SPEED = 0.4
29/** The pet's box inside its 8×8 sprite. */
30const BOX = { x: 1, y: 1, w: 6, h: 7 } as const
31const SIDE = 8
32const SNACK_POINTS = 10
33const STOMP_POINTS = 20
34
35/**
36 * `.` sky, `#` ground, `=` brick, `g` a gift box holding a snack (`u` once it is opened), `T` a tree stump,
37 * `c` a snack, `b` a bug (it walks, turning at walls and edges), `F` the goal column
38 * (the snack bowl stands at its foot).
39 */
40export const STAGES: readonly (readonly string[])[] = [
41  // 1
42  [
43    '.............c......................................................................',
44    '................................................................................F...',
45    '............g...........................g=g.....................................F...',
46    '......................cc.......cc...===.............................#...........F...',
47    '................................................TT.....ccc.........##...........F...',
48    '..................TT......b.................b...TT............b...###...........F...',
49    '##############################...###################################################',
50  ],
51  // 2
52  [
53    '.............................c..................................................................',
54    '............................................................................................F...',
55    '............................g.............cc....g......................ccc.............#....F...',
56    '.....................cc...=====...#.#.........=====...........TT......................##....F...',
57    '............TT...................##.##..................TT....TT.....................###....F...',
58    '............TT..b...............###b###............b....TT....TT..b.............b...####....F...',
59    '####################...##################...##########################....######################',
60  ],
61  // 3
62  [
63    '............................................................................................................',
64    '....................................................................g...................................F...',
65    '...............................ccc....g=g......................................cccc.=....#..............F...',
66    '...............cc...................=======..........cc.....TT....====......==..........##..............F...',
67    '......................TT....................................TT.........................###.#............F...',
68    '..........b...........TT..b..................b..b...........TT..b.......b.............####b##...........F...',
69    '##############...#############....##################....######################.....#########################',
70  ],
71  // 4
72  [
73    '..............................................................................................................',
74    '.........................c...............c.................c...............c.................c............F...',
75    '.................c..........................g.........................g...................................F...',
76    '........................TT....cc........TT.....cc.........TT..............TT....cc..........TT............F...',
77    '................TT......TT........TT....TT................TT......TT......TT........TT......TT............F...',
78    '..........TT....TT..b...TT....b...TT....TT....b.....TT....TT..b...TT..b...TT....b...TT..b...TT............F...',
79    '##############################################################################################################',
80  ],
81  // 5
82  [
83    '................................................................................................................',
84    '............................................................................................................F...',
85    '....................g........cc.........g.........c..............ccc........g.........c.....................F...',
86    '..............cc............===................c.==.............====...............c.==.....................F...',
87    '..............==...............==.............==..................................==........................F...',
88    '......................b...............b.................b.b...............b...................b.............F...',
89    '############......########........##########........##########........##########........########################',
90  ],
91  // 6
92  [
93    '........................................................................................................',
94    '..........................................................................g.........................F...',
95    '................c...c.....g.....c...c...............g.....c...c.................c...c...c...........F...',
96    '..........................................====......................................................F...',
97    '......................................................................TT............................F...',
98    '..............b...b...b.......b...b...b.................b...b...b.....TT......b...b...b...b.........F...',
99    '################################################...#####################################################',
100  ],
101  // 7
102  [
103    '.................................c....................c........................c....................................',
104    '................c...............................................................................................F...',
105    '.....................c..g........##........c..g........#..........c...g........##...........c........#..........F...',
106    '................##..............####..................##.#....................####..................##..........F...',
107    '...............####............######................###.##..................######................###..........F...',
108    '..............######......b...########.b........b...####.###.b..........b...########..b...........####...b......F...',
109    '####################...###################...###################....######################....######################',
110  ],
111  // 8
112  [
113    '........................................................................................................................................',
114    '...............................c....................g......cc..............c...........................c....g..............#........F...',
115    '...............cc.....................cc........g..........==.................g..........c......................cc........##........F...',
116    '...............==.............TT.....===......#...........................TT..........c.==............TT.......===.......###........F...',
117    '........................TT....TT.............##.....................TT....TT.........==.........TT....TT................####........F...',
118    '..........b..........b..TT..b.TT..b.........###...b...b.........b...TT..b.TT....b.............b.TT..b.TT..b............#####..b.....F...',
119    '##############....##################.....#################....######################......####################.....#####################',
120  ],
121]
122
123const SOLID = new Set(['#', '=', 'g', 'u', 'T'])
124
125type Foe = { x: number; y: number; dir: -1 | 1; isAlive: boolean }
126
127export type QuestPhase = 'ready' | 'running' | 'clear' | 'over'
128
129export type Quest = {
130  stage: number
131  phase: QuestPhase
132  /** The stage's tiles as they stand: snacks eaten and blocks knocked are gone. */
133  tiles: string[]
134  /** The view's width in pixels. */
135  width: number
136  x: number
137  y: number
138  rise: number
139  isGrounded: boolean
140  hasBoosted: boolean
141  foes: Foe[]
142  snacks: number
143  stomps: number
144  tick: number
145}
146
147export function newQuest(stage: number, width: number): Quest {
148  const map = STAGES[stage] ?? STAGES[0] ?? []
149  const foes: Foe[] = []
150  const tiles = map.map((row, ty) =>
151    [...row]
152      .map((mark, tx) => {
153        if (mark === 'b') {
154          foes.push({ x: tx * TILE, y: ty * TILE, dir: -1, isAlive: true })
155
156          return '.'
157        }
158
159        return mark
160      })
161      .join(''),
162  )
163  // Feet on the ground of the first column.
164  const groundRow = tiles.findIndex(row => SOLID.has(row[1] ?? '.'))
165
166  return {
167    stage,
168    phase: 'ready',
169    tiles,
170    width,
171    x: TILE,
172    y: (groundRow < 0 ? QUEST_ROWS - 1 : groundRow) * TILE - SIDE,
173    rise: 0,
174    isGrounded: true,
175    hasBoosted: false,
176    foes,
177    snacks: 0,
178    stomps: 0,
179    tick: 0,
180  }
181}
182
183export function questScore(quest: Quest): number {
184  return quest.snacks * SNACK_POINTS + quest.stomps * STOMP_POINTS + (quest.phase === 'clear' ? 100 : 0)
185}
186
187function tileAt(tiles: readonly string[], tx: number, ty: number): string {
188  if (tx < 0) {
189    return '#'
190  }
191
192  return tiles[ty]?.[tx] ?? '.'
193}
194
195function isSolidAt(tiles: readonly string[], px: number, py: number): boolean {
196  return SOLID.has(tileAt(tiles, Math.floor(px / TILE), Math.floor(py / TILE)))
197}
198
199/** Tiles under a box, as `[tx, ty]`. */
200function tilesUnder(x: number, y: number, w: number, h: number): [number, number][] {
201  const under: [number, number][] = []
202
203  for (let ty = Math.floor(y / TILE); ty <= Math.floor((y + h - 0.01) / TILE); ty += 1) {
204    for (let tx = Math.floor(x / TILE); tx <= Math.floor((x + w - 0.01) / TILE); tx += 1) {
205      under.push([tx, ty])
206    }
207  }
208
209  return under
210}
211
212function hitsSolid(tiles: readonly string[], x: number, y: number, w: number, h: number): boolean {
213  return tilesUnder(x, y, w, h).some(([tx, ty]) => SOLID.has(tileAt(tiles, tx, ty)))
214}
215
216/** A press: starts the stage, jumps from the ground, or once per jump lifts it higher while rising. */
217export function jumpQuest(quest: Quest): Quest {
218  if (quest.phase === 'ready') {
219    return { ...quest, phase: 'running', rise: LIFT, isGrounded: false }
220  }
221  if (quest.phase !== 'running') {
222    return quest
223  }
224  if (quest.isGrounded) {
225    return { ...quest, rise: LIFT, isGrounded: false, hasBoosted: false }
226  }
227  if (!quest.hasBoosted && quest.rise > 0) {
228    return { ...quest, rise: quest.rise + BOOST, hasBoosted: true }
229  }
230
231  return quest
232}
233
234function setTile(tiles: string[], tx: number, ty: number, mark: string): void {
235  const row = tiles[ty]
236
237  if (row !== undefined && tx >= 0 && tx < row.length) {
238    tiles[ty] = row.slice(0, tx) + mark + row.slice(tx + 1)
239  }
240}
241
242/** One tick: run, fall or rise against the tiles, eat, knock, stomp, and end at a pit, a bug or the bowl. */
243export function advanceQuest(quest: Quest): Quest {
244  if (quest.phase !== 'running') {
245    return quest
246  }
247
248  const tiles = [...quest.tiles]
249  let { x, y, rise, isGrounded, hasBoosted, snacks, stomps } = quest
250
251  // Across: the pet runs until a wall stops it.
252  const ahead = x + SPEED
253  if (!hitsSolid(tiles, ahead + BOX.x, y + BOX.y, BOX.w, BOX.h)) {
254    x = ahead
255  }
256
257  // Down and up.
258  rise = Math.max(-FALL_MAX, rise - GRAVITY)
259  const next = y - rise
260  if (rise <= 0) {
261    if (hitsSolid(tiles, x + BOX.x, next + BOX.y, BOX.w, BOX.h)) {
262      // Land on top of the tile under its feet.
263      y = Math.floor((next + BOX.y + BOX.h) / TILE) * TILE - BOX.y - BOX.h
264      rise = 0
265      isGrounded = true
266      hasBoosted = false
267    } else {
268      y = next
269      isGrounded = false
270    }
271  } else {
272    const head = tilesUnder(x + BOX.x, next + BOX.y, BOX.w, 0.5).filter(([tx, ty]) => SOLID.has(tileAt(tiles, tx, ty)))
273
274    if (head.length > 0) {
275      for (const [tx, ty] of head) {
276        if (tileAt(tiles, tx, ty) === 'g') {
277          setTile(tiles, tx, ty, 'u')
278          snacks += 1
279        }
280      }
281      y = (Math.floor((next + BOX.y) / TILE) + 1) * TILE - BOX.y
282      rise = 0
283    } else {
284      y = next
285    }
286    isGrounded = false
287  }
288
289  // Snacks it touches.
290  for (const [tx, ty] of tilesUnder(x + BOX.x, y + BOX.y, BOX.w, BOX.h)) {
291    if (tileAt(tiles, tx, ty) === 'c') {
292      setTile(tiles, tx, ty, '.')
293      snacks += 1
294    }
295  }
296
297  // Bugs near the view walk; a fall onto one stomps it, any other touch ends the run.
298  let phase: QuestPhase = 'running'
299  const foes = quest.foes.map(foe => {
300    if (!foe.isAlive || foe.x > x + quest.width) {
301      return foe
302    }
303    const step = foe.x + foe.dir * FOE_SPEED
304    const front = foe.dir === 1 ? step + TILE : step
305    const isBlocked = isSolidAt(tiles, front, foe.y + 1) || !isSolidAt(tiles, front, foe.y + TILE + 1)
306
307    return isBlocked ? { ...foe, dir: (foe.dir === 1 ? -1 : 1) as Foe['dir'] } : { ...foe, x: step }
308  })
309  const pet = { x: x + BOX.x, y: y + BOX.y, w: BOX.w, h: BOX.h }
310  for (const [i, foe] of foes.entries()) {
311    if (!foe.isAlive) {
312      continue
313    }
314    const isTouching = pet.x < foe.x + TILE && foe.x < pet.x + pet.w && pet.y < foe.y + TILE && foe.y + 1 < pet.y + pet.h
315    if (!isTouching) {
316      continue
317    }
318    // A stomp: falling, with its feet above the bug's back as the tick began.
319    if (rise < 0 && quest.y + BOX.y + BOX.h <= foe.y + 2) {
320      // A copy: the foe may still be the one the last tick's quest holds.
321      foes[i] = { ...foe, isAlive: false }
322      stomps += 1
323      rise = BOUNCE
324      isGrounded = false
325    } else {
326      phase = 'over'
327    }
328  }
329
330  if (y > QUEST_HEIGHT) {
331    phase = 'over'
332  }
333  const goal = tiles[QUEST_ROWS - 2]?.indexOf('F') ?? -1
334  if (phase === 'running' && goal >= 0 && pet.x + pet.w >= goal * TILE) {
335    phase = 'clear'
336  }
337
338  return { ...quest, tiles, x, y, rise, isGrounded, hasBoosted, foes, snacks, stomps, phase, tick: quest.tick + 1 }
339}
340
341/** Where the view starts: the pet a third of the way in, never past either end of the stage. */
342export function cameraOf(quest: Quest): number {
343  const length = (quest.tiles[0]?.length ?? 0) * TILE
344
345  return Math.round(Math.max(0, Math.min(length - quest.width, quest.x - quest.width / 3)))
346}
347
348const INK = {
349  dirt: 0x9c7a5b,
350  dirtDark: 0x7d6049,
351  brick: 0xd9946a,
352  mortar: 0xa86f4c,
353  gift: 0xff9ec4,
354  ribbon: 0xffe36e,
355  opened: 0xa88a8f,
356  bark: 0xa0704c,
357  barkDark: 0x7a5236,
358  rings: 0xe2bf8f,
359  bowl: 0x87b7ff,
360  bowlDark: 0x5f8fd8,
361  heart: 0xff87af,
362  marker: 0xffafd7,
363  snack: 0xffc46b,
364  cloud: 0x3c3c50,
365} as const
366
367const BUG = ['a..a', 'bbbb', 'bkbk', 'b..b'] as const
368const BUG_INK = { a: 0x5f4b5f, b: 0xb48ead, k: 0x3a2a3a } as const
369const CLOUD = ['..ccc...', '.cccccc.', 'cccccccc'] as const
370
371/** One tile's 4×4 pixels, by what is around it. */
372function tilePixels(tiles: readonly string[], tx: number, ty: number, tick: number): (number | null)[][] | null {
373  const mark = tileAt(tiles, tx, ty)
374  const above = tileAt(tiles, tx, ty - 1)
375  const fill = (color: number): (number | null)[][] => Array.from({ length: TILE }, () => Array.from({ length: TILE }, () => color))
376
377  switch (mark) {
378    case '#': {
379      const tile = fill(INK.dirt)
380      tile[2]![(tx * 3) % 4] = INK.dirtDark
381      if (!SOLID.has(above)) {
382        tile[0] = [PALETTE.grass, (tx % 3 === 0 ? PALETTE.tuft : PALETTE.grass), PALETTE.grass, PALETTE.grass]
383        tile[1] = [PALETTE.grassDeep, PALETTE.grassDeep, PALETTE.grassDeep, PALETTE.grassDeep]
384      }
385
386      return tile
387    }
388    case '=': {
389      const tile = fill(INK.brick)
390      tile[3] = [INK.mortar, INK.mortar, INK.mortar, INK.mortar]
391      tile[1]![tx % 2 === 0 ? 1 : 3] = INK.mortar
392
393      return tile
394    }
395    case 'g': {
396      // A gift box: pink, a yellow ribbon each way; the bow twinkles now and then.
397      const tile = fill(INK.gift)
398      tile[1] = [INK.ribbon, INK.ribbon, INK.ribbon, INK.ribbon]
399      for (const row of tile) {
400        row[2] = INK.ribbon
401      }
402      tile[0]![2] = Math.floor(tick / 6) % 4 === 0 ? 0xffffff : INK.ribbon
403
404      return tile
405    }
406    case 'u': {
407      const tile = fill(INK.opened)
408      tile[0] = [null, null, null, null]
409
410      return tile
411    }
412    case 'T': {
413      // A tree stump: bark down the sides, rings on top.
414      const isLeft = tileAt(tiles, tx - 1, ty) !== 'T'
415      const row: number[] = isLeft ? [INK.barkDark, INK.bark, INK.bark, INK.bark] : [INK.bark, INK.bark, INK.bark, INK.barkDark]
416      const tile = Array.from({ length: TILE }, (_, y) => (y % 2 === 1 ? row.map((color, x) => (x === 2 ? INK.barkDark : color)) : [...row]))
417      if (above !== 'T') {
418        tile[0] = Array.from({ length: TILE }, (_, x) => (x === (isLeft ? 3 : 0) ? INK.bark : INK.rings))
419      }
420
421      return tile
422    }
423    case 'c': {
424      const bob = Math.floor(tick / 5) % 2
425      const tile: (number | null)[][] = Array.from({ length: TILE }, () => Array.from({ length: TILE }, () => null))
426      tile[bob]![1] = tile[bob + 1]![0] = tile[bob + 1]![1] = tile[bob + 1]![2] = tile[bob + 2]![1] = INK.snack
427
428      return tile
429    }
430    case 'F': {
431      // The goal: a snack bowl at the foot of the column, a heart bobbing over it.
432      const tile: (number | null)[][] = Array.from({ length: TILE }, () => Array.from({ length: TILE }, () => null))
433      const below = tileAt(tiles, tx, ty + 1)
434      if (below !== 'F') {
435        tile[0] = [INK.snack, INK.snack, INK.snack, INK.snack]
436        tile[1] = [INK.bowl, INK.bowl, INK.bowl, INK.bowl]
437        tile[2] = [null, INK.bowlDark, INK.bowlDark, null]
438        tile[3] = [null, INK.bowl, INK.bowl, null]
439      } else if (tileAt(tiles, tx, ty + 2) !== 'F') {
440        const bob = Math.floor(tick / 6) % 2
441        tile[bob]![0] = tile[bob]![2] = tile[bob + 1]![0] = tile[bob + 1]![1] = tile[bob + 1]![2] = tile[bob + 2]![1] = INK.heart
442      }
443
444      return tile
445    }
446    default:
447      return null
448  }
449}
450
451/** The view as pixels, `quest.width` across and `QUEST_HEIGHT` down. */
452export function paintQuest(quest: Quest, kind: Species, stage: Stage, form: Form = 'curious'): Pixels {
453  const width = quest.width
454  const camera = cameraOf(quest)
455  const pixels: Pixels = Array.from({ length: QUEST_HEIGHT }, () => Array.from({ length: width }, () => null))
456  const put = (x: number, y: number, color: number | null | undefined) => {
457    const line = pixels[Math.round(y)]
458    const at = Math.round(x) - camera
459
460    if (line !== undefined && color !== null && color !== undefined && at >= 0 && at < width) {
461      line[at] = color
462    }
463  }
464  const stamp = (rows: readonly string[], ink: Readonly<Record<string, number>>, left: number, top: number) => {
465    rows.forEach((row, y) => {
466      ;[...row].forEach((mark, x) => put(left + x, top + y, ink[mark]))
467    })
468  }
469
470  // Clouds far off move at a quarter of the camera.
471  for (const at of [10, 52, 97, 140]) {
472    stamp(CLOUD, { c: INK.cloud }, at + (camera * 3) / 4, 1 + (at % 4))
473  }
474
475  const first = Math.floor(camera / TILE)
476  for (let ty = 0; ty < QUEST_ROWS; ty += 1) {
477    for (let tx = first; tx <= first + Math.ceil(width / TILE); tx += 1) {
478      tilePixels(quest.tiles, tx, ty, quest.tick)?.forEach((row, y) => row.forEach((color, x) => put(tx * TILE + x, ty * TILE + y, color)))
479    }
480  }
481
482  for (const foe of quest.foes) {
483    if (foe.isAlive) {
484      // Legs swap as it walks.
485      const rows = Math.floor(foe.x) % 2 === 0 ? BUG : [BUG[0], BUG[1], BUG[2], '.bb.']
486
487      stamp(rows, BUG_INK, Math.round(foe.x), foe.y)
488    }
489  }
490
491  const sprite = kind.mini
492  const one: Pet = {
493    x: 0,
494    dir: 1,
495    frame: quest.isGrounded && quest.phase === 'running' ? Math.floor(quest.tick / 3) : 0,
496    mood: quest.phase === 'over' ? 'sleep' : quest.phase === 'clear' ? 'happy' : 'walk',
497    hold: 0,
498    idle: 0,
499  }
500  const top = Math.round(quest.y)
501  stamp(spriteRows(one, sprite, false), kind.ink, Math.round(quest.x), top)
502  // Off the top of the view: a little arrow where it will come down.
503  if (top + BOX.y < 0) {
504    const at = Math.round(quest.x) + 3
505
506    put(at, 0, INK.marker)
507    put(at + 1, 0, INK.marker)
508    put(at - 1, 1, INK.marker)
509    put(at + 2, 1, INK.marker)
510  }
511  // Dressed as the pane dresses it: a teen's accessory, an adult's gear.
512  for (const overlay of dressingOf(kind, sprite, false, stage, form, 1, false, one.frame)) {
513    stamp(overlay.rows, overlay.ink, Math.round(quest.x) + overlay.x, top + overlay.y)
514  }
515
516  return pixels
517}
518
hooks/nature.ts 144 lines
1/**
2 * What kind of pet it is becoming: its nature, read off what it has lived through, and its
3 * rhythm, read off the hours its sessions keep. Pure: counts in, words out.
4 *
5 * @handbook 3.7-nature-rhythm
6 * @tested tests/pet.test.ts
7 */
8import type { PetStats } from '../types'
9import { SIDES, topOf } from './scene'
10import type { Side } from './scene'
11
12/** `curious` until it knows you, or while no one side of you stands out. */
13export type Nature = 'worker' | 'scholar' | 'sweetie' | 'gamer' | 'curious'
14
15/** The four quarters of a day by local hour: 0–6, 6–12, 12–18, 18–24. */
16export type Daypart = 'night' | 'morning' | 'day' | 'evening'
17
18export type Rhythm = 'night owl' | 'early bird' | 'daytimer' | 'evening type'
19
20/** Experience it takes before a nature shows, and the share one side must have. */
21const KNOWN_AFTER = 60
22const STANDS_OUT = 0.4
23/** Finished turns it takes before a rhythm shows, and the share one quarter must have. */
24const RHYTHM_AFTER = 20
25const RHYTHM_SHARE = 0.35
26
27export const DAYPARTS: readonly Daypart[] = ['night', 'morning', 'day', 'evening']
28const RHYTHMS: Readonly<Record<Daypart, Rhythm>> = {
29  night: 'night owl',
30  morning: 'early bird',
31  day: 'daytimer',
32  evening: 'evening type',
33}
34
35export function daypartOf(hour: number): Daypart {
36  return DAYPARTS[Math.min(3, Math.max(0, Math.floor(hour / 6)))] ?? 'day'
37}
38
39/**
40 * Each side's weight, in experience as `xpOf` counts it: tools for the worker, turns and
41 * output for the scholar, pats for the sweetie, game snacks for the gamer.
42 */
43export function sidesOf(stats: PetStats): Record<Exclude<Nature, 'curious'>, number> {
44  return {
45    worker: stats.tools,
46    scholar: stats.turns * 5 + Math.floor(stats.tokens / 1000),
47    sweetie: stats.pats * 2,
48    gamer: stats.snacks,
49  }
50}
51
52export { SIDES } from './scene'
53export type { Side } from './scene'
54
55/**
56 * How far one day moves the leaning toward what that day brought: 12%. Days, not turns, so a
57 * long session changes nothing until the day is over, and a heavy day counts as one day:
58 * a new prop shows after a few days of a new habit, a new form after about two weeks.
59 */
60const DRIFT = 0.12
61
62/** A weighting as shares that sum to 1, or all zero when there is nothing to share. */
63function shares(weights: readonly number[]): number[] {
64  const total = weights.reduce((sum, weight) => sum + weight, 0)
65
66  return weights.map(weight => (total > 0 ? weight / total : 0))
67}
68
69/** The leaning a pet starts from, before any turn has moved it: its whole life's shares. */
70export function lifetimeLeaning(stats: PetStats): number[] {
71  const sides = sidesOf(stats)
72
73  return shares(SIDES.map(side => sides[side]))
74}
75
76/**
77 * The leaning after a day: each side moves a little toward what the day brought (its tool
78 * calls, its turns and their output, its pats and game snacks). Old habits fade, so a pet that
79 * changes how it is kept changes with it, slowly.
80 */
81export function driftLeaning(leaning: readonly number[], brought: Readonly<Record<Side, number>>): number[] {
82  const now = shares(SIDES.map(side => brought[side]))
83
84  if (now.every(share => share === 0)) {
85    return [...leaning]
86  }
87  if (leaning.every(share => share === 0)) {
88    return now
89  }
90
91  return SIDES.map((_, at) => (leaning[at] ?? 0) * (1 - DRIFT) + (now[at] ?? 0) * DRIFT)
92}
93
94/** Whether it has seen enough of you for a nature to show. */
95export function isKnown(stats: PetStats): boolean {
96  return Object.values(sidesOf(stats)).reduce((sum, weight) => sum + weight, 0) >= KNOWN_AFTER
97}
98
99/** Its nature now: the side its leaning favors, once it knows you and one side stands out. */
100export function natureOf(stats: PetStats): Nature {
101  const sides = Object.values(sidesOf(stats))
102  const total = sides.reduce((sum, weight) => sum + weight, 0)
103  const leaning = stats.leaning.length === SIDES.length ? stats.leaning : lifetimeLeaning(stats)
104  const top = topOf(leaning)
105
106  return total >= KNOWN_AFTER && (leaning[top] ?? 0) >= STANDS_OUT ? SIDES[top] ?? 'curious' : 'curious'
107}
108
109/**
110 * An adult's form after a day: it keeps its form until its leaning has clearly moved on (the
111 * new side well ahead, the old one faded), then takes the new side's. The rare form stays.
112 */
113export function reformOf(form: string, leaning: readonly number[]): string {
114  const at = SIDES.indexOf(form as Side)
115  // A tie has not clearly moved on anywhere.
116  const top = topOf(leaning)
117  const isFaded = at < 0 ? true : (leaning[at] ?? 0) <= 0.2
118
119  return form !== 'rare' && form !== '' && top !== at && (leaning[top] ?? 0) >= 0.45 && isFaded ? SIDES[top] ?? form : form
120}
121
122export function rhythmOf(hours: readonly number[]): Rhythm | null {
123  const total = hours.reduce((sum, count) => sum + count, 0)
124  const top = topOf(hours)
125  const part = DAYPARTS[top]
126
127  return part !== undefined && total >= RHYTHM_AFTER && (hours[top] ?? 0) / total >= RHYTHM_SHARE ? RHYTHMS[part] : null
128}
129
130/** A line it says now and then while it wanders, by its nature and the time of day. */
131export function musingOf(nature: Nature, daypart: Daypart, beat: number): string {
132  const lines: Readonly<Record<Nature, readonly string[]>> = {
133    worker: ['ship it!', 'one more test…', 'refactor time'],
134    scholar: ['hmm, interesting', 'reading the docs', 'let me think…'],
135    sweetie: ['pat me?', '♡', 'you are doing great'],
136    gamer: ['wanna play?', 'new high score?', '/pet play!'],
137    curious: ['what is that?', 'la la la', 'sniff sniff'],
138  }
139  const late = daypart === 'night' ? ['it is late…', 'stars are out'] : daypart === 'morning' ? ['good morning!'] : []
140  const pool = [...lines[nature], ...late]
141
142  return pool[beat % pool.length] ?? ''
143}
144
hooks/luck.ts 51 lines
1/**
2 * What luck brings: a gift now and then after a finished turn, a lucky pat, and the rare shiny
3 * pet. Pure: the hooks module rolls `Math.random()` and hands the numbers in, so tests can too.
4 *
5 * @handbook 3.6-luck
6 * @tested tests/pet.test.ts
7 */
8
9/** A finished turn of the main conversation finds a gift this often. */
10export const GIFT_CHANCE = 0.1
11/** A pat is lucky this often, worth three times as much. */
12export const LUCKY_PAT_CHANCE = 0.1
13/** A pet met for the first time is shiny this often. */
14export const SHINY_CHANCE = 1 / 32
15/** The biggest gift, and what a second sparkle stone is worth instead. */
16const JACKPOT_XP = 100
17/** An evolution at Lv 40 takes the rare form this often. */
18export const RARE_CHANCE = 1 / 20
19
20export type Gift = { kind: 'xp'; name: string; xp: number } | { kind: 'stone' }
21
22/**
23 * Which gift a roll `r` in [0, 1) finds: mostly small, now and then a treasure, rarely a
24 * jackpot, and one time in a hundred the sparkle stone that makes the pet shiny.
25 */
26export function giftOf(r: number): Gift {
27  if (r < 0.6) {
28    return { kind: 'xp', name: 'a cookie', xp: 5 }
29  }
30  if (r < 0.85) {
31    return { kind: 'xp', name: 'a toy', xp: 15 }
32  }
33  if (r < 0.95) {
34    return { kind: 'xp', name: 'a treasure', xp: 40 }
35  }
36  if (r < 0.99) {
37    return { kind: 'xp', name: 'the jackpot', xp: JACKPOT_XP }
38  }
39
40  return { kind: 'stone' }
41}
42
43/** What a gift is worth to a pet that already is (or is not) shiny: a second stone is a jackpot. */
44export function worthOf(gift: Gift, isShiny: boolean): { xp: number; makesShiny: boolean } {
45  if (gift.kind === 'xp') {
46    return { xp: gift.xp, makesShiny: false }
47  }
48
49  return isShiny ? { xp: JACKPOT_XP, makesShiny: false } : { xp: 0, makesShiny: true }
50}
51
hooks/run.ts 229 lines
1/**
2 * Pet Run: the pet runs along the grass, jumps the bugs and snaps up the snacks above them.
3 *
4 * Pure, like scene.ts: `advance` moves one tick on, `paintRun` draws it as pixels. The hooks
5 * module owns the clock, the keys and the pane; tests drive the same functions.
6 *
7 * @handbook 3.4-pet-run
8 * @tested tests/pet.test.ts
9 */
10import type { Pet } from '../types'
11import { PALETTE, dressingOf, spriteRows } from './scene'
12import type { Form, Pixels, Stage } from './scene'
13import type { Species } from './species'
14
15/** Ticks a second the hooks module runs it at. */
16export const RUN_TICK_MS = 50
17/** Pixel rows: sky above two rows of grass. */
18export const RUN_HEIGHT = 22
19const GROUND = 2
20const PET_X = 4
21const GRAVITY = 0.6
22const LIFT = -3.6
23const SPEED_START = 1
24const SPEED_MAX = 2.6
25/** Distance the speed takes to climb one step of 0.1. */
26const SPEED_EVERY = 120
27const SNACK_POINTS = 10
28
29const BUG = ['.a.a.', 'bbbbb', 'bkbkb', '.b.b.'] as const
30const BUG_INK = { a: 0x5f4b5f, b: 0xb48ead, k: 0x3a2a3a } as const
31const SNACK = ['.c.', 'ccc', '.c.'] as const
32const SNACK_INK = { c: 0xffc46b } as const
33const CLOUD = ['..ccc...', '.cccccc.', 'cccccccc'] as const
34const CLOUD_INK = { c: 0x3c3c50 } as const
35
36export type Phase = 'ready' | 'running' | 'over'
37
38type Thing = { kind: 'bug' | 'snack'; x: number; y: number }
39
40export type Run = {
41  phase: Phase
42  width: number
43  /** Ticks since the run began: the score, and what animates. */
44  tick: number
45  /** The pet's top above its place on the grass, in pixels, and its upward speed. */
46  lift: number
47  rise: number
48  things: Thing[]
49  /** Pixels until the next bug. */
50  gap: number
51  snacks: number
52  seed: number
53}
54
55/** A small seeded random: the same seed runs the same course, so tests can replay it. */
56function random(seed: number): [number, number] {
57  let t = (seed + 0x6d2b79f5) | 0
58  t = Math.imul(t ^ (t >>> 15), t | 1)
59  t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
60
61  return [((t ^ (t >>> 14)) >>> 0) / 4294967296, (seed + 0x6d2b79f5) | 0]
62}
63
64export function newRun(width: number, seed = 1): Run {
65  return { phase: 'ready', width, tick: 0, lift: 0, rise: 0, things: [], gap: width, snacks: 0, seed }
66}
67
68export function scoreOf(run: Run): number {
69  return Math.floor(run.tick / 2) + run.snacks * SNACK_POINTS
70}
71
72export function speedOf(run: Run): number {
73  return Math.min(SPEED_MAX, SPEED_START + Math.floor(run.tick / SPEED_EVERY) / 10)
74}
75
76/** A jump: starts a ready run, leaves the grass in a running one, and does nothing midair. */
77export function jump(run: Run): Run {
78  if (run.phase === 'over') {
79    return run
80  }
81  if (run.phase === 'ready') {
82    return { ...run, phase: 'running', rise: -LIFT }
83  }
84
85  return run.lift === 0 ? { ...run, rise: -LIFT } : run
86}
87
88function overlaps(a: { x: number; y: number; w: number; h: number }, b: { x: number; y: number; w: number; h: number }): boolean {
89  return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h
90}
91
92/**
93 * One tick: the pet rises and falls, the course scrolls by the speed, a new bug comes when
94 * the gap closes (a snack sometimes floats over it), a snack touched is eaten, a bug ends it.
95 */
96export function advance(run: Run, kind: Species): Run {
97  if (run.phase !== 'running') {
98    return run
99  }
100
101  const speed = speedOf(run)
102  const sprite = kind.mini
103  const side = sprite.rows.length
104  const groundTop = RUN_HEIGHT - GROUND
105  let { seed, gap } = run
106  let roll: number
107
108  const rise = run.rise - GRAVITY
109  const lift = Math.max(0, run.lift + rise)
110  const things = run.things.map(thing => ({ ...thing, x: thing.x - speed })).filter(thing => thing.x > -6)
111
112  gap -= speed
113  if (gap <= 0) {
114    things.push({ kind: 'bug', x: run.width, y: groundTop - BUG.length })
115    ;[roll, seed] = random(seed)
116    if (roll < 0.45) {
117      // Over the bug, at the height a jump passes through.
118      things.push({ kind: 'snack', x: run.width + 1, y: groundTop - side - 6 - Math.floor(roll * 6) })
119    }
120    ;[roll, seed] = random(seed)
121    gap = Math.round((26 + roll * 30) * (0.7 + speed * 0.3))
122  }
123
124  // The pet's box, a pixel in from each side so a graze is forgiven.
125  const pet = { x: PET_X + 1, y: groundTop - side - lift + 1, w: side - 2, h: side - 2 }
126  let snacks = run.snacks
127  let isHit = false
128  const kept = things.filter(thing => {
129    const box =
130      thing.kind === 'bug'
131        ? { x: thing.x, y: thing.y + 1, w: BUG[0].length, h: BUG.length - 1 }
132        : { x: thing.x, y: thing.y, w: SNACK[0].length, h: SNACK.length }
133
134    if (!overlaps(pet, box)) {
135      return true
136    }
137    if (thing.kind === 'snack') {
138      snacks += 1
139
140      return false
141    }
142    isHit = true
143
144    return true
145  })
146
147  return {
148    ...run,
149    phase: isHit ? 'over' : 'running',
150    tick: run.tick + 1,
151    lift,
152    rise: lift === 0 ? 0 : rise,
153    things: kept,
154    gap,
155    snacks,
156    seed,
157  }
158}
159
160/** The run as pixels, `run.width` across and `RUN_HEIGHT` down: clouds, grass, things, the pet. */
161export function paintRun(run: Run, kind: Species, stage: Stage, form: Form = 'curious'): Pixels {
162  const width = run.width
163  const pixels: Pixels = Array.from({ length: RUN_HEIGHT }, () => Array.from({ length: width }, () => null))
164  const put = (x: number, y: number, color: number) => {
165    const line = pixels[y]
166    const at = Math.round(x)
167
168    if (line !== undefined && at >= 0 && at < width) {
169      line[at] = color
170    }
171  }
172  const stamp = (rows: readonly string[], ink: Readonly<Record<string, number>>, left: number, top: number) => {
173    rows.forEach((row, y) => {
174      ;[...row].forEach((mark, x) => {
175        const color = ink[mark]
176
177        if (color !== undefined) {
178          put(left + x, top + y, color)
179        }
180      })
181    })
182  }
183  const travelled = run.tick * speedOf(run)
184
185  // Clouds drift at a fifth of the speed; the grass's tufts scroll with the course.
186  for (const at of [6, 31, 57]) {
187    const x = (((at - travelled / 5) % (width + 10)) + width + 10) % (width + 10) - 8
188
189    stamp(CLOUD, CLOUD_INK, x, 2 + (at % 3))
190  }
191  const groundTop = RUN_HEIGHT - GROUND
192  for (let x = 0; x < width; x += 1) {
193    const mark = (x + Math.floor(travelled)) % 6
194
195    put(x, groundTop, mark === 0 ? PALETTE.tuft : mark === 3 ? PALETTE.pink : PALETTE.grass)
196    put(x, groundTop + 1, PALETTE.grassDeep)
197  }
198
199  for (const thing of run.things) {
200    if (thing.kind === 'bug') {
201      stamp(BUG, BUG_INK, thing.x, thing.y)
202    } else {
203      // Snacks bob a pixel.
204      stamp(SNACK, SNACK_INK, thing.x, thing.y + (Math.floor(run.tick / 4) % 2))
205    }
206  }
207
208  // The pet faces right, running: feet step every other tick, eyes shut and a tear once it is over.
209  const sprite = kind.mini
210  const side = sprite.rows.length
211  const one: Pet = {
212    x: 0,
213    dir: 1,
214    frame: run.phase === 'running' && run.lift === 0 ? Math.floor(run.tick / 2) : 0,
215    mood: run.phase === 'over' ? 'sleep' : 'walk',
216    hold: 0,
217    idle: 0,
218  }
219  const top = groundTop - side - Math.round(run.lift)
220
221  stamp(spriteRows(one, sprite, run.phase === 'over'), kind.ink, PET_X, top)
222  // Dressed as the pane dresses it: a teen's accessory, an adult's gear.
223  for (const overlay of dressingOf(kind, sprite, false, stage, form, 1, false, one.frame)) {
224    stamp(overlay.rows, overlay.ink, PET_X + overlay.x, top + overlay.y)
225  }
226
227  return pixels
228}
229
hooks/species.ts 703 lines
1/**
2 * A species is drawn twice, one character per pixel, facing left: `big` at 12×12 and
3 * `mini` at 8×8 for the small pane. Two pixel rows make one terminal row of half blocks,
4 * so the big sprite is 6 rows tall and the mini one 4.
5 *
6 * To add one: draw both sprites' `rows`, give every character a color in `ink` (`.` is
7 * empty), and supply the rows that replace the eyes, the cheek row and the feet.
8 *
9 * @handbook 4.1-species-sprite-data
10 * @tested tests/pet.test.ts
11 */
12export type Sprite = {
13  rows: readonly string[]
14  /** Colors of its own, laid over the species' (an adult form's clothes). */
15  ink?: Readonly<Record<string, number>>
16  /** Where its head and accessory sit, when not where the baby's do. */
17  head?: Head
18  accessory?: Accessory
19  /** Drawn in its own gear (a hood): no cap or headset over it. */
20  hasOwnGear?: boolean
21  /** Rows replaced while it blinks, sleeps or is pleased, by index. */
22  eyesShut: Readonly<Record<number, string>>
23  /** Rows replaced when a rate limit is nearly used up, by index. */
24  tear: Readonly<Record<number, string>>
25  /** Rows replaced on every other tick so it steps, by index. */
26  feetApart: Readonly<Record<number, string>>
27}
28
29/**
30 * What a grown pet wears: pixels laid over the sprite (drawn facing left, `.` see-through),
31 * `x` and `y` from the sprite's top left, in colors of their own.
32 */
33export type Accessory = {
34  rows: readonly string[]
35  x: number
36  y: number
37  ink: Readonly<Record<string, number>>
38}
39
40/**
41 * A sprite's head, facing left: `top` the row its crown starts on between any ears, `eye`
42 * the eyes' row, `left` and `right` its widest columns there.
43 */
44export type Head = { top: number; eye: number; left: number; right: number }
45
46export type Species = {
47  label: string
48  ink: Readonly<Record<string, number>>
49  big: Sprite
50  mini: Sprite
51  /** The teen, from Lv 15: taller, with a body of its own. */
52  teen: Sprite
53  /** The rare adult, one evolution in twenty: the teen in colors of its own, with a mark of its own. */
54  rare: { name: string; ink: Readonly<Record<string, number>>; overlay: Accessory }
55  /** Adults drawn for one nature or another; any other nature's adult is the teen in its gear. */
56  adults?: Partial<Record<'worker' | 'scholar' | 'sweetie' | 'gamer' | 'curious', Sprite>>
57  /** The rare shiny's colors, laid over `ink`. */
58  shiny: Readonly<Record<string, number>>
59  /** Worn from the grown stage on, at each sprite size; `head` ones give way to a cap or a headset. */
60  accessory: { slot: 'head' | 'body'; big: Accessory; mini: Accessory }
61  /** Where its head is in each sprite, for what its nature puts on it. */
62  head: { big: Head; mini: Head }
63  purr: string
64}
65
66const RIBBON = { r: 0xff6b8a, R: 0xd94f6e } as const
67const GOLD = { y: 0xffd447 } as const
68const BLOOM = { f: 0xffffff, Y: 0xffd447 } as const
69const SCARF = { s: 0x6fa8dc, S: 0x4f86bd } as const
70const LEAF = { g: 0x7cc576 } as const
71
72export const DEFAULT_SPECIES = 'cat'
73
74export const SPECIES: Readonly<Record<string, Species>> = {
75  cat: {
76    label: 'cat',
77    ink: { o: 0xffd787, w: 0xfffaf0, c: 0xfff3dc, p: 0xffafd7, k: 0x5f4b4b, m: 0xd08770, b: 0x87d7ff },
78    big: {
79      rows: [
80        '.o........o.',
81        '.oo......oo.',
82        '.opoooooopo.',
83        'oooooooooooo',
84        'oowkoooowkoo',
85        'ookkooookkoo',
86        'oppoommooppo',
87        '.oooooooooo.',
88        '..ooccccoo..',
89        '..occcccco.o',
90        '..occccccooo',
91        '...oo..oo...',
92      ],
93      eyesShut: { 4: 'oooooooooooo' },
94      tear: { 6: 'obpoommooppo' },
95      feetApart: { 11: '..oo....oo..' },
96    },
97    mini: {
98      rows: [
99        'o......o',
100        'oo....oo',
101        'oooooooo',
102        'okooooko',
103        'poommoop',
104        '.oooooo.',
105        '.occccoo',
106        '..o..o..',
107      ],
108      eyesShut: { 3: 'oooooooo' },
109      tear: { 4: 'pbommoop' },
110      feetApart: { 7: '.o....o.' },
111    },
112    shiny: { o: 0xd9c2ff, m: 0xa98ad8 },
113    teen: {
114      rows: [
115        '.o......o...',
116        '.oo....oo...',
117        '.opoooopo...',
118        'ooooooooo...',
119        'owkooowko...',
120        'okkoookko...',
121        'oppomoppo...',
122        '.ooooooo....',
123        '..occco...o.',
124        '.oocccoo.oo.',
125        '..occcoooo..',
126        '..oo..oo....',
127      ],
128      eyesShut: { 4: 'ooooooooo...' },
129      tear: { 6: 'obpomoppo...' },
130      feetApart: { 11: '.oo....oo...' },
131      head: { top: 2, eye: 4, left: 0, right: 8 },
132      accessory: { rows: ['r...r', 'rrRrr', 'r...r'], x: 5, y: 0, ink: RIBBON },
133    },
134    rare: { name: 'celestial', ink: { o: 0xfff1d0, p: 0xffc9e3, c: 0xffffff }, overlay: { rows: ['.yyyy.', 'y....y'], x: 1, y: -2, ink: { y: 0xffe36e } } },
135    adults: {
136      worker: {
137        rows: [
138          '.o......o...',
139          '.oo....oo...',
140          '.opoooopo...',
141          'ooooooooo...',
142          'owkooowko...',
143          'okkoookko...',
144          'oppomoppo...',
145          '.ooooooo....',
146          '..bOOOb...o.',
147          '.obOOObo.oo.',
148          '..bOOObooo..',
149          '..bb..bb....',
150        ],
151        eyesShut: { 4: 'ooooooooo...' },
152        tear: { 6: 'obpomoppo...' },
153        feetApart: { 11: '.bb....bb...' },
154        ink: { b: 0x5b7fb8, O: 0x7fa6e0 },
155      },
156      scholar: {
157        rows: [
158          '.o......o...',
159          '.oo....oo...',
160          '.opoooopo...',
161          'ooooooooo...',
162          'gwkgggwkg...',
163          'gkkgogkkg...',
164          'oppomoppo...',
165          '.ooooooo....',
166          '..rrwrr...o.',
167          '.orrwrro.oo.',
168          '..rrrrrooo..',
169          '..oo..oo....',
170        ],
171        eyesShut: { 4: 'googggoog...' },
172        tear: { 6: 'obpomoppo...' },
173        feetApart: { 11: '.oo....oo...' },
174        ink: { g: 0xd9b26a, r: 0xb85a5a },
175      },
176      gamer: {
177        rows: [
178          '.h......h...',
179          '.hh....hh...',
180          '.hhhhhhhh...',
181          'hhooooooh...',
182          'hwkooowkh...',
183          'hkkoookkh...',
184          'hppomopph...',
185          '.hooooohh...',
186          '..hhhhh...o.',
187          '.ohhwhho.oo.',
188          '..hhhhhooo..',
189          '..oo..oo....',
190        ],
191        eyesShut: { 4: 'hoooooooh...' },
192        tear: { 6: 'hbpomopph...' },
193        feetApart: { 11: '.oo....oo...' },
194        ink: { h: 0x7a6fb8 },
195        hasOwnGear: true,
196      },
197      sweetie: {
198        rows: [
199          '.o......o...',
200          '.oo....oo...',
201          '.opoooopo...',
202          'ooooooooo...',
203          'owkooowko...',
204          'okkoookko...',
205          'oppomoppo...',
206          'fooooooof...',
207          '..oHcHo...o.',
208          '.ooHHHoo.oo.',
209          '..ocHcoooo..',
210          '..oo..oo....',
211        ],
212        eyesShut: { 4: 'ooooooooo...' },
213        tear: { 6: 'obpomoppo...' },
214        feetApart: { 11: '.oo....oo...' },
215        ink: { H: 0xff87af, f: 0xfff3dc },
216      },
217    },
218    head: { big: { top: 2, eye: 4, left: 0, right: 11 }, mini: { top: 2, eye: 3, left: 0, right: 7 } },
219    accessory: {
220      slot: 'head',
221      big: { rows: ['r...r', 'rrRrr', 'r...r'], x: 7, y: 0, ink: RIBBON },
222      mini: { rows: ['r.r', 'rRr'], x: 5, y: 0, ink: RIBBON },
223    },
224    purr: 'purr',
225  },
226  chick: {
227    label: 'chick',
228    ink: { o: 0xffe98a, w: 0xfffaf0, l: 0xfff6c9, p: 0xffafd7, k: 0x5f4b4b, y: 0xffaf5f, b: 0x87d7ff },
229    big: {
230      rows: [
231        '.....oo.....',
232        '....oo......',
233        '...oooooo...',
234        '..oooooooo..',
235        '.oowkoowkoo.',
236        '.ookkookkoo.',
237        '.oppoyyoppo.',
238        'looooooooool',
239        '.oooooooooo.',
240        '..oooooooo..',
241        '...oooooo...',
242        '...yy..yy...',
243      ],
244      eyesShut: { 4: '.oooooooooo.' },
245      tear: { 6: '.obpoyyoppo.' },
246      feetApart: { 11: '..yy....yy..' },
247    },
248    mini: {
249      rows: [
250        '...oo...',
251        '..oooo..',
252        '.oooooo.',
253        '.okooko.',
254        '.poyyop.',
255        'looooool',
256        '.oooooo.',
257        '..y..y..',
258      ],
259      eyesShut: { 3: '.oooooo.' },
260      tear: { 4: '.boyyop.' },
261      feetApart: { 7: '.y....y.' },
262    },
263    shiny: { o: 0xbfeaff, l: 0xe6f8ff },
264    teen: {
265      rows: [
266        '...o.o......',
267        '...ooo......',
268        '..oooooo....',
269        '.oooooooo...',
270        'oowkoowkoo..',
271        'ookkookkoo..',
272        'oppoyyoppo..',
273        '.oooooooo...',
274        'l.oooooo.l..',
275        'llooooooll..',
276        '..oooooo....',
277        '..yy..yy....',
278      ],
279      eyesShut: { 4: 'oooooooooo..' },
280      tear: { 6: 'obpoyyoppo..' },
281      feetApart: { 11: '.yy....yy...' },
282      head: { top: 2, eye: 4, left: 0, right: 9 },
283      accessory: { rows: ['.f.', 'fYf', '.f.'], x: 6, y: 0, ink: BLOOM },
284    },
285    rare: { name: 'phoenix', ink: { o: 0xff9a5c, l: 0xffc46b, y: 0xffd447 }, overlay: { rows: ['.r.r', 'rrrr', '.rr.'], x: 3, y: -3, ink: { r: 0xff5f5f } } },
286    head: { big: { top: 2, eye: 4, left: 1, right: 10 }, mini: { top: 1, eye: 3, left: 1, right: 6 } },
287    accessory: {
288      slot: 'head',
289      big: { rows: ['.f.', 'fYf', '.f.'], x: 7, y: 0, ink: BLOOM },
290      mini: { rows: ['.f.', 'fYf'], x: 5, y: 0, ink: BLOOM },
291    },
292    purr: 'cheep',
293  },
294  dog: {
295    label: 'dog',
296    ink: { o: 0xe8c39e, d: 0xa8754f, w: 0xfffaf0, c: 0xfff3dc, p: 0xffafd7, k: 0x5f4b4b, m: 0xd08770, b: 0x87d7ff },
297    big: {
298      rows: [
299        '...oooooo...',
300        '..oooooooo..',
301        'ddoooooooodd',
302        'ddoooooooodd',
303        'ddwkoooowkdd',
304        'ddkkooookkdd',
305        'dppocmmcoppd',
306        '.oooccccooo.',
307        '..oooooooo..',
308        '..occcccco.o',
309        '..occccccooo',
310        '...oo..oo...',
311      ],
312      eyesShut: { 4: 'ddoooooooodd' },
313      tear: { 6: 'dbpocmmcoppd' },
314      feetApart: { 11: '..oo....oo..' },
315    },
316    mini: {
317      rows: [
318        '.oooooo.',
319        'dooooood',
320        'dkooookd',
321        'dpcmmcpd',
322        '.oooooo.',
323        '.oooooo.',
324        '.occccoo',
325        '..o..o..',
326      ],
327      eyesShut: { 2: 'dooooood' },
328      tear: { 3: 'dbcmmcpd' },
329      feetApart: { 7: '.o....o.' },
330    },
331    shiny: { o: 0xf7f2ea, d: 0x8a8a9a },
332    teen: {
333      rows: [
334        '..oooooo....',
335        '.oooooooo...',
336        'ddoooooodd..',
337        'ddwkoowkdd..',
338        'ddkkookkdd..',
339        'dopcmmcpod..',
340        '.oocccooo...',
341        '..oooooo....',
342        '.ooccccoo.o.',
343        '..occccooo..',
344        '..oo..oo....',
345        '..oo..oo....',
346      ],
347      eyesShut: { 3: 'ddoooooodd..' },
348      tear: { 5: 'dobcmmcpod..' },
349      feetApart: { 11: '.oo....oo...' },
350      head: { top: 0, eye: 3, left: 0, right: 9 },
351      accessory: { rows: ['ssssss', '.sS...'], x: 2, y: 7, ink: SCARF },
352    },
353    rare: { name: 'moon wolf', ink: { o: 0xc3cde0, d: 0x6b7894, c: 0xeef2fa }, overlay: { rows: ['.y.', 'yyy'], x: 4, y: 1, ink: { y: 0xfff3b0 } } },
354    head: { big: { top: 0, eye: 4, left: 0, right: 11 }, mini: { top: 0, eye: 2, left: 0, right: 7 } },
355    accessory: {
356      slot: 'body',
357      big: { rows: ['ssssssss', '.sS.....'], x: 2, y: 8, ink: SCARF },
358      mini: { rows: ['ssssss'], x: 1, y: 4, ink: SCARF },
359    },
360    purr: 'woof',
361  },
362  slime: {
363    label: 'slime',
364    ink: { o: 0x9fe0a8, l: 0xe0f7e3, d: 0x6fbf7f, w: 0xfffaf0, p: 0xffafd7, k: 0x4b5f4f, m: 0x5a9a64, b: 0x87d7ff },
365    big: {
366      rows: [
367        '............',
368        '............',
369        '....oooo....',
370        '..oooooooo..',
371        '.oloooooooo.',
372        'oloooooooooo',
373        'oowkoooowkoo',
374        'ookkooookkoo',
375        'oppoommooppo',
376        'oooooooooooo',
377        'oooooooooooo',
378        '.dddddddddd.',
379      ],
380      eyesShut: { 6: 'oooooooooooo' },
381      tear: { 8: 'obpoommooppo' },
382      feetApart: { 11: 'dddddddddddd' },
383    },
384    mini: {
385      rows: [
386        '........',
387        '..oooo..',
388        '.oloooo.',
389        'oloooooo',
390        'okooooko',
391        'poommoop',
392        'oooooooo',
393        '.dddddd.',
394      ],
395      eyesShut: { 4: 'oooooooo' },
396      tear: { 5: 'pbommoop' },
397      feetApart: { 7: 'dddddddd' },
398    },
399    shiny: { o: 0xffb8dc, l: 0xffe6f2, d: 0xe48ab8, m: 0xc06a98 },
400    teen: {
401      rows: [
402        '.....o......',
403        '....oo......',
404        '...oooooo...',
405        '..oloooooo..',
406        '.oloooooooo.',
407        '.oloooooooo.',
408        'oowkoooowkoo',
409        'ookkooookkoo',
410        'oppoommooppo',
411        'oooooooooooo',
412        'oooooooooooo',
413        '.dddddddddd.',
414      ],
415      eyesShut: { 6: 'oooooooooooo' },
416      tear: { 8: 'obpoommooppo' },
417      feetApart: { 11: 'dddddddddddd' },
418      head: { top: 2, eye: 6, left: 0, right: 11 },
419      accessory: { rows: ['y.y.y', 'yyyyy'], x: 6, y: 0, ink: GOLD },
420    },
421    rare: { name: 'king', ink: { o: 0xb9a2ff, l: 0xe2d8ff, d: 0x8a6fe0, m: 0x6a50b8 }, overlay: { rows: ['y.y.y.y', 'yyyyyyy', 'yRyRyRy'], x: 3, y: -1, ink: { y: 0xffd447, R: 0xff6b8a } } },
422    head: { big: { top: 2, eye: 6, left: 0, right: 11 }, mini: { top: 1, eye: 4, left: 0, right: 7 } },
423    accessory: {
424      slot: 'head',
425      big: { rows: ['y.y.y', 'yyyyy'], x: 4, y: 0, ink: GOLD },
426      mini: { rows: ['y..y', 'yyyy'], x: 2, y: 0, ink: GOLD },
427    },
428    purr: 'blub',
429  },
430  bunny: {
431    label: 'bunny',
432    ink: { o: 0xece4f4, w: 0xffffff, c: 0xffffff, p: 0xffafd7, k: 0x5f4b5f, m: 0xd08790, b: 0x87d7ff },
433    big: {
434      rows: [
435        '..oo....oo..',
436        '..op....po..',
437        '..op....po..',
438        '..oo....oo..',
439        '.oooooooooo.',
440        'oowkoooowkoo',
441        'ookkooookkoo',
442        'oppoommooppo',
443        '.oooooooooo.',
444        '..occccccocc',
445        '..occccccocc',
446        '...oo..oo...',
447      ],
448      eyesShut: { 5: 'oooooooooooo' },
449      tear: { 7: 'obpoommooppo' },
450      feetApart: { 11: '..oo....oo..' },
451    },
452    mini: {
453      rows: [
454        '.op..po.',
455        '.op..po.',
456        '.oooooo.',
457        'okooooko',
458        'poommoop',
459        '.oooooo.',
460        '.occccoc',
461        '..o..o..',
462      ],
463      eyesShut: { 3: 'oooooooo' },
464      tear: { 4: 'pbommoop' },
465      feetApart: { 7: '.o....o.' },
466    },
467    shiny: { o: 0xffe6a0 },
468    teen: {
469      rows: [
470        '..oo..oo....',
471        '..op..po....',
472        '..op..po....',
473        '.oooooooo...',
474        'oowkoowkoo..',
475        'ookkookkoo..',
476        'oppommoppo..',
477        '.oooooooo...',
478        '..occccocc..',
479        '.oocccoocc..',
480        '..occcco....',
481        '..oo..oo....',
482      ],
483      eyesShut: { 4: 'oooooooooo..' },
484      tear: { 6: 'obpommoppo..' },
485      feetApart: { 11: '.oo....oo...' },
486      head: { top: 3, eye: 4, left: 0, right: 9 },
487      accessory: { rows: ['r.r', 'rRr'], x: 5, y: 0, ink: RIBBON },
488    },
489    rare: { name: 'moon', ink: { o: 0xdcdcf4, c: 0xf6f6ff }, overlay: { rows: ['.mm', 'm..', 'm..', '.mm'], x: 9, y: -1, ink: { m: 0xfff3b0 } } },
490    head: { big: { top: 4, eye: 5, left: 0, right: 11 }, mini: { top: 2, eye: 3, left: 0, right: 7 } },
491    accessory: {
492      slot: 'head',
493      big: { rows: ['r...r', 'rrRrr', 'r...r'], x: 4, y: 1, ink: RIBBON },
494      mini: { rows: ['rRr'], x: 3, y: 2, ink: RIBBON },
495    },
496    purr: 'boop',
497  },
498  hamster: {
499    label: 'hamster',
500    ink: { o: 0xf5b971, w: 0xfffaf0, c: 0xfff3dc, p: 0xffafd7, k: 0x5f4b4b, m: 0xd08770, b: 0x87d7ff },
501    big: {
502      rows: [
503        '............',
504        '..oo....oo..',
505        '.opoooooopo.',
506        '.oooooooooo.',
507        'oowkoooowkoo',
508        'ookkooookkoo',
509        'oppccmmccppo',
510        'ooccccccccoo',
511        'occcccccccco',
512        'occcccccccco',
513        '.ooccccccoo.',
514        '..pp....pp..',
515      ],
516      eyesShut: { 4: 'oooooooooooo' },
517      tear: { 6: 'obpccmmccppo' },
518      feetApart: { 11: '.pp......pp.' },
519    },
520    mini: {
521      rows: [
522        '.o....o.',
523        'oooooooo',
524        'okooooko',
525        'pccmmccp',
526        'occcccco',
527        'occcccco',
528        '.oooooo.',
529        '..p..p..',
530      ],
531      eyesShut: { 2: 'oooooooo' },
532      tear: { 3: 'bccmmccp' },
533      feetApart: { 7: '.p....p.' },
534    },
535    shiny: { o: 0xd6d8e8 },
536    teen: {
537      rows: [
538        '..oo....oo..',
539        '.opoooooopo.',
540        '.oooooooooo.',
541        '.owkoooowko.',
542        '.okkooookko.',
543        'oppccmmccppo',
544        '.occcccccco.',
545        '..occcccco..',
546        '.poccccccop.',
547        '..occcccco..',
548        '...oo..oo...',
549        '...pp..pp...',
550      ],
551      eyesShut: { 3: '.oooooooooo.' },
552      tear: { 5: 'obpccmmccppo' },
553      feetApart: { 11: '..pp....pp..' },
554      head: { top: 1, eye: 3, left: 1, right: 10 },
555      accessory: { rows: ['..gg', '.gg.', 'gg..'], x: 8, y: -1, ink: LEAF },
556    },
557    rare: { name: 'golden', ink: { o: 0xffcf4d, c: 0xfff1c0 }, overlay: { rows: ['.s.', 'sws', '.s.'], x: 9, y: -2, ink: { s: 0xffe36e, w: 0xffffff } } },
558    head: { big: { top: 2, eye: 4, left: 0, right: 11 }, mini: { top: 1, eye: 2, left: 0, right: 7 } },
559    accessory: {
560      slot: 'head',
561      big: { rows: ['..gg', '.gg.', 'gg..'], x: 8, y: 0, ink: LEAF },
562      mini: { rows: ['.g', 'g.'], x: 6, y: 0, ink: LEAF },
563    },
564    purr: 'squeak',
565  },
566  penguin: {
567    label: 'penguin',
568    ink: { o: 0x4f5b82, c: 0xfffaf0, e: 0x2e2e3a, p: 0xffafd7, y: 0xffaf5f, b: 0x87d7ff },
569    big: {
570      rows: [
571        '...oooooo...',
572        '..oooooooo..',
573        '.oooooooooo.',
574        '.ooccooccoo.',
575        '.occcccccco.',
576        '.occeccecco.',
577        '.oceecceeco.',
578        '.ocpcyycpco.',
579        'oocccccccooo',
580        'ooccccccccoo',
581        '.oocccccco..',
582        '...yy..yy...',
583      ],
584      eyesShut: { 5: '.occcccccco.' },
585      tear: { 7: '.ocbcyycpco.' },
586      feetApart: { 11: '..yy....yy..' },
587    },
588    mini: {
589      rows: [
590        '..oooo..',
591        '.oooooo.',
592        '.occcco.',
593        'ocecceco',
594        'opcyycpo',
595        'occcccco',
596        '.occcco.',
597        '..y..y..',
598      ],
599      eyesShut: { 3: 'occcccco' },
600      tear: { 4: 'obcyycpo' },
601      feetApart: { 7: '.y....y.' },
602    },
603    shiny: { o: 0x8f64bf },
604    teen: {
605      rows: [
606        '...oooooo...',
607        '..oooooooo..',
608        '.ooccooccoo.',
609        '.occeccecco.',
610        '.oceecceeco.',
611        '.ocpcyycpco.',
612        'oocccccccoo.',
613        'oocccccccoo.',
614        '.occcccccco.',
615        '.occcccccco.',
616        '..occcccco..',
617        '...yy..yy...',
618      ],
619      eyesShut: { 3: '.occcccccco.' },
620      tear: { 5: '.ocbcyycpco.' },
621      feetApart: { 11: '..yy....yy..' },
622      head: { top: 0, eye: 4, left: 1, right: 10 },
623      accessory: { rows: ['...ww...', '.rrrrrr.', 'RrRrRrRr'], x: 2, y: -2, ink: { r: 0xff6b8a, R: 0xd94f6e, w: 0xfffaf0 } },
624    },
625    rare: { name: 'emperor', ink: { o: 0x2f3a66, p: 0xffc46b, y: 0xffb03b }, overlay: { rows: ['yy......yy', '.y......y.'], x: 1, y: 3, ink: { y: 0xffd447 } } },
626    head: { big: { top: 0, eye: 5, left: 1, right: 10 }, mini: { top: 0, eye: 3, left: 0, right: 7 } },
627    accessory: {
628      slot: 'head',
629      big: { rows: ['...ww...', '.rrrrrr.', 'RrRrRrRr'], x: 2, y: -2, ink: { r: 0xff6b8a, R: 0xd94f6e, w: 0xfffaf0 } },
630      mini: { rows: ['.ww.', 'RrRr'], x: 2, y: -1, ink: { r: 0xff6b8a, R: 0xd94f6e, w: 0xfffaf0 } },
631    },
632    purr: 'peep',
633  },
634  frog: {
635    label: 'frog',
636    ink: { o: 0x8fd18a, w: 0xfffaf0, c: 0xf0f7d4, p: 0xffafd7, k: 0x3f5a3f, m: 0x4f8a4a, b: 0x87d7ff },
637    big: {
638      rows: [
639        '............',
640        '.oooo..oooo.',
641        '.owko..owko.',
642        '.okko..okko.',
643        'oooooooooooo',
644        'oppooooooppo',
645        'oooommmmoooo',
646        '.oooooooooo.',
647        '..occcccco..',
648        '.ooccccccoo.',
649        '.ooccccccoo.',
650        '.ooo....ooo.',
651      ],
652      eyesShut: { 2: '.oooo..oooo.' },
653      tear: { 5: 'obpooooooppo' },
654      feetApart: { 11: 'ooo......ooo' },
655    },
656    mini: {
657      rows: [
658        '.oo..oo.',
659        '.ko..ok.',
660        'oooooooo',
661        'pommmmop',
662        '.oooooo.',
663        '.occcco.',
664        '.oooooo.',
665        'oo....oo',
666      ],
667      eyesShut: { 1: '.oo..oo.' },
668      tear: { 3: 'bommmmop' },
669      feetApart: { 7: '.oo..oo.' },
670    },
671    shiny: { o: 0x93c6ff, m: 0x4f7fc0 },
672    teen: {
673      rows: [
674        '.oooo..oooo.',
675        '.owko..owko.',
676        '.okko..okko.',
677        '.oooooooooo.',
678        'oppooooooppo',
679        '.oommmmmmoo.',
680        '..oooooooo..',
681        '...occcco...',
682        '.oooccccooo.',
683        'o..occcco..o',
684        '...oo..oo...',
685        '..ooo..ooo..',
686      ],
687      eyesShut: { 1: '.oooo..oooo.' },
688      tear: { 4: 'obpooooooppo' },
689      feetApart: { 11: '.ooo....ooo.' },
690      head: { top: 0, eye: 1, left: 1, right: 10 },
691      accessory: { rows: ['y.y.y', 'yyyyy'], x: 4, y: -2, ink: GOLD },
692    },
693    rare: { name: 'prince', ink: { o: 0x5fc9b8, m: 0x2f8a7c, c: 0xd8f7f0 }, overlay: { rows: ['y.y.y', 'yyyyy', 'yRyRy'], x: 4, y: -3, ink: { y: 0xffd447, R: 0x6fa8dc } } },
694    head: { big: { top: 1, eye: 2, left: 1, right: 10 }, mini: { top: 0, eye: 1, left: 1, right: 6 } },
695    accessory: {
696      slot: 'head',
697      big: { rows: ['y.y.y', 'yyyyy'], x: 4, y: 0, ink: GOLD },
698      mini: { rows: ['yy'], x: 3, y: 0, ink: GOLD },
699    },
700    purr: 'ribbit',
701  },
702}
703
types/index.d.ts 76 lines
1/**
2 * The state contract: what the `pets` atoms and `$.store` hold.
3 * @handbook 1.2-state-layers
4 */
5export type Pet = {
6  x: number
7  dir: 1 | -1
8  frame: number
9  mood: 'walk' | 'sleep' | 'happy' | 'work' | 'love' | 'gift'
10  hold: number
11  idle: number
12}
13
14/** What one pet has lived through. Each species is its own pet, with its own name and level. */
15export type PetStats = {
16  name: string
17  pats: number
18  tools: number
19  turns: number
20  /** Output tokens of the turns it watched. */
21  tokens: number
22  /** Snacks eaten in Pet Run and Pet Quest together. */
23  snacks: number
24  /** Pet Run's best score. */
25  best: number
26  /** Pet Quest stages cleared, counted from the first. */
27  cleared: number
28  /** Gifts found, the experience they and lucky pats brought, and whether it is a rare shiny. */
29  gifts: number
30  bonus: number
31  shiny: boolean
32  /** Finished turns by local quarter of the day (0–6, 6–12, 12–18, 18–24): its rhythm. */
33  hours: number[]
34  /** What it became at Lv 40: its nature then, or 'rare'; empty before. */
35  form: string
36  /**
37   * Its leaning, worker / scholar / sweetie / gamer, shares that follow what recent turns
38   * brought: it shapes the yard, its nature, and an adult's form over time.
39   */
40  leaning: number[]
41  /** The local day being tallied (YYYY-MM-DD), and what it has brought so far to each side, in leaning order. */
42  day: string
43  brought: number[]
44}
45
46/** What the Pet Quest pane's text shows. */
47export type QuestView = {
48  stage: number
49  phase: 'ready' | 'running' | 'clear' | 'over'
50  score: number
51  snacks: number
52}
53
54/** What the Pet Run pane's text shows: the game's own state lives in the hooks module. */
55export type RunView = {
56  phase: 'ready' | 'running' | 'over'
57  score: number
58  snacks: number
59}
60
61/** How tall the pane draws the pet: `small` is the 8×8 sprite, `medium` the 12×12 one. A stored size no longer offered reads as the default. */
62export type PetSize = 'small' | 'medium'
63
64/** What is kept across sessions: which pet is out, how big it is drawn, and every pet met so far by species. */
65export type PetProfile = {
66  species: string
67  size: PetSize
68  pets: Record<string, PetStats>
69}
70
71declare module 'claude-code' {
72  interface PluginState {
73    'pets': { pet: Pet; profile: PetProfile; isWorried: boolean; run: RunView; quest: QuestView }
74  }
75}
76