SLOPSHOPPER

clawd-tales

A pixel Clawd acts out every tool call above the prompt; helpers wander in by model color. Terminal-first, no model calls.

newbandguardcommandtoastprompt
★ 2v0.6.1MITupdated 2026-10-08plaxagoras/clawd-tales
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd-tales
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /tales ⎿ clawd-tales: Clawd tales is on (lively) in the meadow. Clawd is a hatchling: 9/200 tool calls to grow up. Use /tales on|off ▄▀▀▄ ▄ ▄ ▄ ▀▀▀▀ ▄ ▀▀▀▀▀▀▀▄ ▄▀▀▀▄ ▄▀▀▀▄ ▄▀▀▀▄ ▀▀▀▀▀▀▀▀▀▀▀ ▀▄▄ ▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀ ▀▄▀▄▄▄▄▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄ The end · 9 steps · 42s ctx 49% · 5h 31% ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▄▀▀▄ ▄ ▄ ▄ ▀▀▀▀ ▄ ▀▀▀▀▀▀▀▄ ▄▀▀▀▄ ▄▀▀▀▄ ▄▀▀▀▄ ▀▀▀▀▀▀▀▀▀▀▀ ▀▄▄ ▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀ ▀▄▀▄▄▄▄▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄ The end · 9 steps · 42s ctx 49% · 5h 31% ⟨Claude Code's own drawing⟩
README

clawd-tales

A pixel Clawd that acts out what Claude Code is doing, in a band above your prompt.

Claude Fables draws a model-written cartoon of your session in the desktop app. clawd-tales does it in the terminal from templates, so it costs no tokens.

clawd-tales demo

The 45-second /tales demo in the dungeon: Clawd hatches and changes into Opus, helpers walk in, slimes get loose and Clawd fights them, Claude calls for you, a note goes up on the wall, and Clawd grows up.

Glance down and you know what Claude is doing. A red ! and a jumping Clawd means it is waiting on you. Rain means the context is nearly full.

Every tool call gets a pose and a one-line caption. Claude reads a book for Read, digs for Edit, sneaks for Grep, runs for Bash, flies for web search. Subagents walk in as their own critters, colored by model.

A minute in the band

You ask Claude to fix a failing test. Clawd runs off to pytest and three bugs drop onto the ground. It reads the test file with a book open, then digs into the code. A red ! appears and Clawd jumps: Claude wants to run a command. You approve within a few seconds, and Clawd beams with hearts. The tests go green, Clawd eats all three bugs, and a turn that long ends in a dance.

What it shows

Happening in Claude CodeIn the band
A tool callClawd's pose and a caption naming the file, pattern or command
A subagent startsA helper walks in: purple for Opus, teal for Sonnet, green for Haiku, gold for Fable
A subagent finishesIt runs back to Clawd and they cheer, with hearts
A permission dialog opensClawd jumps next to a red ! and the caption turns red
A test run failsBugs drop onto the ground, one per failing test (up to 8)
The tests go greenClawd eats the bugs
A todo listPending items sit on the ground as pellets. Finishing one is a gobble
Context fills upClouds at 50%, rain and an umbrella at 70%, lightning at 85%
The turn endsA nod if it took under 4 seconds, a hop up to a minute, a dance up to five, and past that a flag planted at the summit. Then Clawd sits, yawns and falls asleep. The band hides after 3 minutes; your next prompt wakes Clawd, and a new helper brings the band back
A plan limit hits 100%Clawd sleeps until the reset time. A minute before, it stretches, and once the limit resets it's up
Claude waits on a subagentClawd puts on headphones. With two or more out, it juggles one ball per helper, each in that helper's color
Three or more subagents outClawd wears a tie. It's the boss now
An MCP tool callA ring of color spreads from Clawd. Each server gets its own color
Every 50K tokensConfetti, and the count in the turn's last caption
Claude thinks for a while between tool callsA thought bubble fills in over the pose it is holding
Five clean tool calls in a rowA combo counter. At ten, Clawd wears a crown
A subagent failsIt trips, then walks back to Clawd for a pat on the head
Four or more subagents out at onceThe idle ones walk in a line behind the first one
Two errors in a rowClawd goes cross-eyed with stars circling. A third flips the table
You approve a permission prompt quicklyHearts. Leave one waiting past 30 seconds and Clawd starts to sweat, with a timer in the caption
The conversation is compactedClawd sweeps the stage with a broom and the weather clears
Claude writes to a memory file (a MEMORY.md, or anything in a memory/ folder)Clawd holds up a sticky note and pins it to the wall
You switch models between turnsClawd hops in the new model's hat for a few seconds: a sprout for Haiku, a beret for Sonnet, a purple crown for Opus, a gold one for Fable
A turn dies on an API errorClawd ends it next to a sparking grey cloud, sweating
A session startsClawd drops in from the sky. A resumed session gets a wave hello. The first new session after you install hatches Clawd from an egg, and progress starts counting from there
200 tool callsThe hatchling loses its eggshell cap and grows up into whatever work it did most: a scholar (reading), a detective (searching), a builder (editing), a hacker (commands), an explorer (the web) or a captain (helpers). Each wears its own hat, and the form can change as your habits do

Small things

  • Thank Claude in your prompt and Clawd blushes with heart eyes. Start with "no" or "wrong" and it rubs its head, brows up, and can't look you in the eye.
  • Clawd dresses for the job: a wizard hat in plan mode, a hard hat two minutes into a long turn, and shades for the rest of a turn once it has earned the crown.
  • git commit wraps a parcel and pops confetti. git push sends a paper plane off the stage. rm -rf makes Clawd cover its eyes. Package installs drop boxes from the sky.
  • A command that isn't found sends the sl train across the stage.
  • While it waits for you, Clawd waves, winks, scratches its head, looks around and watches a butterfly. A longer wait and it goes fishing. Asleep, it dreams about the last thing it did.
  • Clawd glances left and right now and then, and looks over when a helper walks in. Sitting, it breathes.
  • Clawd never pops from standing to sitting. It crouches on the way down and gets drowsy before it lies down. /tales calm skips these.
  • The sky follows your clock: sun by day, stars at night. Between 1 and 5 am, Clawd wears a nightcap and keeps a mug of coffee nearby.
  • Halloween, Christmas, New Year's, Valentine's Day and April 1st each get a costume or something extra.
  • Now and then a whale, a UFO or the train passes through. One session in a hundred brings a shiny Clawd.

Under the caption, a status line shows ctx 74% · 5h 22% · 7d 23%, plus the todo count and a roster of the helpers. Numbers turn yellow at 80% and red at 95%.

The dungeon

/tales theme dungeon sends Clawd down a brick hallway lit by wall lamps. The story is the same, told underground:

  • The lamps are your context. As it fills they burn low, then half of them go out, and near the end only the lamp next to Clawd still flickers. Compaction refills them.
  • Reading a file pulls out a scroll instead of a book.
  • Some shell commands are spells: Clawd raises a wand with a twinkling star.
  • Your todo list is a row of treasure chests. Finishing a task opens one and coins spill out.
  • Every clean tool call pops a gold coin over Clawd's head, and the status line keeps the count.
  • A failing test run lets slimes loose on the floor. Until the tests pass, every edit or command is a sword fight. A green run squashes them for gold.
  • A failed tool call brings out a skeleton. The next clean call beats it.

The band shrinks with your terminal: the full band at 8 rows, no sky at 6, then a single line.

Install

Needs a Claude Code build with mods (function hooks). Built and tested on 2.1.289; run claude --version to see yours. After installing, start a new session and type /: if /tales isn't in the list, the mod didn't load, and your build probably doesn't support mods yet.

claude plugin marketplace add plaxagoras/clawd-tales
claude plugin install clawd-tales@clawd-tales

Start a new session, then run /tales demo.

To try it for one session without installing:

git clone https://github.com/plaxagoras/clawd-tales
claude --plugin-dir ./clawd-tales

Commands

CommandWhat it does
/tales demoPlays a short story: a hatching, a model switch, helpers, bugs, pellets, weather, a permission call, a memory note and growing up. Your own Clawd's progress isn't touched
/tales theme dungeon / meadowSwaps the meadow for a lamplit dungeon hallway (/tales dungeon works too). Saved between sessions
/tales calmOne frame a second, and nobody wanders between tool calls
/tales livelyBack to 5 frames a second (the default)
/tales off / /tales onHide or show the band
/talesShows the current state
/tales hat <name>Puts on a top hat, grad cap, captain's hat, wizard hat, hard hat, deerstalker, beanie, pith helmet or eggshell (none takes it off). Saved between sessions
/tales face <name>Glasses, shades or a mustache (none takes them off). Saved between sessions
/tales scarf on / offEach project gets its own scarf color, picked from the project folder, so you can tell repos apart. On by default
/tales holiday <name>Previews a holiday look until the next restart (auto goes back to the calendar)

Your on/off and calm settings are saved between sessions.

How it works

It is a Claude Code mod: a plugin of function hooks in hooks/register.tsx. It listens to tool.call, agent.spawn, classic.PermissionRequest, classic.UserPromptSubmit (for plan mode), session.measure and the turn events, and draws with ui.render on the AbovePrompt band. Each terminal cell is two pixels (▀ with a foreground and a background color).

  • No network, no model calls. It only reads what the hooks hand it, and the only things it saves are your settings (on/off, calm, hat, face, scarf) and Clawd's tool-call tally in Claude Code's plugin store.
  • Test results come from the Bash output of commands that look like test runs (npm test, pytest, cargo test, go test and similar).
  • Subagent end is detected by polling $.agent.list().

Limits

  • A test runner whose command isn't on the list never drops bugs.
  • A helper's finish can show a second or so late (about 5 seconds in calm mode), because the end is polled.

Add a pose

Poses are pixel rows in sprite() in hooks/art.ts. Which tool gets which pose and caption is beat() in hooks/story.ts. Run claude --plugin-dir . and your edits reload as you go; claude plugin test . runs the tests. Pull requests with a short GIF of the new pose are welcome.

Credits

The walk and cheer frames and the base palette come from Claude Fables by henrik-thevibe (MIT). See NOTICE.

Clawd is Anthropic's mascot. This is an unofficial fan project, not affiliated with Anthropic.

License

MIT

Source 4 files
hooks/register.tsx 1100 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Action, Emote, Face, Form, Fx, FxKind, Hat, Hero, Holiday, Limit, Stats, Theme, Worker } from '../types'
5import { CONGA_AT, HERO_W, SCENE_COUNT, TIER_COLOR, glyph, lampsOf, transition, lines, speed, stage, tierOf, weatherOf } from './art'
6import type { Egg } from './story'
7import {
8  COMBO_AT,
9  CROWN_AT,
10  FORM_HAT,
11  GROW_AT,
12  ambushCaption,
13  dungeonBeat,
14  victoryCaption,
15  NEW_STATS,
16  REST,
17  THINKING,
18  bashEggs,
19  beat,
20  clockTime,
21  countCall,
22  eggBeat,
23  eggDone,
24  ending,
25  endingAction,
26  fidgetAt,
27  fishing,
28  formOf,
29  growCaption,
30  holidayOf,
31  isLate,
32  isMidnightNewYear,
33  modelSwitch,
34  moodOf,
35  notFound,
36  readStats,
37  restCaption,
38  scarfColor,
39  skyOf,
40  startCaption,
41  stuckCaption,
42  testCounts,
43  tokenMilestone,
44  tripCaption,
45  waitBeat,
46  warpColor,
47} from './story'
48
49const IDLE: Hero = { mode: 'idle', action: 'walk', caption: '', x: 0, dir: 1, scene: 0, steps: 0, since: 0 }
50
51const hero = atom({ plugin: 'clawd-tales', key: 'hero' } as const, IDLE)
52const workers = atom({ plugin: 'clawd-tales', key: 'workers' } as const, [])
53const frame = atom({ plugin: 'clawd-tales', key: 'frame' } as const, 0)
54const isOn = atom({ plugin: 'clawd-tales', key: 'isOn' } as const, true)
55const alert = atom({ plugin: 'clawd-tales', key: 'alert' } as const, null)
56const context = atom({ plugin: 'clawd-tales', key: 'context' } as const, null)
57const limits = atom({ plugin: 'clawd-tales', key: 'limits' } as const, [])
58const calm = atom({ plugin: 'clawd-tales', key: 'calm' } as const, false)
59const bugs = atom({ plugin: 'clawd-tales', key: 'bugs' } as const, 0)
60const todos = atom({ plugin: 'clawd-tales', key: 'todos' } as const, null)
61const fx = atom({ plugin: 'clawd-tales', key: 'fx' } as const, [])
62const shiny = atom({ plugin: 'clawd-tales', key: 'shiny' } as const, null)
63const combo = atom({ plugin: 'clawd-tales', key: 'combo' } as const, 0)
64const gold = atom({ plugin: 'clawd-tales', key: 'gold' } as const, 0)
65const theme = atom({ plugin: 'clawd-tales', key: 'theme' } as const, 'meadow' as Theme)
66
67const TICK_MS = 200
68const CALM_TICK_MS = 1000 // calm mode: one beat a second
69const CLIP_STEP_MS = TICK_MS // each in-between pose holds for one beat
70const POLL_EVERY = 5 // ticks between $.agent.list() checks
71const THINK_AFTER_MS = 15000 // a helper with no tool call for this long wanders again
72const TRIP_MS = 2500
73const LINGER_MS = 8000 // the closing cheer lasts this long, then the rest ladder
74const HUG_MS = 3000 // a helper that made it back stays this long with hearts
75const RETURN_MAX_MS = 10_000 // a helper that can't reach Claude hands in anyway
76const ENDED = new Set(['completed', 'failed', 'killed'])
77const THINK_POSE_MS = 8000 // no tool call for this long mid-turn: a thought bubble
78const MOOD_MS = 4000 // a blush or a sweat drop lasts this long
79const VISITOR_ODDS = 1 / 25 // per prompt
80const SHINY_ODDS = 1 / 100 // per session
81const FX_MS: Record<FxKind, number> = { confetti: 2200, plane: 3500, boxes: 6000, train: 8000, whale: 16000, ufo: 10000, warp: 1200, coin: 900, coins: 1600, poof: 700 }
82const HOLIDAYS: readonly Holiday[] = ['halloween', 'christmas', 'newyear', 'valentine', 'aprilfools']
83
84// Module-level: a reload starts these over, and session.start picks the story back up.
85let timer: Timer | undefined
86let isCalm = false // mirrors the calm atom for the tick's own pacing
87let isDungeon = false // mirrors the theme atom, for captions
88let ticks = 0
89let cols = 100
90let heroCalls = 0 // main-loop tool calls still running: a trip waits for them to end
91let tripUntil = 0
92let startedAt = 0
93let lastCallAt = 0 // the last main-loop tool call's end, for the thought bubble
94let best = 0 // the turn's best combo
95let fxId = 0
96let holidayPick: Holiday | 'none' | null = null // /tales holiday: a preview, this load only
97let alertAt: number | null = null // when the current call for the user began
98let errStreak = 0 // main-loop errors in a row
99let nextGlanceAt = 0
100let planMode = false // the last prompt went in under plan mode: the wizard hat
101let lastModel: string | null = null // the session's model at the last turn start
102let modelHat: { hat: Hat; until: number } | null = null // tried on after a model switch
103const MODEL_HAT_MS = 8000
104let agentCalls = 0 // main-loop Agent calls still running: Claude is waiting on helpers
105let sessionTokens = 0 // input, cache writes and output this session; cache reads left out
106let tokensSeen = 0 // the count at the last main-loop ending, so a helper's crossing waits for it
107const HARDHAT_MS = 120_000 // two minutes into a turn: hard hat on
108const WAKE_MS = 60_000 // a minute before a limit resets, Claude stretches; a minute after, it's up
109const TIE_AT = 3 // helpers out for the boss tie
110const FAST_YES_MS = 6000 // approved and done this fast: hearts
111const NERVOUS_MS = 30_000 // a call for the user waiting this long: a sweat drop and a timer
112const FLIP_MS = 4000
113let wornHat: Hat | null = null // /tales hat, saved in $.store
114let wornFace: Face | null = null // /tales face, saved in $.store
115const HATS_TO_WEAR: readonly Hat[] = ['tophat', 'gradcap', 'captain', 'wizard', 'hardhat', 'deerstalker', 'beanie', 'pith', 'shell', 'witch', 'santa', 'party', 'nightcap', 'crown']
116let stats: Stats | null = null // lifetime progress from $.store; null until Clawd has hatched
117let scarfOn = true // /tales scarf, saved in $.store
118let scarfKey: string | null = null // the project's scarf color
119const HATCH_MS = 2400
120let demoGrowth: Form | 'shell' | null = null // /tales demo's own hatch and growth; real stats untouched
121const FACES_TO_WEAR: readonly Face[] = ['glasses', 'shades', 'mustache']
122const workerBeatAt = new Map<string, number>()
123const workerTripUntil = new Map<string, number>()
124const returningSince = new Map<string, number>()
125
126/** Steps x along the stage, turning at either edge. */
127function walk<T extends { x: number; dir: 1 | -1 }>(it: T, action: Action): T {
128  const v = speed(action)
129  if (v <= 0) return it
130  const max = Math.max(0, cols - HERO_W)
131  let x = it.x + v * it.dir
132  let dir = it.dir
133  if (x >= max) [x, dir] = [max, -1]
134  if (x <= 0) [x, dir] = [0, 1]
135  return { ...it, x, dir }
136}
137
138/** The rest ladder, by time since the turn's cheer ended. Null once Claude has gone. */
139function restStep(since: number, now: number, lim: Limit | null, last?: Action): Pick<Hero, 'action' | 'caption' | 'emote'> | null {
140  const resets = lim?.resetsAt ? Date.parse(lim.resetsAt) : NaN
141  // The figure stays at 100 until the next response, so past the reset the clock decides.
142  if (lim && lim.percent >= 100 && !(now >= resets + WAKE_MS)) {
143    if (now >= resets) return { action: 'cheer', caption: `The ${lim.kind} limit has reset. Claude is up and ready!`, emote: null }
144    if (now >= resets - WAKE_MS) return { action: 'stretch', caption: `Claude stretches. The ${lim.kind} limit resets at ${clockTime(lim.resetsAt)}.`, emote: null }
145    return { action: 'sleep', caption: `Claude sleeps until the ${lim.kind} limit resets at ${clockTime(lim.resetsAt)}. z z z`, emote: null }
146  }
147  const t = now - since
148  if (t >= REST.hideMs) return null
149  const action = t < REST.sitMs ? 'sit' : t < REST.yawnMs ? 'yawn' : 'sleep'
150  const fidget = action === 'sit' ? (fishing(t) ?? fidgetAt(t, Math.floor(since / 1000))) : null
151  if (fidget) return { action: fidget.action, caption: fidget.caption, emote: fidget.emote ?? null }
152  return { action, caption: restCaption(action, last, isDungeon), emote: null }
153}
154
155function holidayNow(d: Date): Holiday | null {
156  if (holidayPick === 'none') return null
157  return holidayPick ?? holidayOf(d)
158}
159
160/** Claude's eyes dart toward something for a moment. */
161async function glanceAt($: EngineInterface, dir: -1 | 1, ms = 1500) {
162  const now = await $.clock.now()
163  await update($, hero, (cur): Hero => ({ ...cur, glance: dir, glanceUntil: now + ms }))
164}
165
166async function addFx($: EngineInterface, kind: FxKind, x: number) {
167  if ((await read($, hero)).mode === 'idle') return
168  const start = await $.clock.now()
169  fxId += 1
170  const one: Fx = { id: fxId, kind, start, dur: FX_MS[kind], x }
171  await update($, fx, list => [...list.filter(f => f.kind !== kind), one])
172  ensureTimer($)
173}
174
175/** A rare visitor crosses during the turn: a sky whale, a UFO, or the train. */
176function maybeVisitor($: EngineInterface, force?: FxKind) {
177  if (!force && Math.random() >= VISITOR_ODDS) return
178  const kinds: FxKind[] = ['whale', 'ufo', 'train']
179  const kind = force ?? kinds[Math.floor(Math.random() * kinds.length)] ?? 'whale'
180  $.clock.after(force ? 0 : 3000 + Math.floor(Math.random() * 12000), () => void addFx($, kind, 0))
181}
182
183function ensureTimer($: EngineInterface) {
184  if (timer) return
185  timer = $.clock.every(isCalm ? CALM_TICK_MS : TICK_MS, async () => {
186    ticks += 1
187    const now = await $.clock.now()
188    let cur = await read($, hero)
189    const crew = await read($, workers)
190
191    if (cur.mode === 'ending' && now - cur.since >= LINGER_MS) {
192      cur = { ...cur, mode: 'resting', since: now, action: 'sit', caption: restCaption('sit', undefined, isDungeon) }
193      await update($, hero, () => cur)
194    }
195    if (cur.emote && cur.mode !== 'resting' && cur.emote !== 'think' && now > (cur.emoteUntil ?? 0)) {
196      cur = { ...cur, emote: null }
197      await update($, hero, () => cur)
198    }
199    if (cur.mode === 'resting') {
200      const step = restStep(cur.since, now, topLimit(await read($, limits)), cur.last)
201      const next: Hero = step ? { ...cur, ...step } : { ...cur, mode: 'idle', emote: null }
202      if (next.action !== cur.action || next.mode !== cur.mode || next.caption !== cur.caption || next.emote !== cur.emote) {
203        cur = next
204        await update($, hero, () => next)
205        if (next.mode === 'idle') await update($, fx, () => [])
206      }
207    }
208    let effects = await read($, fx)
209    if (effects.some(f => now - f.start >= f.dur)) {
210      effects = effects.filter(f => now - f.start < f.dur)
211      await update($, fx, () => effects)
212    }
213    if (cur.mode === 'idle' && crew.length === 0 && effects.length === 0) {
214      timer?.cancel()
215      timer = undefined
216      return
217    }
218    const f = (await read($, frame)) + 1
219    await update($, frame, () => f)
220    // Now and then the eyes wander: a look left or right for a second.
221    if (cur.mode !== 'idle' && now >= nextGlanceAt) {
222      if (nextGlanceAt > 0 && cur.action !== 'sleep') await glanceAt($, Math.random() < 0.5 ? -1 : 1, 1200)
223      nextGlanceAt = now + 5000 + Math.floor(Math.random() * 7000)
224    }
225
226    if (cur.mode === 'working' && !(await read($, alert))) {
227      let next: Hero = cur
228      // Claude keeps the last pose through the model's thinking until the next tool call;
229      // only a trip, once played, goes back to wandering.
230      if ((cur.action === 'trip' || cur.action === 'dizzy' || cur.action === 'flip') && heroCalls === 0 && now >= tripUntil) {
231        next = { ...next, action: 'walk', caption: THINKING[f % THINKING.length] ?? '' }
232      }
233      // A long think: the pose and caption stay, a thought bubble rises.
234      if (!cur.emote && heroCalls === 0 && now - lastCallAt >= THINK_POSE_MS && next.action !== 'trip' && next.action !== 'sweep') {
235        next = { ...next, emote: 'think', emoteUntil: Infinity }
236      }
237      // Waiting on an Agent call, Claude sits with the helpers' music or juggling: no wandering.
238      if (!isCalm && agentCalls === 0) next = walk(next, next.action)
239      if (next !== cur) await update($, hero, () => next)
240    }
241
242    if (crew.length > 0) {
243      const homeX = cur.x
244      const arrived: string[] = []
245      await update($, workers, list =>
246        list.map(w => {
247          if (w.state === 'returning') {
248            const gap = homeX - w.x
249            const late = now - (returningSince.get(w.id) ?? now) > RETURN_MAX_MS
250            if (Math.abs(gap) <= HERO_W - 4 || late) {
251              arrived.push(w.id)
252              return { ...w, state: 'done', action: w.sad ? 'sit' : 'cheer', dir: gap >= 0 ? 1 : -1 }
253            }
254            const dir: 1 | -1 = gap > 0 ? 1 : -1
255            const step = Math.min(Math.abs(gap), isCalm ? 8 : 1)
256            return { ...w, dir, x: w.x + dir * step }
257          }
258          if (w.state !== 'running') return w
259          const quiet = now - (workerBeatAt.get(w.id) ?? 0) > THINK_AFTER_MS
260          const tripping = now < (workerTripUntil.get(w.id) ?? 0)
261          const action: Action = quiet && !tripping ? 'walk' : w.action
262          return isCalm ? { ...w, action } : walk({ ...w, action }, action)
263        }),
264      )
265      if (!isCalm) await update($, workers, conga)
266      for (const id of arrived) $.clock.after(HUG_MS, () => void leave($, id))
267      if (ticks % POLL_EVERY === 0) await settle($, crew)
268    }
269  })
270}
271
272/** Four or more helpers out: the ones wandering fall in line behind the first. */
273function conga(list: Worker[]): Worker[] {
274  const line = list.filter(w => w.state === 'running' && speed(w.action) > 0)
275  if (list.filter(w => w.state === 'running').length < CONGA_AT || line.length < 2) return list
276  const max = Math.max(0, cols - HERO_W)
277  const placed = new Map<string, Worker>()
278  let ahead = line[0]!
279  for (const w of line.slice(1)) {
280    const want = Math.min(max, Math.max(0, ahead.x - ahead.dir * 12))
281    const gap = want - w.x
282    const step = Math.min(Math.abs(gap), 1)
283    const moved: Worker = { ...w, action: 'walk', x: w.x + Math.sign(gap) * step, dir: gap === 0 ? ahead.dir : gap > 0 ? 1 : -1 }
284    placed.set(w.id, moved)
285    ahead = moved
286  }
287  return list.map(w => placed.get(w.id) ?? w)
288}
289
290async function leave($: EngineInterface, id: string) {
291  workerBeatAt.delete(id)
292  workerTripUntil.delete(id)
293  returningSince.delete(id)
294  await update($, workers, list => list.filter(w => w.id !== id))
295}
296
297/** A helper ends: done ones run home to Claude, failed ones trip and leave. */
298async function finish($: EngineInterface, id: string, state: 'done' | 'failed') {
299  if (state === 'done') {
300    returningSince.set(id, await $.clock.now())
301    await update($, workers, list => list.map(w => (w.id === id ? { ...w, state: 'returning', action: 'run' } : w)))
302    ensureTimer($)
303    return
304  }
305  await update($, workers, list => list.map(w => (w.id === id ? { ...w, state: 'failed', action: 'trip', sad: true } : w)))
306  // It trips, dusts off, and walks back to Claude for a pat on the head.
307  $.clock.after(TRIP_MS, async () => {
308    returningSince.set(id, await $.clock.now())
309    await update($, workers, list => list.map(w => (w.id === id ? { ...w, state: 'returning', action: 'walk' } : w)))
310  })
311}
312
313async function settle($: EngineInterface, list: readonly Worker[]) {
314  const running = list.filter(w => w.state === 'running' && !w.id.startsWith('demo-'))
315  if (running.length === 0) return
316  const agents = await $.agent.list()
317  for (const w of running) {
318    const a = agents.find(x => x.id === w.id)
319    if (!a) await finish($, w.id, 'done')
320    else if (ENDED.has(a.status)) await finish($, w.id, a.status === 'completed' ? 'done' : 'failed')
321  }
322}
323
324/** The spot on the stage farthest from Claude and every helper already out. */
325function openSpot(taken: readonly number[], max: number): number {
326  let best = 0
327  let bestGap = -1
328  for (let x = 0; x <= max; x += 2) {
329    const gap = Math.min(...taken.map(t => Math.abs(t - x)), Infinity)
330    if (gap > bestGap) [best, bestGap] = [x, gap]
331  }
332  return best
333}
334
335async function addWorker($: EngineInterface, id: string, label: string, model: string | undefined) {
336  const max = Math.max(1, cols - HERO_W)
337  const taken = [(await read($, hero)).x, ...(await read($, workers)).map(w => w.x)]
338  const x = openSpot(taken, max)
339  const w: Worker = {
340    id,
341    label,
342    tier: tierOf(model),
343    state: 'running',
344    action: 'walk',
345    x,
346    dir: x > max / 2 ? -1 : 1,
347    steps: 0,
348  }
349  workerBeatAt.set(id, await $.clock.now())
350  await update($, workers, list => [...list.filter(x => x.id !== id), w])
351  await glanceAt($, x >= taken[0]! ? 1 : -1)
352  ensureTimer($)
353}
354
355async function workerBeat($: EngineInterface, id: string, action: Action, isError: boolean) {
356  const now = await $.clock.now()
357  workerBeatAt.set(id, now)
358  if (isError) workerTripUntil.set(id, now + TRIP_MS)
359  await update($, workers, list =>
360    list.map(w => (w.id === id && w.state === 'running' ? { ...w, action, steps: w.steps + (isError ? 0 : 1) } : w)),
361  )
362}
363
364/** A main-thread beat: action and caption, unless Claude is waiting on the user. */
365const POSES = new Set<Action>(['read', 'dig', 'sneak', 'run', 'fly', 'carry', 'cover'])
366
367async function heroBeat($: EngineInterface, b: Pick<Hero, 'action' | 'caption'>, step = 1, mood?: Emote) {
368  const now = await $.clock.now()
369  lastCallAt = now
370  await update($, hero, (cur): Hero => {
371    if (cur.mode !== 'working') return cur
372    const emote = mood ?? (cur.emote === 'think' ? null : cur.emote)
373    return {
374      ...cur,
375      ...b,
376      steps: cur.steps + step,
377      emote,
378      emoteUntil: mood ? now + MOOD_MS : cur.emoteUntil,
379      last: POSES.has(b.action) ? b.action : cur.last,
380    }
381  })
382}
383
384async function begin($: EngineInterface, text = '') {
385  const now = await $.clock.now()
386  const d = new Date(now)
387  startedAt = now
388  lastCallAt = now
389  heroCalls = 0
390  tripUntil = 0
391  best = 0
392  errStreak = 0
393  alertAt = null
394  const mood = moodOf(text)
395  await update($, combo, () => 0)
396  await update($, alert, () => null)
397  await update($, hero, (cur): Hero => ({
398    ...cur,
399    mode: 'working',
400    action: 'walk',
401    caption: startCaption({ mood, woke: cur.action === 'sleep', holiday: holidayNow(d), hour: d.getHours(), dungeon: isDungeon }),
402    scene: cur.mode === 'idle' ? (cur.scene + 1) % SCENE_COUNT : cur.scene,
403    steps: 0,
404    since: now,
405    emote: mood === 'thanks' ? 'smitten' : mood === 'scold' ? 'sheepish' : null,
406    emoteUntil: now + MOOD_MS,
407    // Scolded, Claude can't quite look you in the eye.
408    glance: mood === 'scold' ? -1 : cur.glance,
409    glanceUntil: mood === 'scold' ? now + MOOD_MS : cur.glanceUntil,
410  }))
411  ensureTimer($)
412}
413
414async function close($: EngineInterface, caption: string, action: Action = 'cheer') {
415  const now = await $.clock.now()
416  await update($, alert, () => null)
417  await update($, hero, (cur): Hero => ({ ...cur, mode: 'ending', action, caption, since: now, emote: null }))
418  ensureTimer($)
419}
420
421/** An MCP call: a puff in the server's own color as Claude warps a message off. */
422async function onMcp($: EngineInterface, tool: string) {
423  const server = tool.split('__')[1] ?? tool
424  const x = (await read($, hero)).x
425  await addFx($, 'warp', x)
426  await update($, fx, list => list.map(f => (f.kind === 'warp' && f.id === fxId ? { ...f, color: warpColor(server) } : f)))
427}
428
429/** Dungeon gold: a coin pops over Claude, a burst for a chest or a fight won. */
430async function earn($: EngineInterface, n: number, burst = false) {
431  if (!isDungeon || n <= 0) return
432  await update($, gold, g => g + n)
433  await addFx($, burst ? 'coins' : 'coin', (await read($, hero)).x)
434}
435
436/** The call as a beat, in the stage's own words. */
437async function themed($: EngineInterface, tool: string, args: Record<string, unknown>, seed: string) {
438  return isDungeon ? dungeonBeat(tool, args, seed, await read($, bugs)) : beat(tool, args, seed)
439}
440
441/** Test output: failures drop bugs into the scene, a green run lets Claude eat them. */
442async function onTests($: EngineInterface, command: string, output: string, isMain: boolean) {
443  const counts = testCounts(command, output)
444  if (!counts) return
445  const had = await read($, bugs)
446  if (counts.failed > 0) {
447    const n = Math.min(counts.failed, 8)
448    await update($, bugs, () => n)
449    const s = counts.failed === 1 ? '' : 's'
450    const caption = isDungeon ? `${counts.failed} slime${s} ooze${s ? '' : 's'} out of the test run` : `${counts.failed} bug${s} crawl out of the test run`
451    if (isMain) await heroBeat($, { action: 'trip', caption }, 0)
452    tripUntil = (await $.clock.now()) + TRIP_MS
453  } else if (counts.passed > 0 && had > 0) {
454    const s = had === 1 ? '' : 's'
455    const caption = isDungeon ? `Claude squashes ${had === 1 ? 'the slime' : `all ${had} slimes`}. Tests are green. +${had} gold` : `Claude eats all ${had} bug${s}. Tests are green.`
456    if (isMain) await heroBeat($, { action: 'cheer', caption }, 0)
457    if (isMain) await earn($, had, true)
458    for (let n = 1; n <= had; n++) $.clock.after(n * 350, () => void update($, bugs, b => Math.max(0, b - 1)))
459  }
460}
461
462/** TodoWrite: pending items are pellets; finishing one is a gobble. */
463async function onTodos($: EngineInterface, input: Record<string, unknown>) {
464  const list = Array.isArray(input.todos) ? (input.todos as Record<string, unknown>[]) : []
465  if (list.length === 0) return
466  const done = list.filter(t => t.status === 'completed')
467  const prev = await read($, todos)
468  const next = done.length === list.length ? null : { done: done.length, total: list.length }
469  await update($, todos, () => next)
470  if (prev && done.length > prev.done) {
471    const last = done[done.length - 1]
472    const what = typeof last?.content === 'string' ? last.content : 'a task'
473    const caption = isDungeon
474      ? next ? `Claude opens a treasure chest: ${what}` : 'Claude opens the last chest on the map!'
475      : next ? `Claude gobbles a pellet: ${what}` : 'Claude clears the whole quest list!'
476    await heroBeat($, { action: 'cheer', caption }, 0)
477    await earn($, 3, true)
478  }
479}
480
481/** Clean calls in a row: a combo from 5, a crown at 10. */
482async function onCombo($: EngineInterface) {
483  let n = 0
484  await update($, combo, c => (n = c + 1))
485  best = Math.max(best, n)
486  if (n === CROWN_AT) await heroBeat($, { action: 'cheer', caption: `${CROWN_AT} clean calls in a row. Claude earns a crown!` }, 0)
487}
488
489/** A fun Bash command went through: confetti, a paper plane, boxes. */
490async function onEggs($: EngineInterface, eggs: readonly Egg[], command: string) {
491  const x = (await read($, hero)).x
492  for (const egg of eggs) {
493    if (egg === 'commit') await addFx($, 'confetti', x)
494    if (egg === 'push') await addFx($, 'plane', x)
495    if (egg === 'install') await addFx($, 'boxes', Math.min(Math.max(0, cols - 4), x + HERO_W))
496  }
497  const done = eggs.map(egg => eggDone(egg, command)).filter(Boolean)
498  if (done.length > 0) await heroBeat($, { action: 'cheer', caption: done.join('. ') }, 0)
499}
500
501function readLimits(rates: readonly { kind: string; percentUsed: number; resetsAt?: string }[]): Limit[] {
502  return rates.map(r => ({ kind: limitLabel(r.kind), percent: Math.round(r.percentUsed), resetsAt: r.resetsAt }))
503}
504
505/** 'five_hour' and friends as the short names people know. */
506function limitLabel(kind: string): string {
507  if (/five|5.?h/i.test(kind)) return '5h'
508  if (/seven|7.?d|week/i.test(kind)) return '7d'
509  return kind.replace(/_/g, ' ')
510}
511
512function topLimit(list: readonly Limit[]): Limit | null {
513  let top: Limit | null = null
514  for (const l of list) if (!top || l.percent > top.percent) top = l
515  return top
516}
517
518/** Restarts the tick at the new pace. */
519async function setCalm($: EngineInterface, on: boolean) {
520  isCalm = on
521  await update($, calm, () => on)
522  timer?.cancel()
523  timer = undefined
524  const cur = await read($, hero)
525  if (cur.mode !== 'idle' || (await read($, workers)).length > 0) ensureTimer($)
526}
527
528function runDemo($: EngineInterface) {
529  const at = (ms: number, fn: () => Promise<unknown> | unknown) => $.clock.after(ms, () => void fn())
530  const steps: [number, string, Record<string, unknown>][] = [
531    [1500, 'Grep', { pattern: 'register' }],
532    [4000, 'Read', { file_path: '/src/hooks/register.tsx' }],
533    [6500, 'Agent', { description: 'Review the diff' }],
534    [9000, 'Edit', { file_path: '/src/hooks/register.tsx' }],
535    [15000, 'WebSearch', { query: 'half block pixel art' }],
536  ]
537  // It opens on an egg and ends with the hatchling growing up.
538  demoGrowth = 'shell'
539  at(0, async () => {
540    const now = await $.clock.now()
541    await update($, hero, (h): Hero => ({ ...h, action: 'hatch', caption: 'An egg wobbles…', since: now }))
542  })
543  at(1100, () => heroBeat($, { action: 'cheer', caption: 'A Clawd hatches! Hello!' }, 0))
544  for (const [ms, tool, args] of steps) {
545    at(ms, async () => {
546      await heroBeat($, await themed($, tool, args, `demo${ms}`))
547      await earn($, 1)
548    })
549  }
550  at(2000, () => onTodos($, { todos: [1, 2, 3, 4, 5].map(n => ({ content: `Task ${n}`, status: n === 1 ? 'in_progress' : 'pending' })) }))
551  at(3000, () => update($, context, () => 74)) // rain
552  at(11000, () => heroBeat($, isDungeon ? { action: 'cast', caption: 'Claude casts a spell: run the test suite' } : { action: 'run', caption: 'Claude runs off to run the test suite' }))
553  // The dungeon cut: with slimes on the floor, the next edit is a sword fight.
554  at(13500, async () => {
555    if (isDungeon) await heroBeat($, await themed($, 'Edit', { file_path: '/src/hooks/story.ts' }, 'demo13500'))
556  })
557  at(12000, () => onTests($, 'npm test', 'Tests: 3 failed, 9 passed', true))
558  // 0.6.0 moments: a model switch hat, then a memory note.
559  at(5200, async () => {
560    modelHat = { hat: 'regal', until: (await $.clock.now()) + MODEL_HAT_MS }
561    await heroBeat($, { action: 'cheer', caption: 'Claude changes into Opus' }, 0)
562  })
563  at(22300, async () => heroBeat($, await themed($, 'Write', { file_path: '/home/you/.claude/memory/MEMORY.md' }, 'demo22300')))
564  at(17500, () => update($, alert, () => 'Claude needs you: allow Bash(rm -rf build)?'))
565  at(21000, () => update($, alert, () => null))
566  at(21500, () => onTests($, 'npm test', 'Tests: 0 failed, 12 passed', true))
567  at(23000, () => onTodos($, { todos: [1, 2, 3, 4, 5].map(n => ({ content: `Task ${n}`, status: n <= 3 ? 'completed' : 'pending' })) }))
568  at(24000, () => update($, context, () => 90)) // storm
569  const crew: [string, string, string, Action[], 'done' | 'failed'][] = [
570    ['demo-1', 'Review the diff', 'claude-sonnet-5-5', ['read', 'sneak', 'read'], 'done'],
571    ['demo-2', 'Judge the art', 'claude-opus-5-5', ['read', 'dig', 'run'], 'done'],
572    ['demo-3', 'Scout old toys', 'claude-haiku-4-5', ['fly', 'fly', 'sneak'], 'failed'],
573    ['demo-4', 'Write the fable', 'claude-fable-5-1', ['dig', 'read', 'dig'], 'done'],
574  ]
575  crew.forEach(([id, label, model, acts, end], n) => {
576    const t = 7000 + n * 800
577    at(t, () => addWorker($, id, label, model))
578    acts.forEach((a, i) => at(t + 1500 + i * 2500, () => workerBeat($, id, a, false)))
579    at(t + 1500 + acts.length * 2500 + n * 600, () => finish($, id, end))
580  })
581  at(16500, () => heroBeat($, beat('mcp__claude_ai_Gmail__search_threads', {}, 'demo16500')))
582  at(16500, () => onMcp($, 'mcp__claude_ai_Gmail__search_threads'))
583  at(24500, () => maybeVisitor($, 'whale'))
584  at(25000, () => heroBeat($, eggBeat('commit', 'git commit -m "Teach Clawd new tricks"')))
585  at(26000, async () => {
586    await heroBeat($, { action: 'cheer', caption: eggDone('commit', 'git commit -m "Teach Clawd new tricks"') ?? '' }, 0)
587    await addFx($, 'confetti', (await read($, hero)).x)
588  })
589  at(27000, () => update($, context, () => 30)) // the storm passes before the big moment
590  at(28000, async () => {
591    demoGrowth = 'explorer'
592    await close($, growCaption('explorer', null))
593    await addFx($, 'confetti', (await read($, hero)).x)
594  })
595  at(46000, () => (demoGrowth = null))
596  at(40000, () => update($, todos, () => null)) // the fable's quest list leaves with it
597}
598
599// The pose last drawn, and the in-between poses still to play before the new one shows.
600let drawnAction: Action | undefined
601let clip: { steps: readonly Action[]; at: number } | null = null
602
603export async function between($: Pick<EngineInterface, 'clock'>, action: Action): Promise<Action> {
604  const now = await $.clock.now()
605  if (action !== drawnAction) {
606    const steps = drawnAction && !isCalm ? transition(drawnAction, action) : []
607    clip = steps.length > 0 ? { steps, at: now } : null
608    drawnAction = action
609  }
610  const step = clip?.steps[Math.floor((now - clip.at) / CLIP_STEP_MS)]
611  if (!step) clip = null
612  return step ?? action
613}
614
615export const register: Register = on => {
616  on('session.start', async ($, e, next) => {
617    await $.command.register({ name: 'tales', description: 'Clawd tales: on, off, calm, lively, demo, theme, hat, face, scarf' })
618    sessionTokens = 0
619    tokensSeen = 0
620    const hatSaved = await $.store.get('hat')
621    wornHat = HATS_TO_WEAR.includes(hatSaved as Hat) ? (hatSaved as Hat) : null
622    const faceSaved = await $.store.get('face')
623    wornFace = FACES_TO_WEAR.includes(faceSaved as Face) ? (faceSaved as Face) : null
624    stats = readStats(await $.store.get('stats'))
625    scarfOn = (await $.store.get('scarf')) !== false
626    isDungeon = (await $.store.get('theme')) === 'dungeon'
627    await update($, theme, () => (isDungeon ? 'dungeon' : 'meadow'))
628    try {
629      lastModel = await $.session.model()
630    } catch {
631      lastModel = null
632    }
633    try {
634      scarfKey = scarfColor(await $.session.root())
635    } catch {
636      scarfKey = null
637    }
638    const saved = await $.store.get('isOn')
639    if (typeof saved === 'boolean') await update($, isOn, () => saved)
640    const savedCalm = await $.store.get('calm')
641    isCalm = savedCalm === true
642    await update($, calm, () => isCalm)
643    try {
644      const usage = await $.session.usage()
645      await update($, context, () => usage.context.percent ?? null)
646      await update($, limits, () => readLimits(usage.rateLimits))
647    } catch {
648      // No usage yet: the first measure fills it in.
649    }
650    if ((await read($, shiny)) === null) {
651      const lucky = Math.random() < SHINY_ODDS
652      await update($, shiny, () => lucky)
653      if (lucky) await $.ui.toast('✨ A shiny Clawd appeared this session!')
654    }
655    if ((await read($, hero)).mode !== 'idle' || (await read($, workers)).length > 0) ensureTimer($)
656    return next(e)
657  })
658
659  on('command.run', { command: 'tales' }, async ($, e) => {
660    const arg = (e.args ?? '').trim().toLowerCase()
661    if (arg === 'off' || arg === 'on') {
662      await update($, isOn, () => arg === 'on')
663      await $.store.set('isOn', arg === 'on')
664      return { text: `Clawd tales ${arg}.` }
665    }
666    if (arg === 'calm' || arg === 'lively') {
667      await setCalm($, arg === 'calm')
668      await $.store.set('calm', arg === 'calm')
669      return { text: arg === 'calm' ? 'Calm: one beat a second, Claude stays put between tool calls.' : 'Lively: 5 fps, wandering on.' }
670    }
671    const [word, name] = arg.split(/\s+/)
672    // /tales theme dungeon|meadow, or just /tales dungeon.
673    const pickTheme = word === 'theme' ? name : word === 'dungeon' || word === 'meadow' ? word : undefined
674    if (word === 'theme' || pickTheme) {
675      if (pickTheme !== 'dungeon' && pickTheme !== 'meadow') return { text: `The stage is the ${isDungeon ? 'dungeon' : 'meadow'}. Use /tales theme dungeon|meadow.` }
676      isDungeon = pickTheme === 'dungeon'
677      await update($, theme, () => pickTheme)
678      await $.store.set('theme', pickTheme)
679      return {
680        text: isDungeon
681          ? 'Into the dungeon: lamps for context, scrolls, treasure chests for tasks, gold for clean calls, monsters for failures.'
682          : 'Back to the meadow.',
683      }
684    }
685    if (word === 'scarf') {
686      if (name !== 'on' && name !== 'off') return { text: `The scarf is ${scarfOn ? 'on' : 'off'}. Use /tales scarf on|off.` }
687      scarfOn = name === 'on'
688      await $.store.set('scarf', scarfOn)
689      return { text: scarfOn ? 'Clawd wraps on the project scarf.' : 'Clawd takes off the scarf.' }
690    }
691    if (arg === 'demo') {
692      await begin($)
693      runDemo($)
694      return { text: 'A short fable: helpers, bugs, pellets, weather and a call for you. About 45 seconds.' }
695    }
696    if (word === 'hat' || word === 'face') {
697      const list: readonly string[] = word === 'hat' ? HATS_TO_WEAR : FACES_TO_WEAR
698      if (name === 'none' || name === 'off') {
699        if (word === 'hat') wornHat = null
700        else wornFace = null
701        await $.store.set(word, null)
702        return { text: `Clawd takes off the ${word === 'hat' ? 'hat' : 'face gear'}.` }
703      }
704      if (!name || !list.includes(name)) return { text: `${word === 'hat' ? 'Hats' : 'Face'}: ${list.join(', ')}, none.` }
705      if (word === 'hat') wornHat = name as Hat
706      else wornFace = name as Face
707      await $.store.set(word, name)
708      return { text: `Clawd puts on the ${name}.` }
709    }
710    // Unlisted: preview a holiday look (this load only). "auto" goes back to the calendar.
711    if (word === 'holiday') {
712      if (name === 'auto') holidayPick = null
713      else if (name === 'off') holidayPick = 'none'
714      else if (HOLIDAYS.includes(name as Holiday)) holidayPick = name as Holiday
715      else return { text: `Holidays: ${HOLIDAYS.join(', ')}, off, auto.` }
716      return { text: `Holiday look: ${name}.` }
717    }
718    const mode = (await read($, calm)) ? 'calm' : 'lively'
719    const sparkle = (await read($, shiny)) ? ' ✨ Shiny Clawd this session.' : ''
720    const growth = !stats
721      ? ' Clawd has not hatched yet.'
722      : stats.form
723        ? ` Clawd is a ${stats.form} (${stats.xp} tool calls).`
724        : ` Clawd is a hatchling: ${stats.xp}/${GROW_AT} tool calls to grow up.`
725    return {
726      text: `Clawd tales is ${(await read($, isOn)) ? 'on' : 'off'} (${mode}) in the ${isDungeon ? 'dungeon' : 'meadow'}.${growth}${sparkle} Use /tales on|off|calm|lively|demo, /tales theme dungeon|meadow, /tales hat <name>, /tales face <name>, /tales scarf on|off.`,
727    }
728  })
729
730  // Plan mode only shows on the classic hook's input; a mid-turn shift+tab waits for the next prompt.
731  on('classic.UserPromptSubmit', async ($, e, next) => {
732    planMode = e.permission_mode === 'plan'
733    return next(e)
734  })
735
736  on('prompt.submit', async ($, e, next) => {
737    await begin($, e.text)
738    maybeVisitor($)
739    return next(e)
740  })
741
742  // A turn without a typed prompt (a background agent's notice, a continuation) raises no
743  // prompt.submit: without this Claude kept fishing and dozing through the whole turn.
744  on('turn.start', async ($, e, next) => {
745    if ((await read($, hero)).mode !== 'working') await begin($, e.text)
746    // A /model switch since the last turn: Claude hops in the new model's hat.
747    let model: string | null = null
748    try {
749      model = await $.session.model()
750    } catch {
751      // No model to read: nothing to compare.
752    }
753    const swap = model ? modelSwitch(lastModel, model) : null
754    if (model) lastModel = model
755    if (swap) {
756      if (swap.hat) modelHat = { hat: swap.hat, until: (await $.clock.now()) + MODEL_HAT_MS }
757      await heroBeat($, { action: 'cheer', caption: swap.caption }, 0)
758    }
759    return next(e)
760  })
761
762  on('session.measure', async ($, e, next) => {
763    await update($, context, () => e.context.percent ?? null)
764    await update($, limits, () => readLimits(e.rateLimits))
765    return next(e)
766  })
767
768  // A permission dialog is about to show (not just an "ask" verdict, which auto mode
769  // may settle by itself): Claude jumps and waves until the call goes on.
770  on('classic.PermissionRequest', async ($, e, next) => {
771    const input = e.tool_input
772    const cmd = typeof input === 'object' && input && 'command' in input ? String(input.command) : ''
773    const what = cmd ? `${e.tool_name}(${cmd.length > 40 ? cmd.slice(0, 39) + '…' : cmd})` : e.tool_name
774    alertAt = await $.clock.now()
775    await update($, alert, () => `Claude needs you: allow ${what}?`)
776    ensureTimer($)
777    return next(e)
778  })
779
780  on('agent.spawn', async ($, e, next) => {
781    const result = await next(e)
782    if (!result.deny && result.agentId) await addWorker($, result.agentId, e.description || e.subagentType, result.model)
783    return result
784  })
785
786  on('tool.call', async ($, e, next) => {
787    const seed = e.tool_use_id ?? e.tool
788    const args = e as unknown as Record<string, unknown>
789    const owner = e.agentId
790    const command = e.tool === 'Bash' && typeof args.command === 'string' ? args.command : ''
791    const eggs = owner ? [] : bashEggs(command)
792    const egg = eggs[0] ? eggBeat(eggs[0], command) : null
793    const b = egg ?? (await themed($, e.tool, args, seed))
794    const asks = e.tool === 'AskUserQuestion' || e.tool === 'ExitPlanMode'
795    // A main-loop call means Claude is working, whatever the band missed.
796    if (!owner && (await read($, hero)).mode !== 'working') await begin($)
797    if (owner) await workerBeat($, owner, b.action, false)
798    else {
799      await heroBeat($, b, 1, egg?.emote)
800      // No counting before the hatch: a mid-session install would skip the egg forever.
801      // A sword fight counts as the work it interrupted.
802      if (stats) stats = countCall(stats, b.action === 'fight' ? beat(e.tool, args, seed).action : b.action)
803    }
804    if (eggs.includes('sl')) await addFx($, 'train', 0)
805    if (asks) {
806      alertAt = await $.clock.now()
807      await update($, alert, () => (e.tool === 'ExitPlanMode' ? 'Claude needs you: approve the plan?' : 'Claude needs you: a question is waiting'))
808      ensureTimer($)
809    }
810    const delegates = !owner && e.tool === 'Agent'
811    if (!owner && e.tool.startsWith('mcp__')) await onMcp($, e.tool)
812    if (!owner) heroCalls += 1
813    if (delegates) agentCalls += 1
814    let result: Awaited<ReturnType<typeof next>>
815    try {
816      result = await next(e)
817    } finally {
818      if (!owner) {
819        heroCalls = Math.max(0, heroCalls - 1)
820        lastCallAt = await $.clock.now()
821      }
822      if (delegates) agentCalls = Math.max(0, agentCalls - 1)
823    }
824    if (e.tool === 'ExitPlanMode' && result.deny === undefined && !result.isError) planMode = false
825    // Whatever it was waiting on, the call has gone ahead (or been refused).
826    const waited = alertAt !== null && (await read($, alert)) ? (await $.clock.now()) - alertAt : null
827    alertAt = null
828    await update($, alert, () => null)
829    if (waited !== null && waited < FAST_YES_MS && result.deny === undefined && !result.isError && !owner) {
830      const now = await $.clock.now()
831      await update($, hero, (cur): Hero => ({ ...cur, emote: 'blush', emoteUntil: now + 3000, caption: 'Claude beams at the quick yes' }))
832    }
833    if (result.deny !== undefined || result.isError) {
834      if (owner) {
835        await workerBeat($, owner, 'trip', true)
836      } else {
837        tripUntil = (await $.clock.now()) + TRIP_MS
838        const missing = command && typeof result.text === 'string' ? notFound(result.text) : null
839        errStreak += 1
840        if (missing) {
841          await heroBeat($, { action: 'trip', caption: `Choo choo! “${missing}” isn't a command` }, 0)
842          await addFx($, 'train', 0)
843        } else if (errStreak >= 3) {
844          tripUntil = (await $.clock.now()) + FLIP_MS
845          const caption = isDungeon ? ambushCaption(e.tool, errStreak, seed) : `(╯°□°)╯︵ ┻━┻  ${errStreak} stumbles in a row. Claude flips the table`
846          await heroBeat($, { action: 'flip', caption }, 0)
847        } else if (errStreak === 2) {
848          tripUntil = (await $.clock.now()) + FLIP_MS
849          await heroBeat($, { action: 'dizzy', caption: isDungeon ? ambushCaption(e.tool, 2, seed) : 'Claude sees stars: two stumbles in a row' }, 0)
850        } else {
851          await heroBeat($, { action: 'trip', caption: isDungeon ? ambushCaption(e.tool, 1, seed) : tripCaption(e.tool, seed) }, 0)
852        }
853        await update($, combo, () => 0)
854      }
855    } else if (!owner) {
856      const beaten = errStreak
857      errStreak = 0
858      if (isDungeon && beaten > 0) {
859        // The skeletons fall: a puff where they stood, and their coins.
860        const h = await read($, hero)
861        await addFx($, 'poof', h.dir > 0 ? h.x + HERO_W : h.x - 6)
862        await heroBeat($, { action: 'cheer', caption: victoryCaption(beaten) }, 0)
863        await earn($, beaten + 1, true)
864      } else {
865        await earn($, 1)
866      }
867      await onCombo($)
868      if (eggs.length > 0) await onEggs($, eggs, command)
869    }
870    if (result.deny === undefined && e.tool === 'Bash' && typeof result.text === 'string') {
871      await onTests($, typeof args.command === 'string' ? args.command : '', result.text, !owner)
872    }
873    if (e.tool === 'TodoWrite' && !owner) await onTodos($, args)
874    return result
875  })
876
877  // Compaction: Claude sweeps the stage. Between turns it gets the band to itself, then rests.
878  on('session.compact', async ($, e, next) => {
879    if (e.trigger === 'precompute' || e.agentId) return next(e)
880    const cur = await read($, hero)
881    const solo = cur.mode !== 'working'
882    if (solo) {
883      const now = await $.clock.now()
884      await update($, hero, (h): Hero => ({ ...h, mode: 'working', action: 'sweep', caption: isDungeon ? 'Claude sweeps the hall and refills the lamps…' : 'Claude sweeps up the old context…', since: now, emote: null }))
885      ensureTimer($)
886    } else {
887      await heroBeat($, { action: 'sweep', caption: isDungeon ? 'Claude sweeps the hall and refills the lamps…' : 'Claude sweeps up the old context…' }, 0)
888    }
889    const result = await next(e)
890    if (result.messages) {
891      await update($, context, () => null) // the storm clears; the next measure says by how much
892      if (solo) await close($, 'Squeaky clean. The stage is swept.')
893      else await heroBeat($, { action: 'cheer', caption: 'Squeaky clean. Back to work.' }, 0)
894    } else if (solo) {
895      await update($, hero, (h): Hero => ({ ...h, mode: 'idle' }))
896    }
897    return result
898  })
899
900  // A session opens: a fresh one gets Clawd dropping in, a resumed one a wave hello.
901  on('classic.SessionStart', async ($, e, next) => {
902    const result = await next(e)
903    // The very first session: an egg instead of the drop-in.
904    const unhatched = e.source === 'startup' && !readStats(await $.store.get('stats'))
905    if (unhatched && (await read($, hero)).mode === 'idle') {
906      stats = stats ?? { ...NEW_STATS }
907      await $.store.set('stats', stats)
908      const now = await $.clock.now()
909      await update($, hero, (h): Hero => ({ ...h, action: 'hatch', caption: 'An egg wobbles…', mode: 'ending', since: now, emote: null }))
910      $.clock.after(HATCH_MS, async () => {
911        const at = await $.clock.now()
912        await update($, hero, (h): Hero => (h.action === 'hatch' ? { ...h, action: 'cheer', caption: 'A Clawd hatches! Hello!', since: at } : h))
913      })
914      ensureTimer($)
915      return result
916    }
917    const entrance: Pick<Hero, 'action' | 'caption'> | null =
918      e.source === 'startup'
919        ? { action: 'drop', caption: 'Clawd drops in. Hello!' }
920        : e.source === 'resume' || e.source === 'fork'
921          ? { action: 'wave', caption: 'Welcome back! Clawd waves hello.' }
922          : e.source === 'clear'
923            ? { action: 'sweep', caption: 'A fresh start. Clawd sweeps the stage.' }
924            : null
925    if (entrance && (await read($, hero)).mode === 'idle') {
926      const now = await $.clock.now()
927      await update($, hero, (h): Hero => ({ ...h, ...entrance, mode: 'ending', since: now, emote: null }))
928      ensureTimer($)
929    }
930    return result
931  })
932
933  on('turn.complete', async ($, e, next) => {
934    const u = e.usage
935    if (u) sessionTokens += u.input_tokens + u.cache_creation_input_tokens + u.output_tokens
936    // A helper's turn ends inside the main one: it counts toward tokens, not toward the ending.
937    if (e.agentId) return next(e)
938    const cur = await read($, hero)
939    if (cur.mode === 'working' && e.reason === 'error') {
940      await close($, stuckCaption(isDungeon), 'stuck')
941    } else if (cur.mode === 'working') {
942      const seconds = Math.round((e.durationMs ?? (await $.clock.now()) - startedAt) / 1000)
943      const crossed = tokenMilestone(tokensSeen, sessionTokens)
944      tokensSeen = sessionTokens
945      const grown = stats ? formOf(stats) : null
946      if (stats && grown && grown !== stats.form) {
947        const was = stats.form
948        stats = { ...stats, form: grown }
949        await close($, growCaption(grown, was), 'cheer')
950        await addFx($, 'confetti', cur.x)
951      } else {
952        await close($, ending(cur.steps, seconds, e.isAborted, best, crossed), endingAction(seconds, e.isAborted))
953        if (crossed) await addFx($, 'confetti', cur.x)
954      }
955      if (stats) await $.store.set('stats', stats)
956    }
957    return next(e)
958  })
959
960  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
961    if (e.surface === 'mobile' || e.props.hasSurvey) return next(e)
962    const cur = await read($, hero)
963    const crew = await read($, workers)
964    const asking = await read($, alert)
965    if ((cur.mode === 'idle' && crew.length === 0 && !asking) || !(await read($, isOn))) return next(e)
966    const { Box, Text } = $.ui.resolve(e)
967    cols = Math.max(HERO_W + 4, e.props.bodyColumns ?? 80)
968    const rows = e.props.maxRows ?? 12
969    const f = await read($, frame)
970    const out = crew.filter(w => w.state === 'running').length || crew.length
971    const running = crew.filter(w => w.state === 'running')
972    let shown: Hero = cur
973    // Turn over, or the main loop blocked on an Agent call: Claude idles while the helpers work.
974    if (cur.mode === 'idle' || (cur.mode === 'working' && agentCalls > 0 && running.length > 0)) {
975      shown = { ...cur, ...waitBeat(out, running[0]?.label ?? crew[0]?.label) }
976    }
977    if (asking) {
978      const waited = alertAt !== null ? Math.floor(((await $.clock.now()) - alertAt) / 1000) : 0
979      const nervous = waited * 1000 >= NERVOUS_MS
980      shown = { ...shown, action: 'alert', caption: nervous ? `${asking} (waiting ${waited}s)` : asking, emote: nervous ? 'sweat' : shown.emote }
981    }
982    shown = { ...shown, action: await between($, shown.action) }
983    const fit = (s: string, room: number) => (s.length > room ? s.slice(0, Math.max(1, room - 1)) + '…' : s)
984    const caption = asking ? (
985      <Text color="#e5534b" bold>
986        {fit(shown.caption, cols)}
987      </Text>
988    ) : (
989      <Text dimColor>{fit(shown.caption, cols)}</Text>
990    )
991    const below = await next(e)
992
993    // Degradation ladder: full stage, then stage without sky or roster, then stage without its
994    // caption (a fullscreen split pane leaves ~5 rows), then one line, then nothing. A call for
995    // the person keeps its caption and gives up stage instead.
996    if (rows < 1) return below
997    const captionRows = asking || rows >= 6 ? 1 : 0
998    const room = rows - captionRows
999    if (room < 4 || cols < 30) {
1000      return (
1001        <Box flexDirection="column">
1002          <Box flexDirection="row">
1003            <Text color={asking ? '#e5534b' : '#d77757'}>{`${glyph(shown.action, f)} `}</Text>
1004            {caption}
1005          </Box>
1006          {below}
1007        </Box>
1008      )
1009    }
1010
1011    const task = await read($, todos)
1012    const now = await $.clock.now()
1013    const day = new Date(now)
1014    const holiday = holidayNow(day)
1015    const hour = day.getHours()
1016    const streak = await read($, combo)
1017    const holidayHat: Hat | null = holiday === 'halloween' ? 'witch' : holiday === 'christmas' ? 'santa' : holiday === 'newyear' ? 'party' : null
1018    const longHaul = cur.mode === 'working' && now - startedAt >= HARDHAT_MS
1019    const hat: Hat | null =
1020      streak >= CROWN_AT
1021        ? 'crown'
1022        : ((modelHat && now < modelHat.until ? modelHat.hat : null) ??
1023          wornHat ??
1024          (planMode ? 'wizard' : null) ??
1025          holidayHat ??
1026          (longHaul ? 'hardhat' : null) ??
1027          (isLate(hour) ? 'nightcap' : null) ??
1028          (demoGrowth === 'shell' ? 'shell' : demoGrowth ? FORM_HAT[demoGrowth] : null) ??
1029          (stats?.form ? FORM_HAT[stats.form] : stats ? 'shell' : null))
1030    const dungeon = (await read($, theme)) === 'dungeon'
1031    const coins = await read($, gold)
1032    const extras = {
1033      weather: dungeon ? ('clear' as const) : weatherOf(await read($, context)),
1034      dungeon,
1035      lamps: lampsOf(await read($, context)),
1036      foes: dungeon ? errStreak : 0,
1037      bugs: await read($, bugs),
1038      todos: task,
1039      now,
1040      sky: skyOf(hour),
1041      holiday,
1042      fireworks: holiday === 'newyear' && isMidnightNewYear(day),
1043      hat,
1044      shiny: (await read($, shiny)) === true,
1045      late: isLate(hour),
1046      fx: await read($, fx),
1047      // Earned the crown this turn: shades on, the cool kind.
1048      face: wornFace ?? (best >= CROWN_AT ? 'shades' : null),
1049      tie: running.length >= TIE_AT,
1050      scarf: scarfOn ? scarfKey : null,
1051    }
1052    const all = lines(stage(shown, crew, f, cols, extras))
1053    // Standing Claude reaches the second line; only the alert crop cuts into him.
1054    const stageLines = all.slice(rows >= 8 ? 0 : Math.min(2, Math.max(1, all.length - room)))
1055    const ctx = await read($, context)
1056    const gauges = [
1057      ...(ctx === null ? [] : [{ label: 'ctx', percent: Math.round(ctx) }]),
1058      ...(await read($, limits)).map(l => ({ label: l.kind, percent: l.percent })),
1059    ]
1060    const tone = (p: number) => (p >= 95 ? '#e5534b' : p >= 80 ? '#e0b84c' : undefined)
1061    const hasRoom = rows >= stageLines.length + captionRows + 1
1062    const roster =
1063      !hasRoom || (crew.length === 0 && !task && gauges.length === 0 && streak < COMBO_AT && !(dungeon && coins > 0)) ? null : (
1064        <Box flexDirection="row">
1065          {gauges.map((g, n) => (
1066            <Text color={tone(g.percent)} dimColor={!tone(g.percent)}>
1067              {`${n ? ' · ' : ''}${g.label} ${g.percent}%`}
1068            </Text>
1069          ))}
1070          {gauges.length > 0 && (task || crew.length > 0 || streak >= COMBO_AT || (dungeon && coins > 0)) ? <Text dimColor>{'   '}</Text> : null}
1071          {dungeon && coins > 0 ? <Text color="#f2d24b">{`◉ ${coins} gold  `}</Text> : null}
1072          {streak >= COMBO_AT ? <Text color="#e0b84c" bold>{`×${streak} combo${streak >= CROWN_AT ? ' 👑' : ''}  `}</Text> : null}
1073          {task ? <Text color="#f5d76e">{dungeon ? `▣ ${task.done}/${task.total} chests  ` : `● ${task.done}/${task.total} tasks  `}</Text> : null}
1074          {crew.slice(0, 4).map(w => (
1075            <Text color={TIER_COLOR[w.state === 'failed' ? 'other' : w.tier]}>
1076              {`${w.state === 'running' ? '●' : w.state === 'failed' ? '✗' : '✓'} ${fit(w.label, Math.max(8, Math.floor(cols / 5) - 3))}  `}
1077            </Text>
1078          ))}
1079          {crew.length > 4 ? <Text dimColor>{`+${crew.length - 4}`}</Text> : null}
1080        </Box>
1081      )
1082    return (
1083      <Box flexDirection="column">
1084        {stageLines.map(line => (
1085          <Box flexDirection="row">
1086            {line.map(s => (
1087              <Text color={s.color} backgroundColor={s.backgroundColor}>
1088                {s.text}
1089              </Text>
1090            ))}
1091          </Box>
1092        ))}
1093        {captionRows ? caption : null}
1094        {roster}
1095        {below}
1096      </Box>
1097    )
1098  })
1099}
1100
hooks/art.ts 1112 lines
1/**
2 * The stage, drawn with half blocks: each terminal cell is two pixels, the top
3 * one the glyph's color and the bottom one its background.
4 * Hero walk and cheer frames from Claude Fables (henrik-thevibe/Claude-Fables), MIT;
5 * the other poses, the scenery, the weather and the critters are ours.
6 */
7import type { Action, Emote, Face, Fx, Hat, Hero, Holiday, Worker } from '../types'
8
9const PALETTE: Record<string, string> = {
10  o: '#d77757', // Clawd's body color in the Claude Code binary
11  k: '#1f1e1d', // ink
12  p: '#9b7fd4', // Opus purple
13  t: '#5fb8b0', // Sonnet teal
14  h: '#8fcf6a', // Haiku green
15  f: '#e0b84c', // Fable gold
16  e: '#8a8780', // other / failed: stone
17  b: '#5b8dd9', // book cover
18  B: '#ece6d6', // pages
19  s: '#8b5a2b', // handle
20  m: '#b8b8b8', // metal
21  d: '#6b4a2e', // dirt
22  w: '#f2f2f2', // wing, z
23  y: '#d9b35b', // box
24  z: '#f2d24b', // star
25  r: '#e5534b', // alert
26  q: '#f27fa5', // heart
27  v: '#a7afb8', // cloud
28  j: '#6fa8dc', // rain
29  l: '#fff27a', // lightning
30  U: '#c0504d', // umbrella
31  x: '#b03a2e', // bug
32  Y: '#f5d76e', // pellet
33  G: '#3f8f4a', // leaves
34  P: '#2f6b4a', // pine
35  T: '#7a5230', // trunk
36  C: '#5d9b4f', // cactus
37  R: '#7a3b3b', // roof
38  H: '#8a6f5a', // wall
39  L: '#f2d24b', // lit window
40  M: '#f2eecb', // moon
41  g: '#4f7f3a', // grass
42  S: '#d8b26e', // sand
43  N: '#3a3a5a', // night ground
44  W: '#e8eef2', // snow
45  n: '#f2a6c8', // shiny Claude
46  A: '#8d6cc4', // witch hat, bats
47  O: '#e8892b', // pumpkin, dusk sun
48  c: '#4fa3e0', // whale
49  D: '#25232b', // dungeon brick
50  K: '#35323d', // mortar
51  E: '#3b312c', // lamplit brick
52  J: '#4a3d33', // lamplit mortar
53  F: '#3d3945', // flagstone
54  Q: '#79c24a', // slime
55}
56
57type Sprite = readonly string[]
58
59// 9 pixels wide, 5 tall: body columns 1-7, arms at 0 and 8, four legs.
60const LEGS_A = '.o.o.o.o.'
61const LEGS_B = '..o.o.o.o'
62
63const HEAD = '.ooooooo.'
64const EYES = '.okoooko.'
65const ARMS = 'ooooooooo'
66/** Clawd's eyes are tall bars: the second pixel sits in the arms row. */
67const ARMS_EYES = 'ookoookoo'
68
69const STAND: Sprite = [HEAD, EYES, ARMS_EYES, HEAD, LEGS_A]
70const STRIDE: Sprite = [HEAD, EYES, ARMS_EYES, HEAD, LEGS_B]
71
72/** Arms up, eyes squeezed into > < : Clawd's happy face. */
73const CHEER: Sprite = ['o.......o', 'okoooooko', '.okoooko.', '.koooook.', LEGS_A]
74
75/** Arms up and a red "!" beside the head: something needs the user. */
76const ALERT: Sprite = ['o.......o.r', 'ooooooooo.r', '.okoooko..r', HEAD, '.o.o.o.o..r']
77
78const SIT: Sprite = [HEAD, EYES, ARMS_EYES, HEAD]
79const BLINK: Sprite = [HEAD, HEAD, ARMS, HEAD]
80const YAWN: Sprite = [HEAD, '.kkoookk.', 'ooookoooo', HEAD]
81const SLEEP: Sprite = ['..ooooo..', '.kkoookk.', ARMS]
82
83/** Seated poses for the rest-time fidgets. */
84const WAVE: Sprite[] = [
85  ['.ooooooo.o', '.okoooko.o', 'ookoooko.', HEAD],
86  ['.ooooooo..o', '.okoooko.o', 'ookoooko.', HEAD],
87]
88const SCRATCH: Sprite[] = [
89  ['oooooooo.', EYES, '.okoookoo', HEAD],
90  [HEAD, 'ookoooko.', '.okoookoo', HEAD],
91]
92const LOOK: Sprite[] = [
93  [HEAD, '.koookoo.', 'okoookooo', HEAD],
94  [HEAD, '.ookoook.', 'oookoooko', HEAD],
95]
96/** One eye shut to a dash. */
97const WINK: Sprite = [HEAD, '.okooooo.', 'ookookkoo', HEAD]
98
99/** Sitting with a rod out front; the line and water are drawn by the stage. */
100const FISH: Sprite = ['.ooooooo...m', '.okoooko..m.', 'ookoookoos..', HEAD]
101/** Rod bent up, eyes squeezed shut: something bit. */
102const REEL: Sprite = ['.ooooooo.mm.', '.kkoookk.m..', 'ooooooooos..', HEAD]
103
104/** Cross-eyed and wobbling after two stumbles in a row; stars circle the head. */
105const DIZZY: Sprite[] = [
106  [HEAD, '.ookokoo.', ARMS_EYES, HEAD, LEGS_A],
107  [HEAD, '.ookokoo.', ARMS_EYES, HEAD, LEGS_B],
108]
109/** Arms up, brows down: three in a row. The table is drawn by the stage. */
110const FLIP: Sprite = ['o.......o', 'ookoookoo', '.ookokoo.', HEAD, LEGS_A]
111/** A broom out front, bristles swishing: compaction. */
112const SWEEP: Sprite[] = [
113  [HEAD, EYES, 'ookoookoos..', '.ooooooo..s.', '.o.o.o.o..yy'],
114  [HEAD, EYES, 'ookoookoos..', '.ooooooo.s..', '.o.o.o.o.yy.'],
115]
116
117/** Paws over the eyes, knees knocking: rm -rf. */
118const COVER: Sprite[] = [
119  [HEAD, ARMS, HEAD, HEAD, LEGS_A],
120  [HEAD, ARMS, HEAD, HEAD, '.oo...oo.'],
121]
122
123const READ: Sprite[] = [
124  [HEAD, '.okoooko.BBb', 'ookoookoobBb', '.ooooooo.bbb', LEGS_A],
125  [HEAD, '.ooooooo.BBb', 'ooooooooobbB', '.ooooooo.bbb', LEGS_A], // blink, page turns
126]
127
128const DIG: Sprite[] = [
129  ['.ooooooo...m', '.okoooko..s.', 'ookoookoos..', HEAD, LEGS_A],
130  ['.ooooooo.d.d', EYES, 'ookoookoos..', '.ooooooo..s.', '.o.o.o.o..mm'],
131]
132
133const FLY: Sprite[] = [
134  ['w.......w', 'wooooooow', EYES, EYES, '..o...o..'],
135  ['.........', HEAD, EYES, 'wokoookow', 'w.o...o.w'],
136]
137
138const CARRY_TOP: Sprite = ['.yyyyyyy.', 'oysyyysyo', 'ooooooooo', EYES, EYES]
139const CARRY: Sprite[] = [
140  [...CARRY_TOP, LEGS_A],
141  [...CARRY_TOP, LEGS_B],
142]
143
144const SNEAK: Sprite[] = [
145  [HEAD, EYES, ARMS_EYES, LEGS_A],
146  [HEAD, EYES, ARMS_EYES, LEGS_B],
147]
148
149/** Flat on its back, legs up, stars circling. */
150const TRIP: Sprite[] = [
151  ['.z...z...', LEGS_A, HEAD, ARMS_EYES, '.okoooko.'],
152  ['z.....z..', LEGS_B, HEAD, ARMS_EYES, '.okoooko.'],
153]
154
155/** A short turn ends on a content blink, no fuss. */
156const NOD: Sprite[] = [STAND, [HEAD, HEAD, ARMS, HEAD, LEGS_A]]
157/** A long turn: one arm up, then the other, feet shuffling. */
158const DANCE: Sprite[] = [
159  ['o........', 'okoooooko', '.okoooko.', '.koooook.', LEGS_A],
160  ['........o', 'okoooooko', '.okoooko.', '.koooook.', LEGS_B],
161]
162/** A very long turn: a flag planted at the summit, flapping. */
163const FLAG: Sprite[] = [
164  ['.........srrr', '.ooooooo.srr.', '.okoooko.s...', 'ookoookoos...', '.ooooooo.s...', '.o.o.o.o.s'],
165  ['.........srr.', '.ooooooo.srrr', '.okoooko.s...', 'ookoookoos...', '.ooooooo.s...', '.o.o.o.o.s'],
166]
167/** Seated, juggling while the helpers work: arms take turns. The balls are drawn by the stage. */
168const JUGGLE: Sprite[] = [
169  ['o........', 'oooooooo.', EYES, '.okoookoo', HEAD],
170  ['........o', '.oooooooo', EYES, 'ookoooko.', HEAD],
171]
172/** Waking at the limit reset: arms high, eyes still shut. */
173const STRETCH: Sprite[] = [
174  ['o.......o', 'o.......o', HEAD, '.kkoookk.', HEAD, LEGS_A],
175  ['o.......o', HEAD, '.kkoookk.', HEAD, LEGS_A],
176]
177
178/** First session ever: an egg rocks and cracks, and Clawd hatches out. */
179const EGG: Sprite[] = [
180  ['...WWW...', '..WWWWW..', '.WWWWkWW.', '.WWWkWWW.', '..WWWWW..'],
181  ['..WWW....', '.WWWWW...', 'WWWWkWW..', 'WWWkWkWW.', '.WWWWW...'],
182  ['...WWW...', '..WWWWW..', '.WWkWkWW.', '.WkWWWkW.', '..WWWWW..'],
183  ['....WWW..', '...WWWWW.', '..WWkWWWW', '.WWkWkWWW', '...WWWWW.'],
184]
185
186/** Dungeon: a sword raised, then thrust at whatever is on the floor. */
187const FIGHT: Sprite[] = [
188  ['.ooooooo.m', '.okoooko.m', 'ookoookoos', HEAD, LEGS_A],
189  [HEAD, EYES, 'ookoookoosmmm', HEAD, LEGS_B],
190]
191/** Dungeon: a wand raised for a shell command, the star at its tip twinkling. */
192const CAST: Sprite[] = [
193  ['.ooooooo...z', '.okoooko..s.', 'ookoookoos..', HEAD, LEGS_A],
194  ['.ooooooo.A.A', '.okoooko..sz', 'ookoookoos.A', HEAD, LEGS_A],
195]
196/** Dungeon: Claude pulls out a scroll instead of a book. */
197const SCROLL: Sprite[] = [
198  ['.ooooooo.sss', '.okoooko.BkB', 'ookoookooBBB', '.ooooooo.sss', LEGS_A],
199  ['.ooooooo.sss', '.ooooooo.BkB', 'oooooooooBBB', '.ooooooo.sss', LEGS_A], // blink
200]
201
202/** A sticky note held up, then pressed to the wall with a red pin: a memory write. */
203const PIN: Sprite[] = [
204  [HEAD, EYES, 'ookoookooYY', '.ooooooo.YY', LEGS_A],
205  ['.oooooooorY', '.ookoookoYY', 'oookoook.', HEAD, LEGS_A],
206]
207/** Eyes on a grey cloud that sparks, a sweat drop running down: the API gave out. */
208const STUCK: Sprite[] = [
209  ['jooooooo..vv.', '.ookoook.vvvv', 'oookoooko..l.', HEAD, LEGS_A],
210  ['.ooooooo..vv.', 'jookoook.vrvv', 'oookoooko....', HEAD, LEGS_A],
211]
212
213/** In-between poses: knees bent on the way down to a seat or up from it, eyes drooping before sleep. */
214const CROUCH: Sprite = [HEAD, EYES, ARMS_EYES, LEGS_A]
215const DROWSY: Sprite = [HEAD, '.kkoookk.', ARMS, HEAD]
216
217type Posture = 'stand' | 'sit' | 'lie' | 'air' | 'egg'
218const SEATED = new Set<Action>(['sit', 'yawn', 'wave', 'scratch', 'look', 'wink', 'fish', 'reel', 'listen', 'juggle', 'drowsy'])
219
220function posture(action: Action): Posture {
221  if (SEATED.has(action)) return 'sit'
222  if (action === 'sleep' || action === 'trip') return 'lie'
223  if (action === 'fly') return 'air'
224  if (action === 'hatch') return 'egg'
225  return 'stand'
226}
227
228/**
229 * The in-between poses played, one beat each, when Clawd's posture changes, so it doesn't pop
230 * from standing to seated or from the air to the ground. A trip stays instant: the fall is the joke.
231 */
232export function transition(from: Action, to: Action): readonly Action[] {
233  const a = posture(from), b = posture(to)
234  if (a === b || a === 'egg' || b === 'egg' || to === 'trip' || from === 'crouch' || from === 'drowsy') return []
235  if (b === 'lie') return ['drowsy']
236  if (from === 'sleep') return b === 'sit' ? ['drowsy'] : ['drowsy', 'crouch']
237  return ['crouch']
238}
239
240const UMBRELLA: Sprite = ['..UUUUU..', '.UUUUUUU.', 'U...s...U']
241const HEART: Sprite = ['q.q', '.q.']
242const ZED: Sprite = ['ww', '.w', 'ww']
243const BUG: Sprite[] = [
244  ['.xx.', 'x.x.'],
245  ['.xx.', '.x.x'],
246]
247const CLOUD: Sprite = ['..vvv...', '.vvvvvv.', 'vvvvvvvv']
248const BOLT: Sprite = ['.l', 'l.', '.l']
249
250/** Pixels the critter floats above the ground, per action and frame. */
251function lift(action: Action, frame: number): number {
252  if (action === 'fly') return 2 + (frame % 4 < 2 ? 1 : 0)
253  if (action === 'cheer') return frame % 2 === 0 ? 1 : 0
254  if (action === 'alert') return frame % 4 < 2 ? 2 : 0
255  // Seated, Clawd breathes: a one-pixel bob, the official idle.
256  if (action === 'sit' || action === 'wink' || action === 'fish') return Math.floor(frame / 4) % 2
257  if (action === 'dance') return Math.floor(frame / 2) % 2
258  // Nodding along to the music.
259  if (action === 'listen') return Math.floor(frame / 3) % 2
260  return 0
261}
262
263export function sprite(action: Action, frame: number, dungeon = false): Sprite {
264  const two = frame % 2
265  switch (action) {
266    case 'read':
267      return (dungeon ? SCROLL : READ)[frame % 12 === 0 ? 1 : 0] ?? STAND
268    case 'cast':
269      return CAST[Math.floor(frame / 2) % 2] ?? STAND
270    case 'fight':
271      return FIGHT[Math.floor(frame / 2) % 2] ?? STAND
272    case 'dig':
273      return DIG[Math.floor(frame / 2) % 2] ?? STAND
274    case 'fly':
275      return FLY[two] ?? STAND
276    case 'carry':
277      return CARRY[two] ?? STAND
278    case 'sneak':
279      return SNEAK[Math.floor(frame / 2) % 2] ?? STAND
280    case 'trip':
281      return TRIP[Math.floor(frame / 3) % 2] ?? STAND
282    case 'cheer':
283      return CHEER
284    case 'alert':
285      return ALERT
286    case 'sit':
287      return frame % 25 === 0 ? BLINK : SIT
288    case 'yawn':
289      return YAWN
290    case 'sleep':
291      return SLEEP
292    case 'wave':
293      return WAVE[Math.floor(frame / 2) % 2] ?? SIT
294    case 'scratch':
295      return SCRATCH[Math.floor(frame / 2) % 2] ?? SIT
296    case 'look':
297      return LOOK[Math.floor(frame / 5) % 2] ?? SIT
298    case 'cover':
299      return COVER[two] ?? STAND
300    case 'dizzy':
301      return DIZZY[Math.floor(frame / 2) % 2] ?? STAND
302    case 'flip':
303      return FLIP
304    case 'sweep':
305      return SWEEP[Math.floor(frame / 2) % 2] ?? STAND
306    case 'drop':
307      return CHEER
308    case 'wink':
309      return WINK
310    case 'fish':
311      return FISH
312    case 'reel':
313      return REEL
314    case 'run':
315      return two ? STRIDE : STAND
316    case 'nod':
317      return NOD[frame % 10 < 6 ? 0 : 1] ?? STAND
318    case 'dance':
319      return DANCE[Math.floor(frame / 2) % 2] ?? CHEER
320    case 'flag':
321      return FLAG[Math.floor(frame / 3) % 2] ?? STAND
322    case 'listen':
323      return frame % 16 < 4 ? BLINK : SIT
324    case 'juggle':
325      return JUGGLE[Math.floor(frame / 2) % 2] ?? SIT
326    case 'stretch':
327      return STRETCH[Math.floor(frame / 5) % 2] ?? STAND
328    case 'hatch':
329      return EGG[Math.floor(frame / 2) % EGG.length] ?? STAND
330    case 'pin':
331      return PIN[Math.floor(frame / 3) % 2] ?? STAND
332    case 'stuck':
333      return STUCK[Math.floor(frame / 3) % 2] ?? STAND
334    case 'crouch':
335      return CROUCH
336    case 'drowsy':
337      return DROWSY
338    default:
339      return Math.floor(frame / 2) % 2 ? STRIDE : STAND
340  }
341}
342
343/** Columns moved per tick: fractional speeds are spread over ticks. */
344export function speed(action: Action): number {
345  switch (action) {
346    case 'run':
347    case 'fly':
348      return 1
349    case 'walk':
350    case 'carry':
351      return 0.5
352    case 'sneak':
353      return 0.34
354    default:
355      return 0
356  }
357}
358
359export const HERO_W = 13
360export const STAGE_ROWS = 12 // pixel rows: 6 terminal lines
361const GROUND = STAGE_ROWS - 1
362
363type SceneDef = { ground: string; prop: Sprite; sky?: Sprite }
364
365const SCENES: readonly SceneDef[] = [
366  {
367    ground: 'g',
368    prop: ['..GGG..', '.GGGGG.', 'GGGGGGG', '.GGGGG.', 'GGGGGGG', '...T...', '...T...'],
369  },
370  {
371    ground: 'S',
372    prop: ['..C....', '..C.C..', 'C.C.C..', 'CCC.C..', '..CCC..', '..C....', '..C....'],
373  },
374  {
375    ground: 'N',
376    prop: ['...R...', '..RRR..', '.RRRRR.', '.HLHLH.', '.HHHHH.', '.HLHHH.', '.HHHHH.'],
377    sky: ['.MM.', 'MMMM', '.MM.'],
378  },
379  {
380    ground: 'W',
381    prop: ['...P...', '..PPP..', '.PPPPP.', '..PPP..', '.PPPPP.', 'PPPPPPP', '...T...'],
382  },
383]
384
385/** The dungeon hallway's props, one per scene: a door, barrels, a portcullis, a bone pile. */
386const DUNGEON_PROPS: readonly Sprite[] = [
387  ['..eee..', '.eTTTe.', 'eTTTTTe', 'eTTTTTe', 'eTTTyTe', 'eTTTTTe', 'eTTTTTe'],
388  ['.......', '.......', '.TTT...', 'TsssT..', 'TTTTT.T', 'TsssTTs', 'TTTTTTT'],
389  ['mmmmmmm', 'm.m.m.m', 'm.m.m.m', 'mmmmmmm', 'm.m.m.m', 'm.m.m.m', 'm.m.m.m'],
390  ['.......', '.......', '.......', '..WWW..', '.WkWkW.', 'WWWWWWW', 'WkWWWkW'],
391]
392
393export const SCENE_COUNT = SCENES.length
394
395const TINT: Record<Worker['tier'], string> = { opus: 'p', sonnet: 't', haiku: 'h', fable: 'f', other: 'e' }
396export const TIER_COLOR: Record<Worker['tier'], string> = {
397  opus: '#9b7fd4',
398  sonnet: '#5fb8b0',
399  haiku: '#8fcf6a',
400  fable: '#e0b84c',
401  other: '#8a8780',
402}
403
404export function tierOf(model: string | undefined): Worker['tier'] {
405  const m = (model ?? '').toLowerCase()
406  for (const t of ['opus', 'sonnet', 'haiku', 'fable'] as const) if (m.includes(t)) return t
407  return 'other'
408}
409
410/** Context fill as weather: clear, clouds, rain, storm. */
411export type Weather = 'clear' | 'clouds' | 'rain' | 'storm'
412/** Context fill as lamp oil, in the dungeon: 0 all bright, 1 burning low, 2 half out, 3 one lamp left. */
413export function lampsOf(percent: number | null): 0 | 1 | 2 | 3 {
414  if (percent === null || percent < 50) return 0
415  if (percent < 70) return 1
416  if (percent < 85) return 2
417  return 3
418}
419
420export function weatherOf(percent: number | null): Weather {
421  if (percent === null || percent < 50) return 'clear'
422  if (percent < 70) return 'clouds'
423  if (percent < 85) return 'rain'
424  return 'storm'
425}
426
427export type Extras = {
428  weather: Weather
429  bugs: number
430  todos: { done: number; total: number } | null
431  /** Clock ms, for effects; time of day and the holiday come from the caller. */
432  now?: number
433  sky?: 'day' | 'dusk' | 'night'
434  holiday?: Holiday | null
435  fireworks?: boolean
436  hat?: Hat | null
437  shiny?: boolean
438  late?: boolean
439  fx?: readonly Fx[]
440  face?: Face | null
441  /** Three or more helpers out: the boss tie. */
442  tie?: boolean
443  /** The project's scarf, a palette key; null with /tales scarf off. */
444  scarf?: string | null
445  /** The dungeon theme: a lamplit hallway instead of the meadow and its weather. */
446  dungeon?: boolean
447  /** Dungeon lamp level from lampsOf. */
448  lamps?: 0 | 1 | 2 | 3
449  /** Dungeon: skeletons squaring up to Claude after failed calls. */
450  foes?: number
451}
452
453const NONE: Extras = { weather: 'clear', bugs: 0, todos: null }
454
455function paint(grid: string[][], art: Sprite, left: number, top: number, onlyEmpty = false) {
456  art.forEach((row, y) => {
457    const line = grid[top + y]
458    if (!line) return
459    for (let x = 0; x < row.length; x++) {
460      const c = row[x]
461      const at = left + x
462      if (!c || c === '.' || at < 0 || at >= line.length) continue
463      if (onlyEmpty && line[at] !== '.') continue
464      line[at] = c
465    }
466  })
467}
468
469export function mirror(art: Sprite): Sprite {
470  return art.map(r => r.padEnd(HERO_W, '.').split('').reverse().join(''))
471}
472
473/** The eye rows, shifted one column to glance left (-1) or right (1). */
474const GLANCES: readonly [string, string, string][] = [
475  [EYES, '.koookoo.', '.ookoook.'],
476  [ARMS_EYES, 'okoookooo', 'oookoooko'],
477]
478function glanced(rows: Sprite, look: -1 | 1): Sprite {
479  return rows.map(r => {
480    for (const [from, left, right] of GLANCES) if (r.includes(from)) return r.replace(from, look < 0 ? left : right)
481    return r
482  })
483}
484
485function critter(grid: string[][], action: Action, x: number, dir: 1 | -1, frame: number, body?: string, look?: -1 | 1 | null, rise = 0, dungeon = false) {
486  const raw = sprite(action, frame, dungeon)
487  const art = body ? raw.map(r => r.replaceAll('o', body)) : raw
488  let drawn = dir < 0 ? mirror(art) : art
489  if (look && !body) drawn = glanced(drawn, look)
490  const top = GROUND - drawn.length - lift(action, frame) - rise
491  paint(grid, drawn, Math.round(x), top)
492  return top
493}
494
495const STARS: readonly [number, number][] = [[0, 0], [2, -1], [4, -1], [6, -1], [8, 0], [6, 1], [4, 1], [2, 1]]
496const TABLE: Sprite[] = [
497  ['sssss', 's...s'],
498  ['s...s', 'sssss'],
499]
500
501/** Hats are the body's width (9), sitting on the pixel rows just above the head. */
502const HATS: Record<Hat, Sprite> = {
503  witch: ['....A....', '...AAA...', 'AAAzzzAAA'],
504  santa: ['.....rW..', '..rrrr...', '.WWWWWWW.'],
505  party: ['....z....', '...qbq...', '..bqbqb..'],
506  nightcap: ['......bw.', '..bbbbb..', '.WWWWWWW.'],
507  crown: ['.z.z.z.z.', '.zzzzzzz.'],
508  tophat: ['..eeeee..', '..eeeee..', '..WWWWW..', '.eeeeeee.'],
509  gradcap: ['eeeeeeeee', '..eeeeez.', '..eeeee.z'],
510  captain: ['..WWWWW..', '.WWWzWWW.', 'eeeeeeeee'],
511  wizard: ['....b....', '...bbb...', '..bbzbb..', 'bbbbbbbbb'],
512  hardhat: ['...zzz...', '.zzzzzzz.', 'yyyyyyyyy'],
513  shell: ['..W.W.W..', '..WWWWW..'],
514  deerstalker: ['...TTT...', '.TTsTsTT.', 'TTTTTTTTT'],
515  beanie: ['....W....', '..ggggg..', '.gGgGgGg.'],
516  pith: ['..SSSSS..', '.SSSSSSS.', 'SSSSSSSSS'],
517  sprout: ['...h.h...', '....h....'],
518  beret: ['.....t...', '..ttttt..', '.ttttttt.'],
519  regal: ['.p.p.p.p.', '.ppppppp.'],
520}
521
522/** Face accessories, drawn from the eye row down. */
523const FACES: Record<Face, Sprite> = {
524  glasses: ['.kkk.kkk.', 'kkWkkkWkk'],
525  shades: ['kkkkkkkkk', '.kkW.kkW.'],
526  mustache: ['.........', '.........', '.kkk.kkk.'],
527}
528/** Which sprite row the eyes are on, per pose; poses without a usable face are left out. */
529const EYE_ROW: Partial<Record<Action, number>> = {
530  walk: 1, run: 1, read: 1, dig: 1, sneak: 1, sit: 1, wave: 1, scratch: 1, look: 1, wink: 1, fish: 1,
531  cheer: 1, yawn: 1, alert: 2, fly: 2, carry: 3, nod: 1, dance: 1, flag: 2, listen: 1, juggle: 2, fight: 1, cast: 1, crouch: 1, drowsy: 1, pin: 1, stuck: 1,
532}
533/** Three or more helpers out: Claude is the boss, collar and tie under the eyes. */
534const TIE: Sprite = ['...WrW...', '....r....']
535/** Over each eye a heart, the notch filled in with the body color (B). */
536const HEART_EYES: Sprite = ['.qBq.qBq.', '..q...q..']
537/** Worried brows a row above the eyes: with the eyes glancing away they slant into a side-eye. */
538const BROWS: Sprite = ['..k...k..']
539const HEADPHONES: Sprite = ['..eeeee..', '.e.....e.', 'r.......r', 'r.......r']
540const NOTE: Sprite = ['.qq', '.q.', 'qq.']
541const BODY_W = 9
542
543/** The thought bubble, dots filling in. */
544function bubble(frame: number): Sprite {
545  const dots = ['wkwwwww', 'wkwkwww', 'wkwkwkw'][Math.floor(frame / 3) % 3] ?? 'wkwkwkw'
546  return ['.wwwww.', dots, '.wwwww.', 'w......']
547}
548
549const DREAM_ICONS: Partial<Record<Action, Sprite>> = {
550  read: ['BBbBB', 'BBbBB', 'bbbbb'],
551  dig: ['.m...', '..s..', '...s.'],
552  fly: ['w...w', 'ww.ww', '.w.w.'],
553  carry: ['yyyyy', 'ysyys', 'yyyyy'],
554  sneak: ['.mm..', 'm..m.', '.mm.s'],
555  run: ['..z..', '.zzz.', '..z..'],
556  cover: ['..r..', '..r..', '..r..'],
557}
558function dream(last: Action | undefined): Sprite {
559  const icon = (last && DREAM_ICONS[last]) || ['.q.q.', '.qqq.', '..q..']
560  return ['.wwwwwww.', ...icon.map(r => `w.${r}.w`), '.wwwwwww.', '.w.......']
561}
562
563const BUTTERFLY: Sprite[] = [
564  ['q.q', '.s.'],
565  ['.q.', '.s.'],
566]
567const DROP: Sprite = ['j', 'j']
568const BAT: Sprite[] = [
569  ['A.A.A', '.AAA.'],
570  ['.A.A.', 'AAAAA'],
571]
572const PUMPKIN: Sprite = ['..G..', '.OOO.', 'OLOLO', 'OOOOO', '.OOO.']
573const SUN: Sprite = ['.zz.', 'zzzz', 'zzzz', '.zz.']
574const DUSK_SUN: Sprite = ['.OO.', 'OOOO']
575const MOON: Sprite = ['.MM.', 'MM..', 'MM..', '.MM.']
576const MUG: Sprite[] = [
577  ['.w.', 'w..', 'ss.', 'sss'],
578  ['w..', '.w.', 'ss.', 'sss'],
579]
580
581const BOX: Sprite = ['yyy', 'ysy']
582const PLANE: Sprite = ['w....', 'www..', '.wwww']
583const WHALE: Sprite[] = [
584  ['..........j.j', '...cccccc..j.', '.cccccccccc.c', 'ccwccccccccc.', '.BBBBBBBccc..'],
585  ['...........j.', '...cccccc.j.j', '.cccccccccc..', 'ccwcccccccccc', '.BBBBBBBccc..'],
586]
587const UFO: Sprite[] = [
588  ['...mmm...', '.mmmmmmm.', 'lmlmlmlml', '.mmmmmmm.'],
589  ['...mmm...', '.mmmmmmm.', 'mlmlmlmlm', '.mmmmmmm.'],
590]
591const TRAIN_SMOKE: Sprite[] = [
592  ['..ww', '.ww.'],
593  ['.ww.', 'ww..'],
594]
595const TRAIN: Sprite = [
596  '..ee..................................',
597  '.eeeeeee.UUU..UUUUUUUUU.  UUUUUUUUU...',
598  '.eeeeeeeeUUU..ULULULULU...ULULULULU...',
599  'eeeeeeeeeeeee.UUUUUUUUU...UUUUUUUUU...',
600  '.m.m...m.m.....m.....m.....m.....m....',
601].map(r => r.replace(/ /g, '.'))
602const CONFETTI = ['r', 'z', 't', 'q', 'b', 'h']
603/** A gold coin, spinning edge-on and back. */
604const COIN: Sprite[] = [['zz', 'Oz'], ['z', 'O'], ['zz', 'zO']]
605/** Dungeon todos: one closed chest per pending item. */
606const CHEST: Sprite = ['.TTT.', 'TyzyT', 'TTTTT']
607/** Dungeon test failures: slimes squishing along the floor. */
608const SLIME: Sprite[] = [
609  ['.QQQ.', 'QkQkQ', 'QQQQQ'],
610  ['.....', 'QkQkQ', 'QQQQQ'],
611]
612/** Dungeon tool errors: a skeleton with a club, facing Claude. */
613const SKELETON: Sprite[] = [
614  ['.WWW.', 'WkWkW', '.WWWs', 'WWWWs', '.W.W.'],
615  ['.WWW.', 'WkWkW', '.WWW.', 'WWWWs', '.W.Ws'],
616]
617const LAMP_FLAME: Sprite[] = [
618  ['.z.', 'zlz', '.O.'],
619  ['z..', '.lz', '.O.'],
620]
621const LAMP_LOW: Sprite = ['...', '.z.', '.O.']
622const LAMP_OUT: Sprite[] = [
623  ['.v.', 'v..', '...'],
624  ['v..', '.v.', '...'],
625]
626const SCONCE: Sprite = ['sss', '.s.']
627const LAMP_TOP = 2
628
629/** One effect at its age: where it is, drawn over the stage. */
630function effect(grid: string[][], fx: Fx, now: number, frame: number, cols: number) {
631  const age = Math.max(0, now - fx.start)
632  const t = Math.min(1, age / fx.dur)
633  switch (fx.kind) {
634    case 'confetti':
635      for (let n = 0; n < 14; n++) {
636        // Bursts out over the head, then flutters down.
637        const vx = ((n * 37) % 13) - 6
638        const x = Math.round(fx.x + 4 + vx * (0.6 + t * 2.5))
639        const y = Math.round(((n * 5) % 3) + 2 + t * t * 8)
640        paint(grid, [CONFETTI[n % CONFETTI.length] ?? 'z'], x, y)
641      }
642      return
643    case 'plane': {
644      const x = Math.round(fx.x + 8 + t * (cols + 10))
645      const y = Math.round(6 - t * 6 + Math.sin(t * 12) * 1.5)
646      paint(grid, PLANE, x, y)
647      return
648    }
649    case 'boxes':
650      for (let n = 0; n < 3; n++) {
651        const land = Math.min(1, Math.max(0, (age - n * 500) / 600))
652        if (age < n * 500) continue
653        const y = Math.round(land * (GROUND - 2 - n * 2))
654        paint(grid, BOX, Math.round(fx.x), y)
655      }
656      return
657    case 'train': {
658      const x = Math.round(cols - t * (cols + TRAIN[0]!.length + 4))
659      paint(grid, TRAIN, x, GROUND - TRAIN.length)
660      paint(grid, TRAIN_SMOKE[frame % 2] ?? [], x + 1, GROUND - TRAIN.length - 2)
661      return
662    }
663    case 'whale':
664      paint(grid, WHALE[Math.floor(frame / 4) % 2] ?? [], Math.round(-14 + t * (cols + 16)), 0, true)
665      return
666    case 'ufo':
667      paint(grid, UFO[frame % 2] ?? [], Math.round(cols - t * (cols + 10)), 0, true)
668      return
669    case 'coin': {
670      // A coin pops up over Claude's head, spinning, and fades.
671      const spin = COIN[Math.floor(frame / 2) % COIN.length] ?? []
672      if (t < 0.85) paint(grid, spin, Math.round(fx.x + 4), Math.max(0, Math.round(3 - t * 3)))
673      return
674    }
675    case 'coins':
676      for (let n = 0; n < 6; n++) {
677        // A burst arcs out from the chest or the slain monster and rains onto the floor.
678        const vx = (n - 2.5) * 1.6
679        const x = Math.round(fx.x + 4 + vx * (0.5 + t * 2))
680        const y = Math.min(GROUND - 1, Math.round(4 - Math.sin(t * Math.PI) * 4 + t * 6))
681        paint(grid, [(frame + n) % 3 === 0 ? 'O' : 'z'], x, y)
682      }
683      return
684    case 'poof': {
685      const r = 1 + t * 3
686      for (let n = 0; n < 8; n++) {
687        if (t > 0.5 && (n + frame) % 2 === 0) continue
688        const a = (n / 8) * Math.PI * 2
689        paint(grid, ['v'], Math.round(fx.x + 2 + Math.cos(a) * r * 1.6), Math.round(GROUND - 3 + Math.sin(a) * r * 0.7))
690      }
691      return
692    }
693    case 'warp': {
694      // A ring of the server's color spreads from Claude and thins out.
695      const r = 1 + t * 6
696      const c = fx.color ?? 'p'
697      for (let n = 0; n < 10; n++) {
698        if (t > 0.6 && (n + frame) % 2 === 0) continue
699        const a = (n / 10) * Math.PI * 2 + t
700        paint(grid, [c], Math.round(fx.x + 4 + Math.cos(a) * r * 1.8), Math.round(GROUND - 4 + Math.sin(a) * r * 0.8), true)
701      }
702      return
703    }
704  }
705}
706
707/** Night stars, a sun or a moon, all behind the weather. */
708function sky(grid: string[][], cols: number, frame: number, e: Extras, ownSky: boolean) {
709  if (!e.sky) return
710  if (e.sky === 'night') {
711    for (let x = 3; x < cols; x += 9) {
712      const y = (x * 7) % 4
713      if ((frame + x) % 23 !== 0) paint(grid, ['W'], x, y)
714    }
715  }
716  if (ownSky || e.weather !== 'clear') return
717  const art = e.sky === 'day' ? SUN : e.sky === 'dusk' ? DUSK_SUN : MOON
718  paint(grid, art, cols - 8, e.sky === 'dusk' ? 3 : 0)
719}
720
721function festive(grid: string[][], cols: number, frame: number, e: Extras) {
722  switch (e.holiday) {
723    case 'halloween':
724      for (let n = 0; n < 2; n++) {
725        const x = (Math.floor(frame / 2) + n * 40) % (cols + 10) - 5
726        paint(grid, BAT[(frame + n) % 2] ?? [], x, 1 + n * 2 + (Math.floor(frame / 3) % 2), true)
727      }
728      paint(grid, PUMPKIN, 1, GROUND - PUMPKIN.length)
729      return
730    case 'valentine':
731      for (let n = 0; n < 3; n++) {
732        const y = 8 - (Math.floor(frame / 3) + n * 3) % 9
733        paint(grid, HEART, (n * 31 + 7) % Math.max(1, cols - 3), y, true)
734      }
735      return
736    default:
737      return
738  }
739}
740
741function fireworks(grid: string[][], cols: number, frame: number) {
742  const burst = Math.floor(frame / 8)
743  const age = frame % 8
744  const x = (burst * 29) % Math.max(1, cols - 6) + 3
745  const c = CONFETTI[burst % CONFETTI.length] ?? 'z'
746  if (age < 2) paint(grid, [c], x, 5 - age * 2, true)
747  else if (age < 6) {
748    const r = age - 1
749    for (const [dx, dy] of [[0, -1], [1, 0], [0, 1], [-1, 0], [1, -1], [-1, -1], [1, 1], [-1, 1]] as const) {
750      paint(grid, [c], x + dx * r, 2 + dy * Math.ceil(r / 2), true)
751    }
752  }
753}
754
755/** Snow at Christmas falls whatever the weather. */
756function snow(grid: string[][], cols: number, frame: number) {
757  for (let x = 2; x < cols; x += 5) {
758    const y = (Math.floor(frame / 2) + x * 3) % GROUND
759    paint(grid, ['W'], x + (Math.floor(frame / 4) + x) % 2, y, true)
760  }
761}
762
763/** A sprite over the body, anchored like hats and face gear, flipped with Claude. */
764function onBody(grid: string[][], art: Sprite, x: number, dir: 1 | -1, y: number) {
765  paint(grid, dir > 0 ? art : art.map(r => r.padEnd(BODY_W, '.').split('').reverse().join('')), dir > 0 ? x : x + HERO_W - BODY_W, y)
766}
767
768function emote(grid: string[][], kind: Emote, x: number, dir: 1 | -1, top: number, frame: number, eyes?: number, body = 'o') {
769  switch (kind) {
770    case 'smitten':
771      if (eyes !== undefined) onBody(grid, HEART_EYES.map(r => r.replaceAll('B', body)), x, dir, top + eyes)
772      emote(grid, 'blush', x, dir, top, frame)
773      return
774    case 'sheepish':
775      if (eyes !== undefined) onBody(grid, BROWS, x, dir, top + eyes - 1)
776      emote(grid, 'sweat', x, dir, top, frame)
777      return
778    case 'think': {
779      // Clear of a hat brim on the side Claude faces.
780      const art = bubble(frame)
781      paint(grid, dir > 0 ? art : art.map(r => r.split('').reverse().join('')), dir > 0 ? x + 10 : x - 4, top - 3)
782      return
783    }
784    case 'blush':
785      paint(grid, ['q'], x + (dir > 0 ? 1 : 5), top + 3)
786      paint(grid, ['q'], x + (dir > 0 ? 7 : 11), top + 3)
787      paint(grid, HEART, dir > 0 ? x + 9 : x + 1, top - 1 - (Math.floor(frame / 3) % 2))
788      return
789    case 'sweat':
790      paint(grid, DROP, dir > 0 ? x + 8 : x + 4, top - 1 + (Math.floor(frame / 3) % 3))
791      return
792    case 'butterfly': {
793      const bx = x + 4 + Math.round(Math.sin(frame / 3) * 6)
794      const by = Math.max(0, top - 2 + Math.round(Math.cos(frame / 2)))
795      paint(grid, BUTTERFLY[frame % 2] ?? [], bx, by)
796      return
797    }
798  }
799}
800
801const FISHY: Sprite[] = [
802  ['.c.', 'ccc', '.c.'],
803  ['c..', 'ccc', '..c'],
804]
805
806/** The line from the rod tip to a puddle in front of Claude, a bobber, and on a bite a fish on the hook. */
807function fishingLine(grid: string[][], action: Action, x: number, dir: 1 | -1, top: number, frame: number, cols: number) {
808  // Sprite column c lands at x + c facing right, x + (HERO_W - 1 - c) mirrored.
809  const at = (c: number) => (dir > 0 ? x + c : x + HERO_W - 1 - c)
810  const pond = [13, 14, 15, 16, 17].map(at)
811  for (const c of pond) {
812    const row = grid[GROUND]
813    if (row && c >= 0 && c < cols) row[c] = 'j'
814  }
815  const lineCol = at(15)
816  if (action === 'fish') {
817    paint(grid, ['m'], at(12), top - 1)
818    paint(grid, ['m'], at(13), top - 1)
819    paint(grid, ['m'], at(14), top)
820    for (let y = top + 1; y < GROUND - 1; y++) paint(grid, ['m'], lineCol, y)
821    paint(grid, ['r'], lineCol, GROUND - 1 - (Math.floor(frame / 4) % 2))
822    return
823  }
824  // A bite: the line goes taut and a fish wriggles up out of the water.
825  paint(grid, ['m'], at(11), top - 1)
826  paint(grid, ['m'], at(12), top - 1)
827  paint(grid, ['m'], at(12), top)
828  const fish = FISHY[Math.floor(frame / 2) % 2] ?? []
829  paint(grid, dir > 0 ? fish : fish.map(r => r.split('').reverse().join('')), at(12) - 1, Math.min(top + 1, GROUND - 3))
830}
831
832/** A scarf on the first body row under the eyes, its tail streaming out behind. */
833function scarf(grid: string[][], rows: Sprite, color: string, x: number, dir: 1 | -1, top: number, eyes: number, frame: number) {
834  // Past the eyes and the arms (a blink frame's arm row has no eyes in it).
835  const arms = (r: string) => r[0] === 'o' && r[8] === 'o'
836  let neck = eyes + 1
837  while (neck < rows.length - 1 && ((rows[neck] ?? '').slice(1, 8).includes('k') || arms(rows[neck] ?? ''))) neck += 1
838  // Under a box or wings the eyes sit low and only legs are left: no scarf.
839  if (!/ooooo/.test(rows[neck] ?? '')) return
840  // Sprite column c lands at x + c facing right, x + (HERO_W - 1 - c) mirrored.
841  const at = (c: number) => (dir > 0 ? x + c : x + HERO_W - 1 - c)
842  const put = (c: number, y: number) => paint(grid, [color], at(c), top + y)
843  for (let c = 0; c <= 7; c++) put(c, neck)
844  put(0, neck + 1)
845  put(-1, neck + (frame % 6 < 3 ? 0 : 1)) // the tail flutters
846}
847
848const WALKING = new Set<Action>(['walk', 'run', 'sneak', 'carry'])
849
850/** Running helpers line up behind each other: four or more make a conga. */
851export const CONGA_AT = 4
852
853/** The stage as pixel rows: sky, weather, ground, scenery, pellets, bugs, helpers, Claude, then effects. */
854export function stage(hero: Hero, workers: readonly Worker[], frame: number, cols: number, extras: Extras = NONE): string[] {
855  const grid = Array.from({ length: STAGE_ROWS }, () => Array<string>(cols).fill('.'))
856  const def = SCENES[hero.scene % SCENES.length] ?? SCENES[0]
857  if (!def) return grid.map(r => r.join(''))
858  const dungeon = !!extras.dungeon
859  // Underground there is no weather: context fill burns the lamp oil instead.
860  const wet: Weather = dungeon ? 'clear' : extras.weather
861  const lit: [number, number][] = []
862  if (dungeon) {
863    grid[GROUND] = Array.from({ length: cols }, (_, x) => (x % 7 === 0 ? 'K' : 'F'))
864    const level = extras.lamps ?? 0
865    const spots: number[] = []
866    for (let x = 8 + ((hero.scene * 5) % 9); x < cols - 3; x += 22) spots.push(x)
867    // At the last level only the lamp nearest Claude still burns.
868    const nearest = spots.reduce((a, b) => (Math.abs(b - hero.x - 4) < Math.abs(a - hero.x - 4) ? b : a), spots[0] ?? 0)
869    spots.forEach((x, n) => {
870      const on = level < 2 || (level === 2 ? n % 2 === 0 : x === nearest && frame % 7 !== 0)
871      paint(grid, SCONCE, x, LAMP_TOP + 3)
872      if (!on) paint(grid, LAMP_OUT[Math.floor(frame / 4) % 2] ?? [], x, LAMP_TOP)
873      else if (level === 0) paint(grid, LAMP_FLAME[Math.floor(frame / 3) % 2] ?? [], x, LAMP_TOP)
874      else paint(grid, LAMP_LOW, x, LAMP_TOP)
875      if (on) lit.push([x + 1, level === 0 ? 4 + (frame % 5 === 0 ? 0 : 1) : 3])
876    })
877  } else {
878    grid[GROUND] = Array<string>(cols).fill(def.ground)
879    sky(grid, cols, frame, extras, !!def.sky)
880    if (def.sky && wet === 'clear') paint(grid, def.sky, cols - 8, 0)
881  }
882  if (wet !== 'clear') {
883    const drift = Math.floor(frame / 6)
884    for (let x = 0; x < cols + 20; x += 20) paint(grid, CLOUD, ((x + drift) % (cols + 20)) - 10, 0)
885  }
886  festive(grid, cols, frame, extras)
887  if (extras.fireworks) fireworks(grid, cols, frame)
888  const prop = dungeon ? (DUNGEON_PROPS[hero.scene % DUNGEON_PROPS.length] ?? def.prop) : def.prop
889  for (let x = 4 + ((hero.scene * 7) % 11); x < cols - 7; x += 26) {
890    paint(grid, prop, x, GROUND - prop.length)
891  }
892
893  // Todos on the ground, right to left: pellets in the meadow, treasure chests in the dungeon.
894  if (extras.todos && extras.todos.total > 0) {
895    const left = extras.todos.total - extras.todos.done
896    if (dungeon) for (let n = 0; n < Math.min(left, 8); n++) paint(grid, CHEST, cols - 6 - n * 7, GROUND - CHEST.length)
897    else for (let n = 0; n < Math.min(left, 12); n++) paint(grid, ['Y'], cols - 3 - n * 3, GROUND - 1)
898  }
899
900  // Failing tests: bugs crawl along the ground, or slimes squish along the dungeon floor.
901  for (let n = 0; n < Math.min(extras.bugs, 8); n++) {
902    const span = Math.max(1, cols - 6)
903    const x = (n * 37 + Math.floor((frame + n * 5) / (dungeon ? 5 : 3))) % span
904    if (dungeon) paint(grid, SLIME[Math.floor((frame + n * 3) / 3) % 2] ?? [], x, GROUND - 3)
905    else paint(grid, BUG[(frame + n) % 2] ?? [], x, GROUND - 2)
906  }
907
908  // Failed calls in the dungeon: skeletons line up in front of Claude, clubs toward it.
909  if (dungeon && extras.foes) {
910    for (let n = 0; n < Math.min(extras.foes, 3); n++) {
911      const art = SKELETON[Math.floor((frame + n * 2) / 3) % 2] ?? []
912      const ahead = hero.dir > 0 ? Math.round(hero.x) + HERO_W + n * 6 : Math.round(hero.x) - 6 - n * 6
913      paint(grid, hero.dir > 0 ? art.map(r => r.split('').reverse().join('')) : art, ahead, GROUND - art.length)
914    }
915  }
916
917  const now = extras.now ?? 0
918  const fx = extras.fx ?? []
919  for (const f of fx) if (f.kind === 'boxes') effect(grid, f, now, frame, cols)
920
921  workers.forEach((w, n) => {
922    const f = frame + n * 3 // out of step with each other
923    const body = w.state === 'failed' || w.sad ? 'e' : TINT[w.tier]
924    const top = critter(grid, w.action, w.x, w.dir, f, body, null, 0, !!extras.dungeon)
925    if (w.state === 'done' && w.sad) {
926      // Claude's paw pats the head of a helper that didn't make it.
927      paint(grid, ['ooo'], Math.round(w.x) + 3, Math.max(0, top - 1 - (Math.floor(f / 2) % 2)))
928      paint(grid, HEART, Math.round(w.x) + 8, Math.max(0, top - 2))
929    } else if (w.state === 'done') {
930      const rise = Math.floor(f / 3) % 2
931      paint(grid, HEART, Math.round(w.x) + 3, Math.max(0, top - 2 - rise))
932    }
933  })
934
935  // April 1st: Claude walks backwards.
936  const face: 1 | -1 = extras.holiday === 'aprilfools' && WALKING.has(hero.action) ? (hero.dir > 0 ? -1 : 1) : hero.dir
937  const x = Math.round(hero.x)
938  // Dropping in: falls from the sky over the first ~0.7 s, then a puff on landing.
939  const dropAge = hero.action === 'drop' ? Math.max(0, now - hero.since) : Infinity
940  const fall = Math.max(0, Math.round(12 - dropAge / 60))
941  // Heart eyes stay put; any other time the eyes may wander.
942  const glance = hero.glance && now < (hero.glanceUntil ?? 0) && hero.emote !== 'smitten' ? hero.glance : null
943  const top = critter(grid, hero.action, hero.x, face, frame, extras.shiny ? 'n' : undefined, glance, fall, dungeon)
944  if (hero.action === 'drop' && fall === 0 && dropAge < 1100) {
945    paint(grid, ['w', '.w'], x - 2, GROUND - 2)
946    paint(grid, ['.w', 'w'], x + 9, GROUND - 2)
947  }
948  if (hero.action === 'dizzy') {
949    for (let n = 0; n < 2; n++) {
950      const [dx, dy] = STARS[(Math.floor(frame / 2) + n * 4) % STARS.length] ?? [0, 0]
951      paint(grid, ['z'], x + dx, Math.max(0, top - 2 + dy))
952    }
953  }
954  if (hero.action === 'flip') {
955    const k = frame % 10
956    const tx = face > 0 ? x + 8 + k : x + 4 - k - 5
957    paint(grid, TABLE[Math.floor(frame / 2) % 2] ?? [], tx, Math.max(0, top - 1 - Math.floor(k / 2)))
958  }
959  if (hero.action === 'sweep' && frame % 4 < 2) {
960    paint(grid, ['v.v'], face > 0 ? x + 12 + (frame % 3) : x - 4 - (frame % 3), GROUND - 2 - (frame % 2), true)
961  }
962  if (extras.shiny && frame % 6 < 3) {
963    paint(grid, ['z'], x + ((frame * 5) % 10), Math.max(0, top - 1))
964    paint(grid, ['W'], x + ((frame * 11 + 7) % 10), top + 2)
965  }
966  const wearsHat = extras.hat && hero.action !== 'trip' && hero.action !== 'hatch' && !(wet === 'rain' || wet === 'storm')
967  if (wearsHat && extras.hat) {
968    const hat = HATS[extras.hat]
969    paint(grid, face > 0 ? hat : hat.map(r => r.split('').reverse().join('')), face > 0 ? x : x + HERO_W - BODY_W, top - hat.length)
970  }
971  const wear = extras.face ? FACES[extras.face] : null
972  const eyes = EYE_ROW[hero.action]
973  if (wear && eyes !== undefined) {
974    paint(grid, face > 0 ? wear : wear.map(r => r.split('').reverse().join('')), face > 0 ? x : x + HERO_W - BODY_W, top + eyes)
975  }
976  if (extras.scarf && eyes !== undefined) scarf(grid, sprite(hero.action, frame, dungeon), extras.scarf, x, face, top, eyes, frame)
977  if (extras.tie && eyes !== undefined) onBody(grid, TIE, x, face, top + eyes + 1)
978  if (hero.action === 'listen') paint(grid, HEADPHONES, x, top - 2)
979  if ((hero.action === 'listen' || hero.action === 'dance') && frame % 12 < 8) {
980    paint(grid, NOTE, face > 0 ? x + 10 : x - 3, Math.max(0, top - 2 - (Math.floor(frame / 4) % 2)), true)
981  }
982  if (hero.action === 'juggle') {
983    // One ball per helper out, in its tint, on an arc over Claude's head.
984    const balls = workers.filter(w => w.state === 'running').slice(0, 4)
985    const n = Math.max(2, balls.length)
986    for (let i = 0; i < n; i++) {
987      const a = frame * 0.45 + (i * Math.PI * 2) / n
988      const tint = balls[i] ? TINT[balls[i]!.tier] : 'z'
989      paint(grid, [tint], Math.round(x + 4 + Math.cos(a) * 5), Math.max(0, Math.round(top - 2 - Math.abs(Math.sin(a)) * 3)))
990    }
991  }
992  if (hero.action === 'fish' || hero.action === 'reel') fishingLine(grid, hero.action, x, face, top, frame, cols)
993  if (wet === 'rain' || wet === 'storm') paint(grid, UMBRELLA, x, Math.max(0, top - 3))
994  if (hero.action === 'sleep') {
995    if (Math.floor(frame / 15) % 2 === 1) {
996      paint(grid, dream(hero.last), x + 7, Math.max(0, top - 7))
997    } else {
998      const zx = x + 8 + (Math.floor(frame / 5) % 3)
999      paint(grid, ZED, zx, Math.max(0, top - 3 - (Math.floor(frame / 5) % 2)))
1000    }
1001  }
1002  if (extras.late && (hero.action === 'sit' || hero.action === 'look' || hero.action === 'scratch' || hero.action === 'wave')) {
1003    paint(grid, MUG[Math.floor(frame / 3) % 2] ?? [], face > 0 ? x + 10 : x - 1, GROUND - 4)
1004  }
1005  if (hero.emote) emote(grid, hero.emote, x, face, top, frame, eyes, extras.shiny ? 'n' : 'o')
1006  if (hero.action === 'cheer' && workers.some(w => w.state === 'done')) {
1007    paint(grid, HEART, x + 3, Math.max(0, top - 2))
1008  }
1009  for (const f of fx) if (f.kind !== 'boxes') effect(grid, f, now, frame, cols)
1010
1011  // Rain falls through the empty sky; a storm adds a flash of lightning.
1012  if (wet === 'rain' || wet === 'storm') {
1013    for (let x = 1; x < cols; x += 4) {
1014      const y = 2 + ((frame + x * 7) % (GROUND - 2))
1015      paint(grid, ['j'], x, y, true)
1016    }
1017  }
1018  if (extras.holiday === 'christmas' && !dungeon) snow(grid, cols, frame)
1019  if (wet === 'storm' && frame % 40 < 3) paint(grid, BOLT, (frame * 13) % Math.max(1, cols - 2), 2, true)
1020  if (dungeon) bricks(grid, lit)
1021  return grid.map(r => r.join(''))
1022}
1023
1024/** The hallway wall behind everything: bricks in every empty pixel, warm near a burning lamp. */
1025function bricks(grid: string[][], lit: readonly [number, number][]) {
1026  for (let y = 0; y < GROUND; y++) {
1027    const row = grid[y]
1028    if (!row) continue
1029    const course = Math.floor(y / 3)
1030    for (let x = 0; x < row.length; x++) {
1031      if (row[x] !== '.') continue
1032      const mortar = y % 3 === 2 || (x + (course % 2) * 4) % 8 === 0
1033      // Full warmth near the flame, a dithered rim further out.
1034      const d = Math.min(...lit.map(([lx, r]) => ((x - lx) / (r * 1.8)) ** 2 + ((y - LAMP_TOP - 1) / r) ** 2), Infinity)
1035      const warm = d <= 0.45 || (d <= 1 && (x + y) % 2 === 0)
1036      row[x] = warm ? (mortar ? 'J' : 'E') : mortar ? 'K' : 'D'
1037    }
1038  }
1039}
1040
1041export type Segment = { text: string; color?: string; backgroundColor?: string }
1042
1043/** Two pixel rows into one line of half-block segments, adjacent styles merged. */
1044export function halfBlocks(top: string, bottom: string): Segment[] {
1045  const out: Segment[] = []
1046  for (let i = 0; i < top.length; i++) {
1047    const a = PALETTE[top[i] ?? '.']
1048    const b = PALETTE[bottom[i] ?? '.']
1049    let seg: Segment
1050    if (!a && !b) seg = { text: ' ' }
1051    else if (a && !b) seg = { text: '▀', color: a }
1052    else if (!a && b) seg = { text: '▄', color: b }
1053    else seg = { text: '▀', color: a, backgroundColor: b }
1054    const last = out[out.length - 1]
1055    if (last && last.color === seg.color && last.backgroundColor === seg.backgroundColor) last.text += seg.text
1056    else out.push(seg)
1057  }
1058  return out
1059}
1060
1061export function lines(rows: readonly string[]): Segment[][] {
1062  const out: Segment[][] = []
1063  for (let y = 0; y < rows.length; y += 2) out.push(halfBlocks(rows[y] ?? '', rows[y + 1] ?? ''))
1064  return out
1065}
1066
1067/** One-glyph stand-in for the critter when there is no room for the stage. */
1068const GLYPHS: Record<Action, readonly string[]> = {
1069  walk: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
1070  run: ['⠁', '⠂', '⠄', '⡀', '⢀', '⠠', '⠐', '⠈'],
1071  sneak: ['∙∙∙', '●∙∙', '∙●∙', '∙∙●'],
1072  read: ['▤'],
1073  crouch: ['▃'],
1074  drowsy: ['◡'],
1075  dig: ['▖', '▘', '▝', '▗'],
1076  fly: ['◜', '◝', '◞', '◟'],
1077  carry: ['▰▱▱', '▰▰▱', '▰▰▰'],
1078  trip: ['✗'],
1079  cheer: ['✓'],
1080  alert: ['!', ' '],
1081  sit: ['·'],
1082  yawn: ['○'],
1083  sleep: ['z', 'zz', 'zzz'],
1084  wave: ['~', '≈'],
1085  scratch: ['#'],
1086  look: ['◂', '▸'],
1087  cover: ['◡', '◠'],
1088  dizzy: ['@', '◎'],
1089  flip: ['┻━┻'],
1090  sweep: ['⌇'],
1091  drop: ['↓', '✓'],
1092  wink: [';'],
1093  fish: ['⌐', '¬'],
1094  reel: ['><>'],
1095  nod: ['·', '˙'],
1096  dance: ['♪', '♫'],
1097  flag: ['⚑'],
1098  listen: ['♪'],
1099  juggle: ['∘°', '°∘'],
1100  stretch: ['\\o/'],
1101  hatch: ['◯', '◔', '◑', '◕'],
1102  fight: ['⚔', '†'],
1103  pin: ['¶'],
1104  stuck: ['☁', '☈'],
1105  cast: ['✦', '✧'],
1106}
1107
1108export function glyph(action: Action, frame: number): string {
1109  const set = GLYPHS[action]
1110  return set[Math.floor(frame / 2) % set.length] ?? '·'
1111}
1112
hooks/story.ts 499 lines
1/**
2 * Turns a tool call into an action and a one-line caption, from templates.
3 * No model calls: Claude Fables asks Sonnet every few seconds; this reads the call itself.
4 */
5import type { Action, Emote, Form, Hat, Holiday, Stats } from '../types'
6import { tierOf } from './art'
7
8export type Beat = { action: Action; caption: string }
9
10type Args = Record<string, unknown>
11
12function str(a: Args, key: string): string {
13  const v = a[key]
14  return typeof v === 'string' ? v : ''
15}
16
17function base(path: string): string {
18  return path.split('/').filter(Boolean).pop() ?? path
19}
20
21function host(url: string): string {
22  const m = /^[a-z]+:\/\/([^/]+)/i.exec(url)
23  return m?.[1] ?? url
24}
25
26function clip(text: string, room: number): string {
27  const one = text.replace(/\s+/g, ' ').trim()
28  return one.length > room ? one.slice(0, room - 1) + '…' : one
29}
30
31/** A stable pick among phrasings, so a reload or redraw keeps the same line. */
32function pick(lines: readonly string[], seed: string): string {
33  let h = 0
34  for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0
35  return lines[Math.abs(h) % lines.length] ?? lines[0] ?? ''
36}
37
38/** Auto memory and memory vaults: a MEMORY.md, or anything under a memory/ or claude-memory/ folder. */
39export function isMemoryPath(path: string): boolean {
40  return /(^|\/)(claude-)?memory\//i.test(path) || base(path) === 'MEMORY.md'
41}
42
43/** A write to a memory file is a note pinned up, in either theme. */
44function memoryBeat(tool: string, args: Args, seed: string, dungeon: boolean): Beat | null {
45  const path = str(args, 'file_path')
46  if ((tool !== 'Write' && tool !== 'Edit') || !isMemoryPath(path)) return null
47  const file = base(path)
48  return dungeon
49    ? { action: 'pin', caption: pick([`Claude nails a note to the dungeon wall: ${file}`, `Claude scratches ${file} into the wall to remember`], seed) }
50    : { action: 'pin', caption: pick([`Claude pins a note to memory: ${file}`, `Claude jots down ${file} so it won't forget`], seed) }
51}
52
53export function beat(tool: string, args: Args, seed: string): Beat {
54  const file = base(str(args, 'file_path') || str(args, 'notebook_path') || str(args, 'path'))
55  const memo = memoryBeat(tool, args, seed, false)
56  if (memo) return memo
57  switch (tool) {
58    case 'Read':
59      return {
60        action: 'read',
61        caption: pick([`Claude pores over ${file}`, `Claude studies ${file}`, `Claude reads ${file} by lamplight`], seed),
62      }
63    case 'Edit':
64    case 'NotebookEdit':
65      return {
66        action: 'dig',
67        caption: pick([`Claude digs into ${file}`, `Claude reshapes ${file}`, `Claude tunnels through ${file}`], seed),
68      }
69    case 'Write':
70      return { action: 'dig', caption: pick([`Claude builds ${file}`, `Claude lays the stones of ${file}`], seed) }
71    case 'Grep':
72    case 'Glob': {
73      const what = clip(str(args, 'pattern'), 40)
74      return {
75        action: 'sneak',
76        caption: pick([`Claude sniffs out “${what}”`, `Claude tracks “${what}” through the brush`], seed),
77      }
78    }
79    case 'Bash': {
80      const what = clip(str(args, 'description') || str(args, 'command'), 60)
81      return { action: 'run', caption: pick([`Claude runs off to ${lower(what)}`, `Claude hurries: ${what}`], seed) }
82    }
83    case 'WebFetch':
84      return { action: 'fly', caption: `Claude flies off to ${host(str(args, 'url'))}` }
85    case 'WebSearch':
86      return { action: 'fly', caption: `Claude scouts the skies for “${clip(str(args, 'query'), 40)}”` }
87    case 'Agent':
88    case 'Task':
89      return { action: 'carry', caption: `Claude hands a parcel to a helper: ${clip(str(args, 'description'), 50)}` }
90    case 'Skill':
91      return { action: 'read', caption: `Claude opens the ${str(args, 'skill') || 'old'} spellbook` }
92    case 'TodoWrite':
93      return { action: 'read', caption: 'Claude writes the quest list' }
94    case 'AskUserQuestion':
95      return { action: 'alert', caption: 'Claude waves you over with a question' }
96    default:
97      if (tool.startsWith('mcp__')) {
98        const server = tool.split('__')[1]?.replace(/^claude_ai_/, '').replace(/_/g, ' ') ?? 'a stranger'
99        return { action: 'carry', caption: `Claude sends word to ${server}` }
100      }
101      return { action: 'walk', caption: `Claude tries the ${tool}` }
102  }
103}
104
105/**
106 * The same call told in the dungeon: scrolls, runes, ravens and spells. With monsters on the
107 * floor, editing and running turn into a fight.
108 */
109export function dungeonBeat(tool: string, args: Args, seed: string, foes = 0): Beat {
110  const file = base(str(args, 'file_path') || str(args, 'notebook_path') || str(args, 'path'))
111  const memo = memoryBeat(tool, args, seed, true)
112  if (memo) return memo
113  const fight = (what: string): Beat | null =>
114    foes > 0 ? { action: 'fight', caption: pick([`Claude battles the monster${foes === 1 ? '' : 's'} in ${what}`, `Claude swings at the bugs in ${what}`], seed) } : null
115  switch (tool) {
116    case 'Read':
117      return { action: 'read', caption: pick([`Claude unrolls the scroll of ${file}`, `Claude reads ${file} by lamplight`, `Claude pulls out the scroll of ${file}`], seed) }
118    case 'Edit':
119    case 'NotebookEdit':
120      return fight(file) ?? { action: 'dig', caption: pick([`Claude chisels new runes into ${file}`, `Claude digs through ${file}`], seed) }
121    case 'Write':
122      return fight(file) ?? { action: 'dig', caption: pick([`Claude carves a new tablet: ${file}`, `Claude lays the stones of ${file}`], seed) }
123    case 'Grep':
124    case 'Glob':
125      return { action: 'sneak', caption: `Claude searches the dark halls for “${clip(str(args, 'pattern'), 40)}”` }
126    case 'Bash': {
127      const what = clip(str(args, 'description') || str(args, 'command'), 60)
128      const spell = pick(['cast', 'dash'], seed) === 'cast'
129      return fight(`the ${lower(what)}`) ?? (spell ? { action: 'cast', caption: `Claude casts a spell: ${lower(what)}` } : { action: 'run', caption: `Claude dashes down the hall to ${lower(what)}` })
130    }
131    case 'WebFetch':
132      return { action: 'fly', caption: `Claude sends a raven to ${host(str(args, 'url'))}` }
133    case 'WebSearch':
134      return { action: 'fly', caption: `Claude asks the oracle about “${clip(str(args, 'query'), 40)}”` }
135    case 'Agent':
136    case 'Task':
137      return { action: 'carry', caption: `Claude recruits a party member: ${clip(str(args, 'description'), 50)}` }
138    case 'Skill':
139      return { action: 'read', caption: `Claude opens the ${str(args, 'skill') || 'old'} grimoire` }
140    case 'TodoWrite':
141      return { action: 'read', caption: 'Claude marks the treasure map' }
142    default:
143      return beat(tool, args, seed)
144  }
145}
146
147/** A failed call in the dungeon: a skeleton steps out and lands a hit. */
148export function ambushCaption(tool: string, foes: number, seed: string): string {
149  if (foes >= 3) return `(╯°□°)╯︵ ┻━┻  ${foes} skeletons! Claude flips the table`
150  if (foes === 2) return 'A second skeleton! Claude sees stars'
151  return pick([`A skeleton jumps out of the ${tool}. Claude takes a hit`, `The ${tool} was a trap. A skeleton blocks the way`], seed)
152}
153
154/** The next clean call after an ambush. */
155export function victoryCaption(foes: number): string {
156  return foes === 1 ? 'Claude beats the skeleton. A coin drops!' : `Claude beats all ${foes} skeletons. Coins drop!`
157}
158
159function lower(s: string): string {
160  return s ? s.charAt(0).toLowerCase() + s.slice(1) : s
161}
162
163export function tripCaption(tool: string, seed: string): string {
164  return pick([`Claude trips over the ${tool} and dusts off`, `The ${tool} bites back. Claude regroups`], seed)
165}
166
167export const THINKING = ['Claude ponders the path ahead…', 'Claude wanders, thinking…', 'Claude mutters to itself…']
168
169export function ending(steps: number, seconds: number, isAborted: boolean, best = 0, milestone: number | null = null): string {
170  if (isAborted) return 'Claude stops mid-stride and takes a bow.'
171  const s = steps === 1 ? 'step' : 'steps'
172  const tokens = milestone ? ` · ${Math.round(milestone / 1000)}K tokens!` : ''
173  return `The end · ${steps} ${s} · ${seconds}s${best >= CROWN_AT ? ` · crown ×${best}` : ''}${tokens}`
174}
175
176/** The turn died on an API error (overloaded, offline, out of retries): Claude waits under the cloud. */
177export function stuckCaption(dungeon: boolean): string {
178  return dungeon ? 'The portal fizzles out. Claude waits for it to light again.' : 'The cloud stopped answering. Claude waits out the storm.'
179}
180
181const MODEL_HAT: Record<ReturnType<typeof tierOf>, Hat | null> = { haiku: 'sprout', sonnet: 'beret', opus: 'regal', fable: 'crown', other: null }
182
183/** A new model between turns: Claude tries on its hat. Null when nothing changed or there was no model before. */
184export function modelSwitch(before: string | null, after: string): { hat: Hat | null; caption: string } | null {
185  if (!before || !after || before === after) return null
186  const tier = tierOf(after)
187  const name = tier === 'other' ? after : tier.charAt(0).toUpperCase() + tier.slice(1)
188  return { hat: MODEL_HAT[tier], caption: `Claude changes into ${name}` }
189}
190
191/** The closing pose by turn length: a nod for a quick one, a hop, a dance, then a flag at the summit. */
192export function endingAction(seconds: number, isAborted: boolean): Action {
193  if (isAborted || seconds < 4) return 'nod'
194  if (seconds < 60) return 'cheer'
195  if (seconds < 300) return 'dance'
196  return 'flag'
197}
198
199/** Every 50K tokens the session spends is a small party. */
200export const TOKEN_MILESTONE = 50_000
201/** The milestone crossed going from before to after, or null. */
202export function tokenMilestone(before: number, after: number): number | null {
203  const m = Math.floor(after / TOKEN_MILESTONE)
204  return m > Math.floor(before / TOKEN_MILESTONE) && m > 0 ? m * TOKEN_MILESTONE : null
205}
206
207/** Claude idles while the helpers work: headphones for one, juggling for more. */
208export function waitBeat(out: number, label?: string): Beat {
209  if (out <= 1) return { action: 'listen', caption: `Claude puts on headphones while ${label ? `“${clip(label, 30)}”` : 'the helper'} works…` }
210  return { action: 'juggle', caption: `Claude juggles while ${out} helpers work…` }
211}
212
213function hashOf(text: string): number {
214  let h = 0
215  for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0
216  return Math.abs(h)
217}
218
219/** A stable palette key per MCP server, for its warp. */
220const WARP_COLORS = ['p', 't', 'h', 'f', 'c', 'q', 'O', 'b', 'z']
221export function warpColor(server: string): string {
222  return WARP_COLORS[hashOf(server) % WARP_COLORS.length] ?? 'p'
223}
224
225/** A scarf color per project, from its root folder; the helpers' tier colors are left out. */
226const SCARF_COLORS = ['r', 'b', 'W', 'A', 'c', 'Y', 'q', 'O'] // no green: it vanishes against the trees
227export function scarfColor(root: string): string {
228  return SCARF_COLORS[hashOf(root) % SCARF_COLORS.length] ?? 'r'
229}
230
231/** Tool calls before the hatchling grows up. */
232export const GROW_AT = 200
233const FORM_OF: Partial<Record<Action, Form>> = {
234  read: 'scholar',
235  sneak: 'detective',
236  dig: 'builder',
237  run: 'hacker',
238  cast: 'hacker',
239  fly: 'explorer',
240  carry: 'captain',
241}
242export const FORM_HAT: Record<Form, Hat> = {
243  scholar: 'gradcap',
244  detective: 'deerstalker',
245  builder: 'hardhat',
246  hacker: 'beanie',
247  explorer: 'pith',
248  captain: 'captain',
249}
250const FORM_WORK: Record<Form, string> = {
251  scholar: 'reading',
252  detective: 'searching',
253  builder: 'editing',
254  hacker: 'running commands',
255  explorer: 'browsing the web',
256  captain: 'sending helpers',
257}
258export const NEW_STATS: Stats = { xp: 0, by: {}, form: null }
259
260/** One main-loop tool call, counted toward the form its pose belongs to. */
261export function countCall(stats: Stats, action: Action): Stats {
262  const form = FORM_OF[action]
263  return { ...stats, xp: stats.xp + 1, by: form ? { ...stats.by, [form]: (stats.by[form] ?? 0) + 1 } : stats.by }
264}
265
266/** The form Clawd has grown into: none before GROW_AT, then the most-done work. A rival has to lead by 10% to take over. */
267export function formOf(stats: Stats): Form | null {
268  if (stats.xp < GROW_AT) return null
269  let top: Form | null = null
270  for (const [form, n] of Object.entries(stats.by) as [Form, number][]) if (!top || n > (stats.by[top] ?? 0)) top = form
271  const cur = stats.form
272  if (cur && top && (stats.by[cur] ?? 0) * 1.1 >= (stats.by[top] ?? 0)) return cur
273  return top
274}
275
276export function growCaption(form: Form, was: Form | null): string {
277  const a = /^[aeiou]/.test(form) ? 'an' : 'a'
278  return was
279    ? `Clawd is ${a} ${form} now: mostly ${FORM_WORK[form]} lately.`
280    : `Clawd grows up into ${a} ${form}! Mostly ${FORM_WORK[form]}.`
281}
282
283/** Old store data back into shape; anything unreadable starts over. */
284export function readStats(raw: unknown): Stats | null {
285  if (!raw || typeof raw !== 'object') return null
286  const r = raw as Partial<Stats>
287  if (typeof r.xp !== 'number') return null
288  const by = r.by && typeof r.by === 'object' ? r.by : {}
289  return { xp: r.xp, by, form: typeof r.form === 'string' && r.form in FORM_HAT ? r.form : null }
290}
291
292/** Clean calls in a row that show a combo, and that earn the crown. */
293export const COMBO_AT = 5
294export const CROWN_AT = 10
295
296/** How the prompt reads: thanks makes Claude blush, a scolding makes it sheepish. */
297export function moodOf(text: string): 'thanks' | 'scold' | null {
298  const t = text.slice(0, 400)
299  if (/\b(thanks|thank (you|u)|thx|tysm|good (job|work|bot)|great (job|work)|nice (job|work)|well done|love (it|this|you)|you('re| are) (the best|awesome|amazing))\b/i.test(t)) {
300    return 'thanks'
301  }
302  if (/^\s*(no+|nope|wrong|wtf|ugh+|stop)\b|\b(wtf|that'?s (wrong|not (it|right|what))|not what i (asked|said|meant|wanted)|i already told you|you broke)\b/i.test(t)) {
303    return 'scold'
304  }
305  return null
306}
307
308export type Egg = 'commit' | 'push' | 'nuke' | 'install' | 'sl'
309
310/** The fun Bash commands, most specific first. */
311export function bashEggs(command: string): Egg[] {
312  const out: Egg[] = []
313  if (/^\s*sl(\s|$)/.test(command)) out.push('sl')
314  if (/\bgit\s+(-C\s+\S+\s+)?commit\b/.test(command)) out.push('commit')
315  if (/\bgit\s+(-C\s+\S+\s+)?push\b/.test(command)) out.push('push')
316  if (/\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r|-r\s+-f|-f\s+-r|--recursive\s+--force|--force\s+--recursive)/.test(command)) {
317    out.push('nuke')
318  }
319  if (/\b(npm|pnpm|yarn|bun)\s+(i|install|add)\b|\bpip3?\s+install\b|\buv\s+(add|pip\s+install)\b|\bcargo\s+(add|install)\b|\b(apt|apt-get|brew|dnf)\s+install\b|\bgo\s+get\b/.test(command)) {
320    out.push('install')
321  }
322  return out
323}
324
325/** A commit's first line, from -m "…" or a heredoc. */
326export function commitMessage(command: string): string {
327  const doc = /<<-?\s*'?"?(\w+)'?"?\s*\n\s*([^\n]+)/.exec(command)
328  const flag = /\s-[a-zA-Z]*m\s+(["'])((?:(?!\1)[^\n])*)/.exec(command)
329  const msg = doc?.[2] ?? (flag?.[2]?.startsWith('$(') ? '' : flag?.[2]) ?? ''
330  return clip(msg, 50)
331}
332
333export function pushRemote(command: string): string {
334  const m = /\bgit\s+(?:-C\s+\S+\s+)?push\s+(?:-\S+\s+)*([\w.-]+)/.exec(command)
335  return m?.[1] ?? 'origin'
336}
337
338/** Before the command runs. */
339export function eggBeat(egg: Egg, command: string): Beat & { emote?: Emote } {
340  switch (egg) {
341    case 'commit': {
342      const msg = commitMessage(command)
343      return { action: 'carry', caption: msg ? `Claude wraps a parcel: “${msg}”` : 'Claude wraps up a commit' }
344    }
345    case 'push':
346      return { action: 'carry', caption: `Claude folds a paper plane for ${pushRemote(command)}` }
347    case 'nuke':
348      return { action: 'cover', caption: `Claude covers its eyes: ${clip(command.trim(), 40)}`, emote: 'sweat' }
349    case 'install':
350      return { action: 'carry', caption: 'Claude orders packages from the sky' }
351    case 'sl':
352      return { action: 'cheer', caption: 'Choo choo!' }
353  }
354}
355
356/** After it ran cleanly. */
357export function eggDone(egg: Egg, command: string): string | null {
358  switch (egg) {
359    case 'commit': {
360      const msg = commitMessage(command)
361      return msg ? `Claude seals the parcel and stamps it: “${msg}”` : 'Claude seals the parcel and stamps it'
362    }
363    case 'push':
364      return `The paper plane sails off to ${pushRemote(command)}`
365    case 'install':
366      return 'Packages rain down. Claude stacks them'
367    default:
368      return null
369  }
370}
371
372/** The program a "command not found" names, or null. */
373export function notFound(output: string): string | null {
374  const m = /(?:^|\n)(?:[\w/.-]+: )?(?:line \d+: )?([\w.+-]+): (?:command )?not found/.exec(output)
375  return m?.[1] ?? null
376}
377
378/** Today's holiday, by local date. */
379export function holidayOf(d: Date): Holiday | null {
380  const m = d.getMonth() + 1
381  const day = d.getDate()
382  if (m === 10 && day >= 25) return 'halloween'
383  if (m === 12 && day >= 18 && day <= 26) return 'christmas'
384  if ((m === 12 && day === 31) || (m === 1 && day === 1)) return 'newyear'
385  if (m === 2 && day === 14) return 'valentine'
386  if (m === 4 && day === 1) return 'aprilfools'
387  return null
388}
389
390/** Fireworks: the hour either side of midnight on New Year's. */
391export function isMidnightNewYear(d: Date): boolean {
392  return (d.getMonth() === 11 && d.getDate() === 31 && d.getHours() === 23) || (d.getMonth() === 0 && d.getDate() === 1 && d.getHours() === 0)
393}
394
395export type Sky = 'day' | 'dusk' | 'night'
396export function skyOf(hour: number): Sky {
397  if (hour >= 20 || hour < 6) return 'night'
398  if (hour < 7 || hour >= 18) return 'dusk'
399  return 'day'
400}
401
402/** 1 to 4 am: nightcap and coffee. */
403export function isLate(hour: number): boolean {
404  return hour >= 1 && hour < 5
405}
406
407const HOLIDAY_START: Record<Holiday, string> = {
408  halloween: 'Once upon a spooky prompt, Claude set out…',
409  christmas: 'Once upon a snowy prompt, Claude set out…',
410  newyear: 'Once upon a brand-new prompt, Claude set out…',
411  valentine: 'Once upon a lovely prompt, Claude set out…',
412  aprilfools: 'Once upon a prompt, Claude set out… backwards.',
413}
414
415/** The opening caption: the mood of the prompt first, then waking, the holiday, the hour. */
416export function startCaption(o: { mood: 'thanks' | 'scold' | null; woke: boolean; holiday: Holiday | null; hour: number; dungeon?: boolean }): string {
417  if (o.mood === 'thanks') return 'Claude blushes and sets out again…'
418  if (o.mood === 'scold') return 'Claude rubs its head sheepishly and tries again…'
419  if (o.woke) return 'Claude wakes with a start and sets out…'
420  if (o.holiday) return o.dungeon ? HOLIDAY_START[o.holiday].replace('set out', 'ventured into the dungeon') : HOLIDAY_START[o.holiday]
421  if (isLate(o.hour)) return 'Claude pours a coffee and sets out…'
422  return o.dungeon ? 'Once upon a prompt, Claude ventured into the dungeon…' : 'Once upon a prompt, Claude set out…'
423}
424
425export type Fidget = { action: Action; caption: string; emote?: Emote }
426export const FIDGETS: readonly Fidget[] = [
427  { action: 'wave', caption: 'Claude waves at you.' },
428  { action: 'scratch', caption: 'Claude scratches its head.' },
429  { action: 'look', caption: 'Claude looks around.' },
430  { action: 'sit', caption: 'A butterfly visits Claude.', emote: 'butterfly' },
431  { action: 'wink', caption: 'Claude winks at you.' },
432]
433export const FIDGET_SLOT_MS = 7000
434export const FIDGET_MS = 3000
435
436/** While sitting: a fidget for the first few seconds of every slot but the first. */
437export function fidgetAt(sitFor: number, seed: number): Fidget | null {
438  if (sitFor >= REST.fishMs) return null
439  const slot = Math.floor(sitFor / FIDGET_SLOT_MS)
440  if (slot === 0 || sitFor - slot * FIDGET_SLOT_MS >= FIDGET_MS) return null
441  return FIDGETS[Math.abs(seed + slot * 7) % FIDGETS.length] ?? null
442}
443
444const DREAMS: Partial<Record<Action, string>> = {
445  read: 'books',
446  dig: 'tunnels',
447  sneak: 'hidden trails',
448  run: 'open roads',
449  fly: 'the sky',
450  carry: 'parcels',
451  cover: 'scary commands',
452}
453export function dreamOf(last: Action | undefined): string {
454  return (last && DREAMS[last]) || 'electric sheep'
455}
456
457const TEST_CMD = /\b(test|tests|pytest|jest|vitest|mocha|rspec|phpunit|tox|nox)\b|cargo t\b|go test|npm t\b|bun test|plugin test/i
458
459/** Pass and fail counts from a test run's output, or null when it isn't one. */
460export function testCounts(command: string, output: string): { failed: number; passed: number } | null {
461  if (!TEST_CMD.test(command)) return null
462  const num = (re: RegExp) => {
463    let best = -1
464    for (const m of output.matchAll(re)) best = Math.max(best, Number(m[1]))
465    return best
466  }
467  const failed = num(/(\d+)\s+(?:failed|failing|fail\b|failures?\b|errors?\b)/gi)
468  const passed = num(/(\d+)\s+(?:passed|passing|pass\b|ok\b)/gi)
469  if (failed < 0 && passed < 0) return null
470  return { failed: Math.max(0, failed), passed: Math.max(0, passed) }
471}
472
473export const REST = { fishMs: 15_000, sitMs: 45_000, yawnMs: 53_000, hideMs: 180_000 }
474
475/** A long wait: Claude casts a line, and every so often something bites. */
476export const BITE_EVERY_MS = 12_000
477export const BITE_MS = 3_000
478export function fishing(sitFor: number): Fidget | null {
479  if (sitFor < REST.fishMs || sitFor >= REST.sitMs) return null
480  const t = sitFor - REST.fishMs
481  if (t > BITE_MS && t % BITE_EVERY_MS < BITE_MS) {
482    return { action: 'reel', caption: 'A bite! Claude reels in a fish… and lets it go.' }
483  }
484  return { action: 'fish', caption: 'Claude casts a line while it waits for you.' }
485}
486
487export function restCaption(action: 'sit' | 'yawn' | 'sleep', last?: Action, dungeon = false): string {
488  if (action === 'sit') return dungeon ? 'Claude sits by a lamp and waits for you.' : 'Claude sits by the path and waits for you.'
489  if (action === 'yawn') return 'Claude yawns…'
490  return `Claude dozes off, dreaming of ${dreamOf(last)}. z z z`
491}
492
493export function clockTime(iso: string | undefined): string {
494  if (!iso) return 'reset'
495  const d = new Date(iso)
496  if (Number.isNaN(d.getTime())) return 'reset'
497  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
498}
499
types/index.d.ts 152 lines
1export type Action =
2  | 'walk'
3  | 'run'
4  | 'sneak'
5  | 'read'
6  | 'dig'
7  | 'fly'
8  | 'carry'
9  | 'trip'
10  | 'cheer'
11  | 'alert'
12  | 'sit'
13  | 'yawn'
14  | 'sleep'
15  | 'wave'
16  | 'scratch'
17  | 'look'
18  | 'cover'
19  | 'wink'
20  | 'fish'
21  | 'reel'
22  | 'dizzy'
23  | 'flip'
24  | 'sweep'
25  | 'drop'
26  | 'nod'
27  | 'dance'
28  | 'flag'
29  | 'listen'
30  | 'juggle'
31  | 'stretch'
32  | 'hatch'
33  /** Dungeon theme: a sword swing while monsters are on the floor. */
34  | 'fight'
35  /** Dungeon theme: a wand raised for a shell command. */
36  | 'cast'
37  /** In-between poses, drawn for a beat when the posture changes (art.ts transition). */
38  | 'crouch'
39  | 'drowsy'
40  /** A note pinned up: a write to a memory file. */
41  | 'pin'
42  /** Under a sparking cloud, sweating: the turn died on an API error. */
43  | 'stuck'
44
45/**
46 * A small overlay on Claude: thought bubble, blush, sweat drop, a visiting butterfly;
47 * smitten adds heart eyes to the blush, sheepish adds worried brows to the sweat.
48 */
49export type Emote = 'think' | 'blush' | 'sweat' | 'butterfly' | 'smitten' | 'sheepish'
50
51/** Timed effects that cross or burst over the stage. */
52export type FxKind = 'confetti' | 'plane' | 'boxes' | 'train' | 'whale' | 'ufo' | 'warp' | 'coin' | 'coins' | 'poof'
53/** color: a palette key, for effects tinted per source (an MCP server's warp). */
54export type Fx = { id: number; kind: FxKind; start: number; dur: number; x: number; color?: string }
55
56/** The stage: the meadow with its weather, or a lamplit dungeon hallway. */
57export type Theme = 'meadow' | 'dungeon'
58
59export type Holiday = 'halloween' | 'christmas' | 'newyear' | 'valentine' | 'aprilfools'
60export type Hat =
61  | 'witch'
62  | 'santa'
63  | 'party'
64  | 'nightcap'
65  | 'crown'
66  | 'tophat'
67  | 'gradcap'
68  | 'captain'
69  | 'wizard'
70  | 'hardhat'
71  | 'shell'
72  | 'deerstalker'
73  | 'beanie'
74  | 'pith'
75  /** Worn for a few seconds after a model switch: Haiku's sprout, Sonnet's beret, Opus's purple crown (Fable keeps the gold one). */
76  | 'sprout'
77  | 'beret'
78  | 'regal'
79/** What Clawd grows into after GROW_AT tool calls, from the work it does most. */
80export type Form = 'scholar' | 'detective' | 'builder' | 'hacker' | 'explorer' | 'captain'
81/** Lifetime progress, saved in $.store: tool calls in all, per form, and the form it grew into. */
82export type Stats = { xp: number; by: Partial<Record<Form, number>>; form: Form | null }
83/** Worn over the face: /tales face. */
84export type Face = 'glasses' | 'shades' | 'mustache'
85
86export type Hero = {
87  /** idle draws nothing; resting is the sit, yawn, sleep ladder after a turn. */
88  mode: 'idle' | 'working' | 'ending' | 'resting'
89  action: Action
90  caption: string
91  x: number
92  dir: 1 | -1
93  scene: number
94  steps: number
95  /** When the current mode began (clock ms), for the rest ladder. */
96  since: number
97  emote?: Emote | null
98  /** Clock ms the emote ends; Infinity holds it until the next tool call. */
99  emoteUntil?: number
100  /** The last tool pose, which Claude dreams about. */
101  last?: Action
102  /** Eyes glancing left or right until glanceUntil (clock ms). */
103  glance?: -1 | 1 | null
104  glanceUntil?: number
105}
106
107/** A spawned subagent, drawn as a tinted critter in the same scene. */
108export type Worker = {
109  id: string
110  label: string
111  tier: 'opus' | 'sonnet' | 'haiku' | 'fable' | 'other'
112  /** returning: done, running back to Claude to hand in the result. */
113  state: 'running' | 'returning' | 'done' | 'failed'
114  action: Action
115  x: number
116  dir: 1 | -1
117  steps: number
118  /** Failed, then walked back to Claude for a pat on the head. */
119  sad?: boolean
120}
121
122export type Limit = { kind: string; percent: number; resetsAt?: string }
123
124declare module 'claude-code' {
125  interface PluginState {
126    'clawd-tales': {
127      hero: Hero
128      workers: Worker[]
129      frame: number
130      isOn: boolean
131      /** What Claude is waiting on the user for, or null. */
132      alert: string | null
133      /** Context fill 0-100, or null before the first measure. */
134      context: number | null
135      /** Plan rate-limit windows (5h, 7d), as last measured. */
136      limits: Limit[]
137      /** Calm mode: one frame a second, no wandering. */
138      calm: boolean
139      bugs: number
140      todos: { done: number; total: number } | null
141      fx: Fx[]
142      /** Rolled once a session: null until then. */
143      shiny: boolean | null
144      /** Clean main-loop tool calls in a row this turn. */
145      combo: number
146      /** Dungeon theme: gold coins earned this session. */
147      gold: number
148      theme: Theme
149    }
150  }
151}
152