SLOPSHOPPER

agent-comic

A little comic above the prompt: Claude acts out what the agent is doing in animated pixel scenes, from a home hub with keepsakes

newbandguardcommandprompttool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-comic
› 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 › /comic ⎿ agent-comic: Comic off. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

catras-claude-code-collection

A Claude Code plugin marketplace with my mods and plugins.

Install

/plugin marketplace add CatraMyBeloved/catras-claude-code-collection
/plugin install agent-comic@catras-claude-code-collection
/plugin install cache-keepalive@catras-claude-code-collection

The repo is private, so git must be authenticated (e.g. gh auth login). If the SSH attempt fails, set CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1.

Plugins

agent-comic

A pixel comic in the band above the prompt: a little Claude acts out what the agent is doing.

  • Home hub: each session opens at Claude's home: a hall with keepsakes from earlier sessions, a garden and a den. While nothing is happening he keeps himself busy at random, like a screensaver: he waters the flowers, watches a film on the couch, lies down in bed for a rest, or strolls past his keepsakes. On any new turn he drops everything and sprints through the door (there is one in every room) into the session's world; after a long idle he comes back home. Claude can add a keepsake (a trophy, a gem, a plant...) after a real milestone, with the mod's own tool (hub_add, deferred, so it costs no context until used); the hub keeps six. /comic-hub lists them.
  • Pet him: the ♥ beside the band (click it in fullscreen mode, or press ctrl+x tab, then p), or /comic-pet: he lights up, and a headpat is sent as your message ("Here, have a headpat. You are doing amazing!"), which Claude answers.
  • One world per session: the session's first turn sets up a place (a setting, a hat and four props) and every later turn plays in it, so the story can call back to earlier turns. The director owns it and changes it rarely, as part of a scene, when the work really shifts: he lifts a prop overhead and, with a puff, it becomes something new (up to 6 per session); a puff over his head brings a new hat (up to 4); or he summons a door and walks through it into new scenery (up to 2). Canned scenes never change it, and nothing is ever squashed away. After a long idle he goes home, and the next turn takes him back into the same world.
  • Needs you: when a permission prompt or a question is waiting, he stops, turns to you and waves.
  • Progress: the agent's task list shows as a trail along the ground, with a flag at the end.
  • Git: a commit plants a little flag, a push lets a balloon go.
  • Sky: the local hour (moon at night, low sun at dusk) and the session's weather (clouds after failures, rain after several), kept in the background.
  • Director: hybrid (default) has the model set up the session's world and stage each turn's opening and wrap-up in it, and plays canned scenes in between; full stages everything with the model; off uses no tokens at all. Set it with /config, along with the model, pace and whether the comic stays up between turns.
  • Places: other plugins can add places to Claude's world through $.comic (a garden, a pond, a minigame). Between turns, wooden signs in the hub's top corners lead to them (a/d once the band has focus); while Claude works the comic takes the band back. With no place installed nothing changes. How to build one: EXTENDING.md; a complete example is pond-place.

Commands: /comic (on/off), /comic-pet, /comic-hub, /comic-stats, /comic-feel <mood>, /comic-demo.

cache-keepalive

Keeps Claude Code's prompt cache warm while you think, so the next prompt reads the conversation from the cache instead of paying to write it again.

  • Countdown: the prompt footer shows how long the cache stays warm, e.g. cache ▰▰▰▰▰▰▱▱ 41:12 (1h), in calm tones: sage green, sand yellow for the last 10 minutes, dusty rose for the last 3 (a 5-minute cache keeps the same proportions). The timer restarts with every request of the main conversation.
  • Keep-alive: shortly before the cache expires (20s by default), and only while Claude is idle, it sends one message asking Claude for a minimal acknowledgement. That request re-reads the cache and restarts its lifetime. After 90 minutes without a prompt from you it lets the cache lapse.
  • Lifetime: auto follows Claude Code's own rules: FORCE_PROMPT_CACHING_5M, then CLAUDE_CODE_PROMPT_CACHE_TTL, the promptCacheTtl setting and ENABLE_PROMPT_CACHING_1H; otherwise 1 hour on a subscription within plan limits and 5 minutes on an API key, a cloud provider or usage credits. The mod cannot always tell when a subscription draws on credits; set the lifetime to 5m in /config then.
  • Gauge: /keepalive opens a pane with the bar, the expiry time, the cached prefix size, a timeline of hits, misses and keep-alives, and buttons to ping now (p) or pause (k).

Commands: /keepalive (gauge), /keepalive status, /keepalive on|off, /keepalive now. Set the mode (prompt or off), lifetime, lead and idle cutoff with /config.

Why a message and not an invisible side request: a side request ($.model.fork) re-sends the conversation, but Claude Code caches it as a separate entry, so it never kept the main conversation's cache warm in testing.

Development

claude plugin validate .
claude plugin test plugins/cache-keepalive
claude plugin test examples/pond-place

examples/ holds example plugins that are not in the marketplace; copy one as a starting point.

CACHE_KEEPALIVE_HEADLESS=1 lets cache-keepalive run under claude -p --plugin-dir plugins/cache-keepalive, for checking its cache behaviour without an interactive session.

License

GPL-3.0. Copyright (c) 2026 Ole Stein.

Source 15 files
hooks/register.tsx 847 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { cannedFirst, cannedInterlude, cannedScene, cannedWrap } from './canned'
5import type { Activity } from './canned'
6import { cellText } from './canvas'
7import { DEMO_MS, demoScene, feelBeat } from './demo'
8import { SYSTEM, buildPrompt, describeCall, varietyNotes } from './director'
9import { MAX_INTERLUDES, addTiming, blendLatency, emptyTimings, formatTimings, nextAsk } from './pacing'
10import type { Timings } from './pacing'
11import { HUB_STORE_KEY, HUB_TOOL, HUB_TOOL_SPEC, MAX_HUB_ITEMS, addHubItem, asHubItems, hubItemFrom } from './hub'
12import type { HubItem } from './hub'
13import { MOODS, clip, parseScene } from './scene'
14import type { Mood, Scene, Stamp } from './scene'
15import { changesLeft, followWorld, limitChanges, noChanges } from './world'
16import type { SessionWorld, WorldChangeCounts } from './world'
17import { PACES, Stage } from './stage'
18import type { Weather } from './stage'
19import { addUsage, asTally, emptyTally, formatStats, today } from './usage'
20import type { Tally } from './usage'
21import { ALIVE_MS, COMIC_VERSION, HUB, asPlace, enteringFrom, kit, routeOf, withPlace, withoutPlace } from './places'
22import type { Comic, ComicSide, ComicState, ComicStop } from '../types'
23
24/** The /config settings, read when the module (re)loads. */
25type Settings = { model: string; pace: keyof typeof PACES; showWhenIdle: boolean; director: Director }
26/** Who stages the scenes: the model for all (full), for a turn's opening and wrap-up (hybrid), or none (off). */
27type Director = 'full' | 'hybrid' | 'off'
28let settings: Settings = { model: 'sonnet', pace: 'calm', showWhenIdle: true, director: 'hybrid' }
29
30const STILL_RUNNING_MS = 4000 // a tool running this long is told to the director before it ends
31const LINGER_MS = 25000 // the band stays this long after a turn ends
32const FRAME_MS = 50
33const MAX_ROWS = 10
34const MIN_ROWS = 6
35const KEY = 'stage'
36const PET_KEY = 'pet'
37// a pat, sent as the person's own message: Claude reads it and answers
38const PET_LINES = [
39  'Here, have a headpat. You are doing amazing!',
40  '*pats your head* You are doing great, Claude.',
41  'Headpat delivery! Thanks for all the help today.',
42  'Here is a little headpat. Keep it up!',
43  '*gentle headpat* Proud of you.',
44  'Have a headpat, you have earned it!',
45  'Headpat break! You are doing wonderfully.',
46  '*pat pat* Great work so far.',
47]
48let lastPetLine = -1
49let isPetTurn = false // Claude is answering a pat: the comic stays as it is
50let pendingPats: string[] = [] // headpats sent, their turns not begun yet
51const HUB_TOOL_NAME = new RegExp(`^mcp__agent-comic__${HUB_TOOL}$`)
52const MAX_HUB_ADDS = 2 // keepsakes per session: a milestone, not a habit
53const WEATHER_MS = 180_000 // failures this recent cloud the sky
54// tools that put a question to the person, who must answer before the turn goes on
55const ASKS_PERSON = new Set(['AskUserQuestion', 'ExitPlanMode'])
56const TEST_RUN = /\b(?:jest|vitest|pytest|mocha|rspec|ctest|tox|phpunit)\b|\b(?:cargo|go|bun|deno|dotnet|mvn|gradle|npm|pnpm|yarn)\s+(?:run\s+)?test\b|\bplugin test\b|\bmake (?:test|check)\b/
57
58const enabled = atom({ plugin: 'agent-comic', key: 'enabled' } as const, true)
59const lingering = atom({ plugin: 'agent-comic', key: 'lingering' } as const, false)
60// the world beyond the hub: places other plugins add through $.comic (places.ts)
61const bandState = atom({ plugin: 'agent-comic', key: 'state' } as const, 'hidden' as ComicState)
62const here = atom({ plugin: 'agent-comic', key: 'here' } as const, null as string | null)
63const cameFrom = atom({ plugin: 'agent-comic', key: 'cameFrom' } as const, null as ComicSide | null)
64const ring = atom({ plugin: 'agent-comic', key: 'places' } as const, [HUB] as ComicStop[])
65const aliveAt = new Map<string, number>() // when each place last said it was drawing
66const SIGN_LEFT = 'sign-left'
67const SIGN_RIGHT = 'sign-right'
68
69const stage = new Stage()
70let mount: { requestId: string; columns: number; rows: number } | null = null
71let isBlitting = false
72
73let goal: string | null = null
74let log: string[] = []
75let fresh = 0
76let lastActivity = 0
77let isTurnRunning = false
78let interludes = 0 // asked for since the last new activity
79let lastAsk = 0
80let epoch = 0 // bumped by each new turn: an answer asked for an older one is dropped
81let askingIn: number | null = null // the epoch of the ask under way, if any
82let failures = 0 // asks in a row that brought no scene
83let retryAfter = 0 // after a failure the director backs off until then
84let isWrapPending = false
85let previous: Scene | null = null
86let recent: Scene[] = [] // the last scenes Sonnet staged, for its variety notes
87let turnSettings: Scene['setting'][] = [] // one per turn, oldest first
88let turnSetting: Scene['setting'] | null = null // the session world's setting, once its first scene set it up
89// the session's world: set up by its first scene, then changed only by the director, now and then
90let sessionWorld: SessionWorld | null = null
91let isWorldShown = false // the stage shows the session's world (not home, not the demo's)
92let worldChanges: WorldChangeCounts = noChanges() // what the director changed this session
93let isOpening = false // a new turn's first scene is still to be staged: the model stages it, in the session's world
94let hasLoggedError = false
95let linger: { cancel: () => void } | null = null
96let directorPausedUntil = 0 // while the demo or a /comic-feel plays, Sonnet waits
97let sessionTally: Tally = emptyTally()
98let latencyMs = 7000 // the director's recent answer time; the first answers correct it
99let firstFreshAt: number | undefined // when the oldest activity not yet staged happened
100let timings: Timings = emptyTimings()
101let acts: Activity[] = [] // what the agent did since the last staged scene, for canned scenes
102let lastSaid: string | undefined // the agent's latest remark to the person
103let failTimes: number[] = [] // recent failures: they bring clouds, and rain
104let hubItems: HubItem[] = []
105let hubAdds = 0
106let tasksMade = 0 // TaskCreate calls this list
107const tasksDone = new Set<string>()
108let answered = 0 // scenes the director handed over this session
109// the main thread's tool calls under way, to tell the director about long ones before they end
110const running = new Map<string, { what: string; since: number; isTold: boolean }>()
111
112function note(line: string) {
113  log.push(line)
114  log = log.slice(-40)
115  if (fresh === 0) firstFreshAt = Date.now()
116  fresh++
117  lastActivity = Date.now()
118  interludes = 0
119}
120
121function drawFrame($: EngineInterface) {
122  stage.step(Date.now())
123  if (!mount || isBlitting) return
124  const { requestId, columns, rows } = mount
125  isBlitting = true
126  $.ui.blit({ requestId, key: KEY, cells: stage.frame(columns, rows) })
127    .catch(() => undefined)
128    .finally(() => { isBlitting = false })
129}
130
131/** A tool still going after a few seconds (a long build, a test run) is news before its result is. */
132function tellRunning() {
133  const now = Date.now()
134  for (const r of running.values()) {
135    if (r.isTold || now - r.since < STILL_RUNNING_MS) continue
136    r.isTold = true
137    note(`still running, no result yet: ${r.what}`)
138  }
139}
140
141async function direct($: EngineInterface) {
142  tellRunning()
143  const now = Date.now()
144  // between turns, a long idle takes him home through the door
145  if (!isTurnRunning && stage.wantsHome) {
146    stage.goHome()
147    isWorldShown = false
148  }
149  stage.ambience = { hour: new Date(now).getHours(), weather: weatherNow(now) }
150  if (askingIn === epoch || now < directorPausedUntil || isPetTurn) return
151  if (!(await read($, enabled)) || askingIn === epoch) return
152  const remainingMs = stage.remainingMs
153  const untilFreeMs = stage.untilFreeMs
154  // a canned scene is ready at once: it is asked for as the stage runs dry, not a model's latency ahead
155  const isCannedNext = isCanned(!turnSetting ? 'first' : isWrapPending ? 'wrap' : 'scene', isOpening) || now < retryAfter
156  const ask = nextAsk({
157    now, pace: settings.pace, fresh, lastAsk, lastActivity, isTurnRunning, isWrapPending,
158    isWorldless: !turnSetting || isOpening, interludes, remainingMs, untilFreeMs, latencyMs: isCannedNext ? 0 : latencyMs,
159  })
160  if (!ask) return
161  const finished = ask === 'wrap'
162  const isInterlude = ask === 'interlude'
163  if (isInterlude) interludes++
164  isWrapPending = false
165  lastAsk = now
166  // after the turn's first scene, scenes play in that world; before it, the first sets one up
167  const world = turnSetting ? stage.world() : undefined
168  const kind: Stamp['kind'] = finished ? 'wrap' : isInterlude ? 'interlude' : world ? 'scene' : 'first'
169  const isTurnOpening = isOpening && kind === 'scene'
170  if (kind !== 'interlude') isOpening = false
171  const activity = isInterlude ? undefined : firstFreshAt
172  const expectedLeftMs = isInterlude ? remainingMs : untilFreeMs
173  const batch = acts
174  const unstagedFresh = fresh
175  acts = []
176  fresh = 0
177  firstFreshAt = undefined
178
179  if (isCanned(kind, isTurnOpening) || now < retryAfter) {
180    const scene = canned(kind, batch, world)
181    if (scene) stageScene(scene, isInterlude)
182    return
183  }
184
185  const interlude = isInterlude
186    ? { n: interludes, quietSecs: Math.round((now - lastActivity) / 1000), isLast: interludes === MAX_INTERLUDES }
187    : undefined
188  const prompt = buildPrompt({
189    goal, log, fresh: unstagedFresh, previous, finished, interlude, world, changesLeft: changesLeft(worldChanges),
190    variety: varietyNotes(recent, Math.random, turnSettings, turnSetting ?? undefined),
191  })
192  const mine = epoch
193  askingIn = mine
194  stage.pondering = true
195  let isStaged = false
196  try {
197    const asked = Date.now()
198    const r = await $.model.complete({ model: settings.model, system: [{ text: SYSTEM, cache: true }], prompt, maxTokens: 1500, effort: 'low', timeoutMs: 45000 })
199    const answeredAt = Date.now()
200    countUsage($, r.usage).catch(() => undefined) // bookkeeping: the scene does not wait on the store
201    if (r.isAnswered) latencyMs = blendLatency(latencyMs, answeredAt - asked)
202    // a new turn began while Sonnet worked: this scene belongs to the old one
203    if (mine !== epoch) return
204    if (!r.isAnswered) return logOnce($, `no scene (${r.reason})`)
205    const parsed = parseScene(r.text, world, { changes: 'director' })
206    if ('error' in parsed) return logOnce($, `unusable scene (${parsed.error})`)
207    // the director owns the world, within the session's limits
208    const { scene, used } = limitChanges(parsed, worldChanges)
209    worldChanges = used
210    stageScene(scene, isInterlude, { kind, activity, asked, answered: answeredAt, expectedLeftMs })
211    isStaged = true
212    failures = 0
213    hasLoggedError = false
214  } catch (err) {
215    logOnce($, `director failed (${err instanceof Error ? err.message : String(err)})`)
216  } finally {
217    if (askingIn === mine) {
218      askingIn = null
219      stage.pondering = false
220    }
221    if (!isStaged && mine === epoch) {
222      // no scene from the model: a canned one plays instead, and the model rests a while
223      failures++
224      retryAfter = Date.now() + Math.min(60_000, 1_000 * 2 ** failures) // 2s, 4s, 8s ... a minute
225      const scene = canned(kind, batch, world)
226      if (scene) stageScene(scene, isInterlude)
227    }
228  }
229}
230
231/**
232 * Whether a scene of this kind is canned under the /config director setting. Hybrid has the model
233 * stage the session's world, each turn's opening scene in it, and each wrap-up.
234 */
235function isCanned(kind: Stamp['kind'], isTurnOpening = false): boolean {
236  if (settings.director === 'off') return true
237  return settings.director === 'hybrid' && ((kind === 'scene' && !isTurnOpening) || kind === 'interlude')
238}
239
240/** A canned scene of `kind`; none for a wrap-up of a turn that never got a world. */
241function canned(kind: Stamp['kind'], batch: readonly Activity[], world: ReturnType<Stage['world']> | undefined): Scene | null {
242  if (kind === 'first') return cannedFirst(goal ?? '', turnSettings, Math.random)
243  if (!world) return null
244  if (kind === 'scene') return cannedScene(batch, world, Math.random)
245  if (kind === 'interlude') return cannedInterlude(world, interludes, Math.random)
246  return cannedWrap(world, lastSaid, false, Math.random)
247}
248
249/** Queues a scene for the turn: the first one fixes the turn's setting. Model scenes carry their timings. */
250function stageScene(scene: Scene, isInterlude: boolean, stamp?: Stamp) {
251  // the world changes between turns, not within one: later scenes keep the first scene's setting
252  if (turnSetting) scene.setting = turnSetting
253  else {
254    // the session's first scene: its world stays for the session
255    turnSetting = scene.setting
256    turnSettings = [...turnSettings, scene.setting].slice(-6)
257    sessionWorld = { setting: scene.setting, props: scene.props.map(p => ({ ...p })), hat: scene.hat ?? 'none' }
258    isWorldShown = true
259  }
260  previous = scene
261  recent = [...recent, scene].slice(-6)
262  if (stamp) {
263    scene.stamp = stamp
264    answered++
265  }
266  scene.isNews = !isInterlude
267  stage.queue(scene)
268}
269
270/** A scene that loads the session's world as it stands, with a few beats to arrive on. */
271function worldScene(world: SessionWorld, beats: Scene['beats']): Scene {
272  return { setting: world.setting, mood: 'neutral', props: world.props.map(p => ({ ...p })), hat: world.hat, beats }
273}
274
275/** Clear, cloudy after a failure, rain after several: the last few minutes' work, in the sky. */
276function weatherNow(now: number): Weather {
277  failTimes = failTimes.filter(t => now - t < WEATHER_MS)
278  return failTimes.length >= 3 ? 'rain' : failTimes.length ? 'cloudy' : 'clear'
279}
280
281const baseName = (path: string) => clip(cellText(path.split(/[\\/]/).pop() ?? path), 20)
282
283/**
284 * One main-thread tool call as the comic sees it: an activity for canned scenes, and its
285 * side effects: git moments, the task trail, failures for the weather.
286 */
287function observe(e: Record<string, unknown>, ran: { isError?: boolean; deny?: string }) {
288  const tool = String(e.tool)
289  const str = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
290  const failed = ran.isError === true || ran.deny !== undefined
291  const fail = (label?: string) => {
292    failTimes.push(Date.now())
293    acts.push({ kind: 'fail', label })
294  }
295  switch (tool) {
296    case 'Read': case 'NotebookRead':
297      return failed ? fail(baseName(str('file_path'))) : acts.push({ kind: 'read', label: baseName(str('file_path')) })
298    case 'Edit': case 'Write': case 'NotebookEdit':
299      return failed ? fail(baseName(str('file_path'))) : acts.push({ kind: 'edit', label: baseName(str('file_path')) })
300    case 'Grep': case 'Glob':
301      return acts.push({ kind: 'search', label: clip(cellText(str('pattern')), 20) })
302    case 'WebFetch': case 'WebSearch':
303      return failed ? fail() : acts.push({ kind: 'web' })
304    case 'Agent':
305      return acts.push({ kind: 'helper' })
306    case 'TodoWrite': {
307      const todos = Array.isArray(e.todos) ? (e.todos as { status?: unknown }[]) : []
308      const done = todos.filter(t => t?.status === 'completed').length
309      stage.progress = todos.length ? { done, total: todos.length } : null
310      return
311    }
312    case 'TaskCreate':
313      if (!failed) tasksMade++
314      return showTasks()
315    case 'TaskUpdate': {
316      const id = str('taskId') || str('id')
317      if (!failed && id && e.status === 'completed') tasksDone.add(id)
318      return showTasks()
319    }
320    case 'Bash': case 'PowerShell': {
321      const command = str('command')
322      if (/\bgit\s+commit\b/.test(command) && !failed) {
323        stage.gitMoment('commit')
324        return acts.push({ kind: 'commit' })
325      }
326      if (/\bgit\s+push\b/.test(command) && !failed) {
327        stage.gitMoment('push')
328        return acts.push({ kind: 'push' })
329      }
330      if (TEST_RUN.test(command)) {
331        if (failed) failTimes.push(Date.now())
332        else failTimes = [] // green again: the sky clears
333        return acts.push({ kind: 'test', ok: !failed })
334      }
335      return failed ? fail() : acts.push({ kind: 'run' })
336    }
337  }
338  if (failed) fail()
339}
340
341/**
342 * A pat: hearts at once, and the headpat sent as the person's message. Sent from a timer, once the
343 * hook that asked has returned: a prompt made inside a command would wait on the turn the command holds.
344 */
345function pat($: EngineInterface) {
346  stage.pet()
347  const text = petLine()
348  pendingPats.push(text)
349  $.clock.after(0, () => $.prompt.submit({ text, asUser: true }).catch(() => undefined))
350}
351
352/** A pat's message, never the same one twice running. */
353function petLine(): string {
354  let i = Math.floor(Math.random() * PET_LINES.length)
355  if (i === lastPetLine) i = (i + 1) % PET_LINES.length
356  lastPetLine = i
357  return PET_LINES[i]!
358}
359
360function showTasks() {
361  if (tasksMade > 0) stage.progress = { done: Math.min(tasksDone.size, tasksMade), total: tasksMade }
362}
363
364/** Asks now rather than at the next tick: a new turn, or its end, should not wait a second. */
365function askSoon($: EngineInterface) {
366  $.clock.after(0, () => direct($).catch(() => undefined))
367}
368
369/** One line per run of failures: the next scene that lands lets the next failure be told. */
370function logOnce($: EngineInterface, why: string) {
371  if (!hasLoggedError) $.ui.log(`agent-comic: ${why}`)
372  hasLoggedError = true
373}
374
375/** Adds a call's tokens to this session's tally and to today's, which is kept across sessions. */
376async function countUsage($: EngineInterface, usage: Parameters<typeof addUsage>[1]) {
377  sessionTally = addUsage(sessionTally, usage)
378  const key = `usage:${today()}`
379  await $.store.set(key, addUsage(asTally(await $.store.get(key)), usage))
380}
381
382function endLinger($: EngineInterface) {
383  void update($, lingering, () => false).then(() => syncState($)).catch(() => undefined)
384}
385
386/** Tells the places what the band is doing; the comic itself never reads it. */
387async function syncState($: EngineInterface) {
388  const isOn = await read($, enabled)
389  const isLingering = await read($, lingering)
390  const state: ComicState = !isOn ? 'hidden'
391    : isTurnRunning || isPetTurn ? 'working'
392    : isLingering ? 'wrapping'
393    : settings.showWhenIdle ? 'idle' : 'hidden'
394  // read as stored, not with the atom's initial: other plugins see only what was written
395  if ((await $.state.get({ plugin: 'agent-comic', key: 'state' })).value !== state) await update($, bandState, () => state)
396}
397
398/** Claude goes to a place on the ring (null: the hub); an id not on it is ignored. */
399async function goTo($: EngineInterface, id: string | null, from: ComicSide | null) {
400  if (id !== null && !(await read($, ring)).some(s => s.id === id)) return
401  if (id !== null) aliveAt.set(id, await $.clock.now())
402  // the side first, so the place reads it as it takes over
403  await update($, cameFrom, () => from)
404  await update($, here, () => id)
405}
406
407/** Follows the sign on side `dir`, if there is one. */
408async function stepTo($: EngineInterface, dir: ComicSide) {
409  const route = routeOf(await read($, ring), await read($, here))
410  const to = dir === -1 ? route.left : route.right
411  if (to) await goTo($, to.id, enteringFrom(dir))
412}
413
414/** A place that stopped drawing (it failed, or was turned off) sends Claude home to the hub. */
415async function checkPlace($: EngineInterface) {
416  const id = await read($, here)
417  if (id === null) return
418  if (!(await read($, ring)).some(s => s.id === id)) return goTo($, null, null)
419  const now = await $.clock.now()
420  const last = aliveAt.get(id)
421  // it draws only while the band is idle: until then, and on the way in, it has its grace
422  if ((await read($, bandState)) !== 'idle' || last === undefined) return void aliveAt.set(id, now)
423  if (now - last > ALIVE_MS) await goTo($, null, null)
424}
425
426/** Keeps the band up for `ms` even with no turn running, and holds the director off. */
427async function showFor($: EngineInterface, ms: number) {
428  directorPausedUntil = Date.now() + ms
429  await update($, lingering, () => true)
430  await syncState($)
431  linger?.cancel()
432  linger = $.clock.after(ms, () => endLinger($))
433}
434
435/** Which mark each tool puts over Claude's head as it starts. */
436const CUES: Record<string, Parameters<Stage['cue']>[0]> = {
437  Read: 'read', NotebookRead: 'read',
438  Grep: 'search', Glob: 'search',
439  Edit: 'edit', Write: 'edit', NotebookEdit: 'edit',
440  Bash: 'run', PowerShell: 'run',
441  WebFetch: 'web', WebSearch: 'web',
442}
443
444const FEELINGS = MOODS.filter((m): m is Exclude<Mood, 'neutral'> => m !== 'neutral')
445
446export const register: Register = (on, options) => {
447  settings = {
448    model: options.model === 'haiku' ? 'haiku' : 'sonnet',
449    pace: options.pace === 'normal' || options.pace === 'lively' ? options.pace : 'calm',
450    showWhenIdle: options.showWhenIdle !== false,
451    director: options.director === 'full' || options.director === 'off' ? options.director : 'hybrid',
452  }
453  stage.pace = PACES[settings.pace]
454  latencyMs = settings.model === 'haiku' ? 4000 : 7000
455  stage.onPlay = scene => {
456    if (scene.stamp) timings = addTiming(timings, scene.stamp, Date.now())
457  }
458  // what plays is what the session keeps: a transformed prop, a new hat, new scenery
459  stage.onWorldChange = shown => {
460    if (!isWorldShown || !sessionWorld) return
461    const { world, moved } = followWorld(sessionWorld, shown)
462    sessionWorld = world
463    if (moved) {
464      turnSetting = world.setting
465      turnSettings = [...turnSettings, world.setting].slice(-6)
466      epoch++ // what was being staged for the old place is dropped
467    }
468  }
469
470  // $.comic: other plugins add places to the comic's world (EXTENDING.md). These are its plain
471  // answers; the hooks below do the work, since only a hook has $
472  on('engine.create', async ($, e, next) => {
473    const built = await next(e)
474    const comic: Comic = {
475      version: async () => COMIC_VERSION,
476      addPlace: async () => undefined,
477      removePlace: async () => undefined,
478      places: async () => [HUB],
479      route: async () => ({ left: null, right: null }),
480      go: async () => undefined,
481      step: async () => undefined,
482      alive: async () => undefined,
483      kit: async () => kit(),
484    }
485    return { ...built, comic }
486  })
487
488  on('comic.addPlace', async ($, e, next) => {
489    const place = asPlace(e)
490    if (place) await update($, ring, r => withPlace(r, place))
491    return next(e)
492  })
493
494  on('comic.removePlace', async ($, e, next) => {
495    if (typeof e.id === 'string') {
496      const id = e.id
497      await update($, ring, r => withoutPlace(r, id))
498      aliveAt.delete(id)
499      if ((await read($, here)) === id) await goTo($, null, null)
500    }
501    return next(e)
502  })
503
504  on('comic.places', async $ => ({ value: await read($, ring) }))
505
506  on('comic.route', async $ => ({ value: routeOf(await read($, ring), await read($, here)) }))
507
508  on('comic.go', async ($, e, next) => {
509    if (e.id === null || typeof e.id === 'string') await goTo($, e.id, null)
510    return next(e)
511  })
512
513  on('comic.step', async ($, e, next) => {
514    if (e.dir === -1 || e.dir === 1) await stepTo($, e.dir)
515    return next(e)
516  })
517
518  on('comic.alive', async ($, e, next) => {
519    const place = asPlace(e)
520    if (place) {
521      // the comic reloaded and lost its ring: the place is added back as it says it is there
522      if (!(await read($, ring)).some(s => s.id === place.id)) await update($, ring, r => withPlace(r, place))
523      aliveAt.set(place.id, await $.clock.now())
524    }
525    return next(e)
526  })
527
528  // a sign at the hub's edge: Claude goes to the place beside it
529  on('ui.press', { element: SIGN_LEFT }, async $ => {
530    await stepTo($, -1)
531    return { element: SIGN_LEFT }
532  })
533
534  on('ui.press', { element: SIGN_RIGHT }, async $ => {
535    await stepTo($, 1)
536    return { element: SIGN_RIGHT }
537  })
538
539  on('session.start', async ($, e, next) => {
540    const stored = await $.store.get('enabled')
541    if (typeof stored === 'boolean') await update($, enabled, () => stored)
542    await syncState($)
543
544    await $.command.register({ name: 'comic', description: 'Turn the agent comic above the prompt on or off' })
545    await $.command.register({ name: 'comic-demo', description: 'Play the comic tour: every mood, then every action, captioned' })
546    await $.command.register({ name: 'comic-stats', description: 'Show the tokens the comic director has spent: this session and today' })
547    await $.command.register({ name: 'comic-feel', description: `Make Claude act out a mood: ${FEELINGS.join(', ')}` })
548
549    await $.command.register({ name: 'comic-pet', description: 'Give the little Claude above the prompt a pat' })
550    await $.command.register({ name: 'comic-hub', description: 'List the keepsakes in the comic hub, or remove one: /comic-hub remove <n>' })
551    // the hub: the session opens there, among the keepsakes of earlier sessions
552    hubItems = asHubItems(await $.store.get(HUB_STORE_KEY))
553    stage.setHome(hubItems)
554    stage.startHome()
555    await $.tool.register(HUB_TOOL_SPEC)
556
557    $.clock.every(FRAME_MS, () => drawFrame($))
558    $.clock.every(1000, () => direct($).catch(() => undefined))
559    $.clock.every(1000, () => checkPlace($).catch(() => undefined))
560
561    return next(e)
562  })
563
564  on('command.run', { command: 'comic' }, async $ => {
565    const isOn = !(await read($, enabled))
566    await update($, enabled, () => isOn)
567    await $.store.set('enabled', isOn)
568    await syncState($)
569    return { text: isOn ? 'Comic on: it plays above the prompt while Claude works.' : 'Comic off.' }
570  })
571
572  on('command.run', { command: 'comic-demo' }, async $ => {
573    stage.queue(demoScene(), true)
574    isWorldShown = false // the tour plays in a world of its own; the next turn goes back to the session's
575    await showFor($, DEMO_MS)
576    return { text: `Comic tour playing above the prompt (about ${Math.round(DEMO_MS / 1000)}s): ${FEELINGS.length} moods, then the actions.` }
577  })
578
579  on('command.run', { command: 'comic-stats' }, async $ => {
580    const key = today()
581    const day = asTally(await $.store.get(`usage:${key}`))
582    return { text: `${formatStats(settings.model, sessionTally, day, key)}
583
584${formatTimings(timings, latencyMs, answered)}` }
585  })
586
587  on('command.run', { command: 'comic-hub' }, async ($, e) => {
588    const remove = /^remove\s+(\d+)$/.exec(e.args.trim())
589    if (remove) {
590      const n = Number(remove[1])
591      if (n < 1 || n > hubItems.length) return { text: `No keepsake ${n}: the hub has ${hubItems.length}.` }
592      const [gone] = hubItems.splice(n - 1, 1)
593      hubItems = [...hubItems]
594      await $.store.set(HUB_STORE_KEY, hubItems)
595      stage.setHome(hubItems)
596      return { text: `Removed the ${gone!.kind} "${gone!.label}".` }
597    }
598    if (!hubItems.length) return { text: 'The hub has no keepsakes yet. Claude adds one after a real milestone.' }
599    const lines = hubItems.map((h, i) => `${i + 1}. ${h.kind}: ${h.label} (${new Date(h.at).toISOString().slice(0, 10)})`)
600    return { text: `Keepsakes in the hub, oldest first:\n${lines.join('\n')}\n\nRemove one with /comic-hub remove <n>.` }
601  })
602
603  // the comic's own tool: Claude keeps a milestone in the hub
604  on('tool.call', { tool: HUB_TOOL_NAME }, async ($, e) => {
605    if (hubAdds >= MAX_HUB_ADDS) return { result: 'Not added: the hub already got its keepsake this session. Keep the next one for another milestone.' }
606    const item = hubItemFrom(e, Date.now())
607    if ('error' in item) return { result: `Not added: ${item.error}.` }
608    hubAdds++
609    const { items, retired } = addHubItem(hubItems, item)
610    hubItems = items
611    await $.store.set(HUB_STORE_KEY, hubItems)
612    stage.setHome(hubItems.slice(0, -1))
613    stage.addHomeItem(item)
614    const room = retired ? ` The hub holds ${MAX_HUB_ITEMS}, so the oldest, the ${retired.kind} "${retired.label}", was retired.` : ''
615    return { result: `Added a ${item.kind} "${item.label}" to the hub; it is there whenever Claude Code starts.${room}` }
616  })
617
618  // a /clear is a new conversation: its first turn sets up a new world
619  on('session.end', async ($, e, next) => {
620    if (e.reason === 'clear') {
621      sessionWorld = null
622      turnSetting = null
623      worldChanges = noChanges()
624    }
625    return next(e)
626  })
627
628  // a pat: the ♥ at the band's edge, or /comic-pet
629  on('ui.press', { element: PET_KEY }, async $ => {
630    pat($)
631    return { element: PET_KEY }
632  })
633
634  on('command.run', { command: 'comic-pet' }, async $ => {
635    pat($)
636    return { text: 'A headpat, on its way.' }
637  })
638
639  on('command.run', { command: 'comic-feel' }, async ($, e) => {
640    const mood = FEELINGS.find(m => m === e.args.trim().toLowerCase())
641    if (!mood) return { text: `Usage: /comic-feel <mood>, one of: ${FEELINGS.join(', ')}` }
642    stage.interject([feelBeat(mood)])
643    await showFor($, 9000)
644    return { text: `Claude is ${mood}.` }
645  })
646
647  on('prompt.submit', async ($, e, next) => {
648    // our own headpat (should a hook of ours see it): its turn is told apart as it starts, below
649    const origin = e.origin as { kind?: string; name?: string } | undefined
650    if (origin?.kind === 'plugin' && origin.name === 'agent-comic') return next(e)
651    linger?.cancel()
652    // typed over the running turn, or delivered into it: the same story goes on
653    if (e.turnId !== undefined) {
654      note(`the person added: ${e.text.slice(0, 300)}`)
655      return next(e)
656    }
657    epoch++
658    retryAfter = 0
659    goal = e.text.slice(0, 300)
660    log = []
661    acts = []
662    lastSaid = undefined
663    stage.attention = null
664    // a finished task list is done with; one still under way carries on into this turn
665    if (stage.progress && stage.progress.done >= stage.progress.total) {
666      stage.progress = null
667      tasksMade = 0
668      tasksDone.clear()
669    }
670    fresh = 0
671    lastAsk = 0
672    isWrapPending = false
673    isTurnRunning = true
674    // the session's world carries on: only its first turn sets one up
675    turnSetting = sessionWorld?.setting ?? null
676    isOpening = sessionWorld !== null
677    note(`the person asked: ${goal}`)
678    // something to see at once: at home he heads for the door, elsewhere (idle, or asleep) he stops to think
679    const isLeaving = stage.leaveHome()
680    // back from home (or the demo): the session's world loads as he steps in, as he left it
681    if (sessionWorld && !isWorldShown) {
682      stage.queue(worldScene(sessionWorld, [{ do: 'ponder', secs: 1.5 }]))
683      isWorldShown = true
684    } else if (!isLeaving && stage.idleMs > 0) stage.interject([{ do: 'ponder', secs: 2 }])
685    askSoon($)
686    await syncState($)
687    return next(e)
688  })
689
690  on('tool.call', async ($, e, next) => {
691    // a subagent's own tool calls: its small Claude acts them out
692    if (e.agentId) stage.helperActivity(e.agentId, e.tool)
693    // a subagent started from the main thread: a small Claude walks in, and reports back when it is done
694    const input = e as unknown as { description?: unknown; run_in_background?: unknown }
695    const isHelper = e.tool === 'Agent' && !e.agentId
696    if (isHelper) {
697      const label = typeof input.description === 'string' ? input.description : 'helper'
698      stage.helperStart(e.tool_use_id, label, input.run_in_background === true)
699    }
700    // the main thread's own calls: a mark over Claude's head at once, while Sonnet catches up
701    const isMain = !e.agentId && !isHelper
702    if (isMain) {
703      const cue = CUES[e.tool]
704      if (cue) stage.cue(cue)
705      running.set(e.tool_use_id, { what: describeCall(e as unknown as Record<string, unknown>, {}), since: Date.now(), isTold: false })
706    }
707    // a question for the person: he turns to them until it is answered
708    const isAsking = isMain && ASKS_PERSON.has(e.tool)
709    if (isAsking) stage.attention = 'question'
710    const startedAt = Date.now()
711    let ran: Awaited<ReturnType<typeof next>>
712    try {
713      ran = await next(e)
714    } finally {
715      running.delete(e.tool_use_id)
716      // answered (a question, or a permission prompt on the way): back to the story
717      if (isMain) stage.attention = null
718    }
719    if (isMain && (ran.isError || ran.deny)) stage.cue('fail')
720    if (isMain) observe(e as unknown as Record<string, unknown>, ran)
721    if (isHelper) {
722      // back within a few seconds: it was only launched and works on in the background
723      if (input.run_in_background === true || Date.now() - startedAt < 5000) stage.helperBackground(e.tool_use_id)
724      else stage.helperDone(e.tool_use_id)
725    }
726    note(describeCall(e as unknown as Record<string, unknown>, ran))
727    return ran
728  })
729
730  // a permission dialog about to be put to the person (no hook answered it, and in auto mode
731  // the classifier did not settle it): he turns to them while it is up
732  on('classic.PermissionRequest', async ($, e, next) => {
733    const answer = await next(e)
734    const isAnswered = (answer as { decision?: unknown } | undefined)?.decision !== undefined
735    if (!isAnswered && !(e as { agent_id?: string }).agent_id) stage.attention = 'permission'
736    return answer
737  })
738
739  // the tool is running (its progress row is up): the dialog was answered
740  on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
741    if (stage.attention === 'permission') stage.attention = null
742    return next(e)
743  })
744
745  // a subagent finished, foreground or background: its small Claude walks over and reports
746  on('classic.SubagentStop', async ($, e, next) => {
747    stage.helperDoneByAgent(e.agent_id)
748    return next(e)
749  })
750
751  on('session.append', async ($, e, next) => {
752    const stored = await next(e)
753    if (e.door === 'response' && !e.agentId && !isPetTurn) {
754      const text = e.message.content
755        .map(b => (b.type === 'text' ? b.text : ''))
756        .join(' ')
757        .replace(/\s+/g, ' ')
758        .trim()
759      if (text) {
760        note(`the agent said: ${text.slice(0, 220)}`)
761        lastSaid = clip(cellText(text), 120)
762        acts.push({ kind: 'said', text: clip(cellText(text), 60) })
763      }
764    }
765    return stored
766  })
767
768  // the turn answering a pat: he stays where he is, glowing, while Claude answers; no world, no scenes
769  on('turn.start', async ($, e, next) => {
770    const i = pendingPats.indexOf(e.text)
771    if (i >= 0) {
772      pendingPats.splice(i, 1)
773      isPetTurn = true
774      await syncState($)
775    }
776    return next(e)
777  })
778
779  on('turn.complete', async ($, e, next) => {
780    const done = await next(e)
781    // a subagent's turn ends inside the main one: its small Claude reports through SubagentStop
782    if (e.agentId) return done
783    if (isPetTurn) {
784      isPetTurn = false
785      await syncState($)
786      return done
787    }
788    isTurnRunning = false
789    stage.attention = null
790    if (e.reason === 'aborted') {
791      // stopped by the person: no celebration, and what was asked for the stopped work is dropped;
792      // a shrug in the turn's world says so, no model needed
793      epoch++
794      fresh = 0
795      firstFreshAt = undefined
796      isWrapPending = false
797      acts = []
798      if (turnSetting) stage.queue(cannedWrap(stage.world(), undefined, true, Math.random))
799    } else {
800      isWrapPending = true
801      askSoon($)
802    }
803    await update($, lingering, () => true)
804    await syncState($)
805    linger?.cancel()
806    linger = $.clock.after(LINGER_MS, () => endLinger($))
807    return done
808  })
809
810  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
811    const isOn = await read($, enabled)
812    const rows = Math.min(MAX_ROWS, e.props.maxRows - 1)
813    // always up while on: Claude idles between turns (no Sonnet calls then) and dozes off after a while
814    const isLingering = settings.showWhenIdle || (await read($, lingering))
815    const isShown = isOn && !e.props.hasSurvey && rows >= MIN_ROWS && (e.props.isWorking || isLingering)
816    // between turns, with places on the ring: one of them may have the band, or the hub shows signs to them
817    const stops = await read($, ring)
818    const isBetween = stops.length > 1 && !e.props.isWorking && !(await read($, lingering))
819    const at = isBetween ? await read($, here) : null
820    const isAway = at !== null && stops.some(s => s.id === at)
821
822    if (!isShown || isAway || e.surface !== 'terminal') {
823      mount = null
824      stage.routeSigns = null
825      return next(e)
826    }
827    const route = isBetween ? routeOf(stops, null) : null
828    stage.routeSigns = route ? { left: route.left?.name ?? null, right: route.right?.name ?? null } : null
829
830    // the stage, and a ♥ beside it to give him a pat (a Raster takes no clicks of its own). A click
831    // reaches it in the fullscreen terminal; anywhere, ctrl+x tab focuses the band and p presses it
832    const columns = Math.max(20, Math.min(512, e.props.bodyColumns - 5))
833    mount = { requestId: e.requestId, columns, rows }
834    const { Box, Button, Raster } = $.ui.resolve(e)
835    return (
836      <Box flexDirection="row">
837        <Raster key={KEY} columns={columns} rows={rows} cells={stage.frame(columns, rows)} />
838        <Box flexDirection="column" paddingLeft={1}>
839          <Button key={PET_KEY} plain hotkey="p" onPress={() => undefined}>♥</Button>
840          {route?.left ? <Button key={SIGN_LEFT} plain hotkey="a" onPress={() => undefined}>◂</Button> : null}
841          {route?.right ? <Button key={SIGN_RIGHT} plain hotkey="d" onPress={() => undefined}>▸</Button> : null}
842        </Box>
843      </Box>
844    )
845  })
846}
847
hooks/canned.ts 353 lines
1// A token-free scene library: canned scenes in the same scene language the director writes,
2// so the comic works with few or no model calls. Every scene goes through `parseScene`.
3
4import { clip, parseScene, SETTINGS } from './scene'
5import type { Hat, Prop, PropKind, Scene, Setting } from './scene'
6
7/** One thing the agent did, as the comic needs it. Labels are already short, clean, single-line text (<= 20 chars), e.g. a file's base name. */
8export type Activity =
9  | { kind: 'read' | 'search' | 'edit' | 'run' | 'web'; label?: string }
10  | { kind: 'test'; ok: boolean }
11  | { kind: 'fail'; label?: string }
12  | { kind: 'helper' }
13  | { kind: 'commit' }
14  | { kind: 'push' }
15  | { kind: 'said'; text: string } // a remark the agent made to the person, already clipped
16
17export type World = { setting: Setting; props: readonly Prop[] }
18
19type Rand = () => number
20type Raw = Record<string, unknown>
21
22const pick = <T,>(list: readonly T[], rand: Rand): T => list[Math.min(list.length - 1, Math.floor(rand() * list.length))]!
23const chance = (p: number, rand: Rand) => rand() < p
24const between = (lo: number, hi: number, rand: Rand) => Math.round((lo + rand() * (hi - lo)) * 2) / 2
25const shuffle = <T,>(list: readonly T[], rand: Rand): T[] => {
26  const a = [...list]
27  for (let i = a.length - 1; i > 0; i--) {
28    const j = Math.min(i, Math.floor(rand() * (i + 1)))
29    ;[a[i], a[j]] = [a[j]!, a[i]!]
30  }
31  return a
32}
33
34// ---- worlds ----
35
36/** Props that belong in each place; one "work" prop is always included. */
37const PLACES: Record<Setting, { work: PropKind[]; rest: PropKind[]; hats: Hat[] }> = {
38  meadow: { work: ['computer', 'scroll'], rest: ['tree', 'rock', 'mushroom', 'flag', 'crate', 'bug', 'sign'], hats: ['straw', 'tophat'] },
39  forest: { work: ['scroll', 'computer'], rest: ['tree', 'mushroom', 'rock', 'bug', 'sign', 'crate'], hats: ['straw', 'wizard'] },
40  cave: { work: ['computer', 'scroll'], rest: ['rock', 'lamp', 'chest', 'mushroom', 'crate', 'bug'], hats: ['miner', 'miner'] },
41  night: { work: ['scroll', 'computer'], rest: ['lamp', 'crate', 'flag', 'tree', 'mushroom'], hats: ['nightcap', 'wizard'] },
42  desert: { work: ['computer', 'scroll'], rest: ['cactus', 'chest', 'rock', 'sign', 'crate', 'bug'], hats: ['straw', 'miner'] },
43  library: { work: ['books', 'computer'], rest: ['lamp', 'scroll', 'crate', 'chest', 'bug'], hats: ['wizard', 'tophat'] },
44  space: { work: ['computer', 'scroll'], rest: ['flag', 'rock', 'crate', 'lamp', 'sign', 'bug'], hats: ['wizard', 'tophat'] },
45  beach: { work: ['scroll', 'computer'], rest: ['chest', 'crate', 'flag', 'rock', 'sign', 'bug'], hats: ['straw', 'straw'] },
46}
47
48const SIGN_LABELS = ['TODO', 'notes', 'here', 'hmm']
49
50const KEYWORDS: [RegExp, Setting[]][] = [
51  [/\b(docs?|documentation|readme|changelog|comments?|explain|guide)\b/i, ['library']],
52  [/\b(bugs?|debug|crash(es)?|errors?|fix|broken|fail(s|ing|ed)?|exceptions?|regression)\b/i, ['cave', 'forest']],
53  [/\b(deploy|release|publish|ship|ci|pipeline|version|bump)\b/i, ['space']],
54  [/\b(data|search|find|query|grep|index|database|csv|logs?)\b/i, ['desert', 'beach']],
55  [/\b(tests?|spec|coverage)\b/i, ['night', 'forest']],
56  [/\b(refactor|clean|rename|move|tidy|lint)\b/i, ['meadow', 'night']],
57  [/\b(web|api|http|fetch|network|server)\b/i, ['beach', 'space']],
58]
59
60/** Four props that belong in `setting`, spread across the ground: one to work at, three around it. */
61export function cannedProps(setting: Setting, rand: Rand): Prop[] {
62  const place = PLACES[setting]
63  const kinds: PropKind[] = [pick(place.work, rand), ...shuffle(place.rest, rand).slice(0, 3)]
64  const slots = shuffle([24, 44, 64, 84], rand)
65  return shuffle(kinds, rand).map((kind, i) => {
66    const p: Prop = { id: `${kind}${i}`, kind, x: slots[i]! + Math.round((rand() - 0.5) * 8) }
67    if (kind === 'sign') p.label = pick(SIGN_LABELS, rand)
68    return p
69  })
70}
71
72/** The session's opening scene, setting up its world without a model. */
73export function cannedFirst(goal: string, recent: readonly Setting[], rand: Rand): Scene {
74  const last = recent[recent.length - 1]
75  const lately = new Set(recent.slice(-3))
76  const keyed = KEYWORDS.filter(([re]) => re.test(goal)).flatMap(([, s]) => s)
77  const tiers = [
78    keyed.filter(s => !lately.has(s)),
79    SETTINGS.filter(s => !lately.has(s)),
80    keyed.filter(s => s !== last),
81    SETTINGS.filter(s => s !== last),
82    [...SETTINGS],
83  ]
84  // a keyword hit is a lean, not a rule: now and then any fresh setting wins
85  const tier = tiers[0]!.length && chance(0.8, rand) ? tiers[0]! : tiers.slice(1).find(t => t.length)!
86  const setting = pick(tier, rand)
87
88  const place = PLACES[setting]
89  const props: Raw[] = cannedProps(setting, rand)
90  const hat: Hat = chance(0.4, rand) ? pick(place.hats, rand) : 'none'
91
92  const ids = props.map(p => p.id as string)
93  const target = pick(ids, rand)
94  const others = ids.filter(i => i !== target)
95  const other = pick(others, rand)
96  const topic = clip(goal.replace(/\s+/g, ' ').trim(), 60)
97  const beats: Raw[] = [{ do: 'walk', to: between(8, 18, rand) }]
98  beats.push({ do: 'look', at: target, react: chance(0.5, rand) ? '?' : undefined })
99  if (chance(0.5, rand)) beats.push({ do: 'look', at: other })
100  beats.push(topic && chance(0.6, rand) ? { do: 'think', text: topic, secs: 3 } : { do: 'ponder', secs: between(2, 3.5, rand) })
101  return build({ setting, mood: pick(['neutral', 'focused', 'happy'] as const, rand), hat, props, beats }, undefined)
102}
103
104// ---- scenes in a world ----
105
106type Ctx = { world: World; rand: Rand; ids: Set<string>; signs: number }
107type Chunk = { beats: Raw[]; mood: string }
108
109const of = (c: Ctx, ...kinds: PropKind[]): Prop | undefined => {
110  const found = c.world.props.filter(p => kinds.includes(p.kind))
111  return found.length ? pick(found, c.rand) : undefined
112}
113const spot = (c: Ctx) => between(12, 92, c.rand)
114/** The widest gap between props, for a new sign. */
115const freeX = (c: Ctx) => {
116  const xs = [0, ...c.world.props.map(p => p.x), 100].sort((a, b) => a - b)
117  let best = 50
118  let gap = -1
119  for (let i = 1; i < xs.length; i++) {
120    if (xs[i]! - xs[i - 1]! > gap) {
121      gap = xs[i]! - xs[i - 1]!
122      best = (xs[i]! + xs[i - 1]!) / 2
123    }
124  }
125  return Math.max(15, Math.min(90, Math.round(best)))
126}
127const lookAround = (c: Ctx): Raw[] => [{ do: 'walk', to: spot(c) }, { do: 'ponder', secs: between(1.5, 3, c.rand) }]
128
129function readChunk(c: Ctx): Chunk {
130  const src = of(c, 'books', 'scroll')
131  if (src) return { mood: 'focused', beats: [{ do: 'look', at: src.id, react: chance(0.3, c.rand) ? '?' : undefined }, { do: 'read', secs: between(2, 3.5, c.rand) }] }
132  return { mood: 'focused', beats: [{ do: 'read', secs: between(2, 3.5, c.rand) }, { do: 'ponder', secs: 2 }] }
133}
134
135function searchChunk(c: Ctx): Chunk {
136  const rock = of(c, 'rock', 'mushroom', 'chest', 'cactus')
137  if (rock) {
138    const find = chance(0.4, c.rand)
139    return {
140      mood: find ? 'surprised' : 'focused',
141      beats: [{ do: 'look', at: rock.id }, { do: 'dig', secs: between(2, 3.5, c.rand) }, find ? { do: 'emote', mood: 'surprised', secs: 1.5 } : { do: 'ponder', secs: 2 }],
142    }
143  }
144  const lamp = of(c, 'lamp')
145  if (lamp) return { mood: 'focused', beats: [{ do: 'look', at: lamp.id }, { do: 'walk', to: spot(c) }, { do: 'dig', secs: 2.5 }] }
146  return { mood: 'focused', beats: [{ do: 'walk', to: spot(c) }, { do: 'dig', secs: between(2, 3.5, c.rand) }, { do: 'ponder', secs: 2 }] }
147}
148
149function editChunk(c: Ctx): Chunk {
150  const crate = of(c, 'crate', 'scroll')
151  const pc = of(c, 'computer')
152  if (crate && (!pc || chance(0.5, c.rand))) {
153    return { mood: 'focused', beats: [{ do: 'carry', at: crate.id }, { do: 'walk', to: spot(c) }, { do: 'drop' }, { do: 'ponder', secs: 1.5 }] }
154  }
155  if (pc) {
156    return { mood: 'focused', beats: [{ do: 'type', at: pc.id, secs: between(2.5, 4, c.rand) }, chance(0.5, c.rand) ? { do: 'emote', mood: 'happy', secs: 1.5 } : { do: 'wait', secs: 1 }] }
157  }
158  return { mood: 'focused', beats: [{ do: 'type', secs: between(2.5, 4, c.rand) }, { do: 'ponder', secs: 2 }] }
159}
160
161function runChunk(c: Ctx): Chunk {
162  const pc = of(c, 'computer')
163  if (pc) return { mood: 'focused', beats: [{ do: 'type', at: pc.id, secs: between(2, 3, c.rand) }, { do: 'wait', secs: between(1, 2, c.rand) }, { do: 'look', at: pc.id }] }
164  return { mood: 'focused', beats: [{ do: 'type', secs: 3 }, { do: 'wait', secs: 1.5 }] }
165}
166
167function webChunk(c: Ctx): Chunk {
168  const far = of(c, 'scroll', 'lamp', 'flag')
169  if (far) return { mood: 'surprised', beats: [{ do: 'look', at: far.id, react: '!' }, { do: 'read', secs: 2.5 }, { do: 'ponder', secs: 1.5 }] }
170  return { mood: 'surprised', beats: [{ do: 'run', to: spot(c) }, { do: 'emote', mood: 'surprised', secs: 1.5 }, { do: 'read', secs: 2.5 }] }
171}
172
173function testChunk(c: Ctx, ok: boolean, afterFail: boolean): Chunk {
174  const pc = of(c, 'computer')
175  const bug = of(c, 'bug')
176  const typed: Raw = pc ? { do: 'type', at: pc.id, secs: 2 } : { do: 'type', secs: 2 }
177  const beats: Raw[] = [typed, { do: 'wait', secs: 1 }]
178  if (ok) {
179    // the bug stays: only Claude's own tool changes the world, so he just looks it over, pleased
180    if (bug) beats.push({ do: 'look', at: bug.id, react: '!' })
181    beats.push(afterFail || chance(0.6, c.rand) ? { do: 'celebrate' } : { do: 'dance', secs: 2 })
182    return { mood: 'happy', beats }
183  }
184  if (bug && chance(0.5, c.rand)) beats.push({ do: 'look', at: bug.id, react: '!' })
185  beats.push(chance(0.5, c.rand) ? { do: 'emote', mood: 'worried', secs: 2 } : { do: 'shrug' })
186  return { mood: 'worried', beats }
187}
188
189function failChunk(c: Ctx): Chunk {
190  const thing = of(c, 'bug', 'rock', 'crate')
191  const beats: Raw[] = []
192  if (thing) beats.push({ do: 'look', at: thing.id, react: '!' })
193  beats.push({ do: 'emote', mood: pick(['worried', 'surprised', 'confused'] as const, c.rand), secs: 2 })
194  beats.push(chance(0.5, c.rand) ? { do: 'shrug' } : { do: 'ponder', secs: 2 })
195  return { mood: 'worried', beats }
196}
197
198function commitChunk(c: Ctx): Chunk {
199  let id = 'saved'
200  for (let n = 2; c.ids.has(id); n++) id = `saved${n}`
201  c.ids.add(id)
202  c.signs++
203  return {
204    mood: 'proud',
205    beats: [
206      { do: 'plant_sign', id, label: pick(['commit', 'saved', 'committed'], c.rand), x: freeX(c) },
207      { do: 'look', at: id },
208      chance(0.5, c.rand) ? { do: 'emote', mood: 'proud', secs: 2 } : { do: 'jump' },
209    ],
210  }
211}
212
213const pushChunk = (c: Ctx): Chunk => ({
214  mood: 'happy',
215  beats: chance(0.5, c.rand)
216    ? [{ do: 'jump' }, { do: 'wait', secs: 0.5 }, { do: 'celebrate' }]
217    : [{ do: 'emote', mood: 'surprised', secs: 1.5 }, { do: 'jump' }, { do: 'celebrate' }],
218})
219
220const helperChunk = (c: Ctx): Chunk => ({
221  mood: 'happy',
222  beats: [{ do: 'walk', to: between(10, 35, c.rand) }, { do: 'wave' }, { do: 'emote', mood: 'happy', secs: 1.5 }],
223})
224
225/** How soon an activity should be staged: failures first, plain reading last. */
226const salience = (a: Activity): number => {
227  switch (a.kind) {
228    case 'fail': return 0
229    case 'test': return a.ok ? 2 : 1
230    case 'push': return 3
231    case 'commit': return 4
232    case 'helper': return 5
233    case 'edit': return 6
234    case 'run': return 7
235    case 'web': return 8
236    case 'search': return 9
237    case 'read': return 10
238    case 'said': return 11
239  }
240}
241
242function chunkFor(a: Activity, c: Ctx, hadFail: boolean): Chunk | null {
243  switch (a.kind) {
244    case 'read': return readChunk(c)
245    case 'search': return searchChunk(c)
246    case 'edit': return editChunk(c)
247    case 'run': return runChunk(c)
248    case 'web': return webChunk(c)
249    case 'test': return testChunk(c, a.ok, hadFail)
250    case 'fail': return failChunk(c)
251    case 'helper': return helperChunk(c)
252    case 'commit': return c.signs < 1 ? commitChunk(c) : null
253    case 'push': return pushChunk(c)
254    case 'said': return null
255  }
256}
257
258const MAX_BEATS = 5
259
260/** A short scene in the current world acting out the freshest activities. */
261export function cannedScene(acts: readonly Activity[], world: World, rand: Rand): Scene {
262  const c: Ctx = { world, rand, ids: new Set(world.props.map(p => p.id)), signs: 0 }
263  const hadFail = acts.some(a => a.kind === 'fail' || (a.kind === 'test' && !a.ok))
264  const said = acts.filter((a): a is Extract<Activity, { kind: 'said' }> => a.kind === 'said').at(-1)
265  const ranked = acts.filter(a => a.kind !== 'said').map((a, i) => ({ a, i })).sort((x, y) => salience(x.a) - salience(y.a) || x.i - y.i)
266
267  const beats: Raw[] = []
268  let mood = ''
269  let kinds = 0
270  const seen = new Set<string>()
271  for (const { a } of ranked) {
272    const key = a.kind === 'test' ? `test${a.ok}` : a.kind
273    if (seen.has(key)) continue
274    const ch = chunkFor(a, c, hadFail)
275    if (!ch) continue
276    // a second idea only when the first leaves room; each chunk plays whole (carry always meets its drop)
277    if (kinds > 0 && (beats.length >= 3 || beats.length + ch.beats.length > MAX_BEATS - (said ? 1 : 0))) continue
278    seen.add(key)
279    beats.push(...ch.beats)
280    mood ||= ch.mood
281    if (++kinds >= 2) break
282  }
283  if (!beats.length) beats.push(...lookAround(c))
284  if (said && beats.length < MAX_BEATS) {
285    const text = clip(said.text.replace(/\s+/g, ' ').trim(), 60)
286    if (text) beats.push({ do: 'say', text, secs: 4 })
287  }
288  return build({ mood: mood || 'neutral', beats: beats.slice(0, MAX_BEATS) }, world)
289}
290
291/** A quiet-moment interlude in the current world (n = 1..; the 4th is the last): small idle business, later ones sleepier. */
292export function cannedInterlude(world: World, n: number, rand: Rand): Scene {
293  const c: Ctx = { world, rand, ids: new Set(world.props.map(p => p.id)), signs: 0 }
294  const prop = of(c, 'tree', 'lamp', 'flag', 'mushroom', 'rock', 'cactus', 'chest', 'crate', 'sign', 'bug', 'scroll', 'books', 'computer')
295  const lookAt: Raw[] = prop ? [{ do: 'look', at: prop.id, react: chance(0.3, rand) ? '?' : undefined }] : []
296  let beats: Raw[]
297  let mood = 'neutral'
298  if (n >= 4) {
299    mood = 'sleepy'
300    beats = [
301      { do: 'think', text: pick(['Still working on it... *yawn*', 'So quiet... just resting my eyes.', 'Waiting is sleepy work.'], rand), secs: 3 },
302      { do: 'emote', mood: 'sleepy', secs: 4 },
303    ]
304  } else if (n === 3) {
305    mood = 'sleepy'
306    beats = [...lookAt, { do: 'ponder', secs: 3 }, { do: 'think', text: pick(['Still going... I can wait.', 'Any moment now.', 'Hmm, patience.'], rand), secs: 3 }, { do: 'emote', mood: 'sleepy', secs: 2 }]
307  } else if (n === 2) {
308    const src = of(c, 'books', 'scroll')
309    beats = src && chance(0.5, rand)
310      ? [{ do: 'look', at: src.id }, { do: 'read', secs: 3 }, { do: 'ponder', secs: 2.5 }]
311      : [...lookAt, { do: 'walk', to: spot(c) }, { do: 'ponder', secs: 3 }]
312  } else {
313    beats = chance(0.5, rand)
314      ? [{ do: 'walk', to: spot(c) }, ...lookAt, { do: 'ponder', secs: 2.5 }]
315      : [...lookAt, { do: 'wait', secs: 1.5 }, { do: 'walk', to: spot(c) }, { do: 'ponder', secs: 2.5 }]
316  }
317  return build({ mood, beats: beats.slice(0, MAX_BEATS) }, world)
318}
319
320const WRAP_LINES = ['Done!', 'That should do it.', 'All wrapped up.', 'There we go.', 'Finished, I think.']
321const ABORT_LINES = ['Okay, stopping here.', 'Right, pausing for now.', 'Alright, I will leave it there.']
322
323/** The turn's wrap-up in the current world. `aborted`: no celebration, a shrug or a small sigh instead. */
324export function cannedWrap(world: World, lastSaid: string | undefined, aborted: boolean, rand: Rand): Scene {
325  const c: Ctx = { world, rand, ids: new Set(world.props.map(p => p.id)), signs: 0 }
326  const said = lastSaid ? clip(lastSaid.replace(/\s+/g, ' ').trim(), 60) : ''
327  const prop = of(c, 'flag', 'chest', 'computer', 'tree', 'lamp', 'crate')
328  if (aborted) {
329    const beats: Raw[] = said || chance(0.5, rand)
330      ? [{ do: 'say', text: said || pick(ABORT_LINES, rand), secs: 3 }, { do: 'shrug' }]
331      : [{ do: 'emote', mood: 'sad', secs: 2 }, { do: 'ponder', secs: 2 }]
332    return build({ mood: 'neutral', beats }, world)
333  }
334  const beats: Raw[] = []
335  if (prop && chance(0.5, rand)) beats.push({ do: 'look', at: prop.id })
336  beats.push({ do: 'say', text: said || pick(WRAP_LINES, rand), secs: 4 })
337  beats.push(pick([{ do: 'celebrate' }, { do: 'wave' }, { do: 'emote', mood: 'happy', secs: 2 }] as Raw[], rand))
338  if (chance(0.4, rand)) beats.push({ do: 'emote', mood: 'proud', secs: 2 })
339  return build({ mood: pick(['happy', 'proud'] as const, rand), beats: beats.slice(0, MAX_BEATS) }, world)
340}
341
342/** Holds a raw scene to the language; on any trouble a minimal valid scene plays instead. */
343function build(raw: Raw, world: World | undefined): Scene {
344  try {
345    const scene = parseScene(JSON.stringify(raw), world, { changes: 'none' })
346    if (!('error' in scene)) return scene
347  } catch { /* fall through to the fallback */ }
348  const minimal = { setting: world?.setting ?? 'meadow', props: [], beats: [{ do: 'ponder', secs: 2 }] }
349  const fallback = parseScene(JSON.stringify(minimal), world)
350  if (!('error' in fallback)) return fallback
351  return { setting: minimal.setting, mood: 'neutral', props: [], beats: [{ do: 'ponder', secs: 2 }], continues: world ? true : undefined }
352}
353
hooks/canvas.ts 161 lines
1// A pixel canvas two pixels tall per terminal cell (half blocks), with a text
2// layer on top, encoded into a Raster's cells.
3
4export const DEFAULT = 0x01000000
5
6const UPPER = 0x2580
7const LOWER = 0x2584
8
9export class Canvas {
10  readonly px: Uint32Array
11  readonly glyph: Uint32Array
12  readonly fg: Uint32Array
13  readonly bg: Uint32Array
14  readonly height: number
15
16  constructor(readonly columns: number, readonly rows: number) {
17    this.height = rows * 2
18    this.px = new Uint32Array(columns * this.height).fill(DEFAULT)
19    this.glyph = new Uint32Array(columns * rows)
20    this.fg = new Uint32Array(columns * rows)
21    this.bg = new Uint32Array(columns * rows)
22  }
23
24  set(x: number, y: number, color: number) {
25    x = Math.round(x)
26    y = Math.round(y)
27    if (x < 0 || y < 0 || x >= this.columns || y >= this.height) return
28    // painter's order: a pixel drawn later covers any text drawn into its cell before.
29    // It covers only its half, though: the other half keeps the text's background
30    // (a sign's board), or the board would show a gap above a figure walking past it.
31    const cell = (y >> 1) * this.columns + x
32    if (this.glyph[cell]) {
33      if (this.bg[cell] !== DEFAULT) this.px[(y ^ 1) * this.columns + x] = this.bg[cell]!
34      this.glyph[cell] = 0
35    }
36    this.px[y * this.columns + x] = color
37  }
38
39  rect(x: number, y: number, w: number, h: number, color: number) {
40    for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) this.set(x + i, y + j, color)
41  }
42
43  /** Draws rows of palette letters, '.' transparent; `flip` mirrors it. */
44  sprite(x: number, y: number, rows: readonly string[], palette: Record<string, number>, flip = false) {
45    for (let j = 0; j < rows.length; j++) {
46      const row = rows[j]!
47      for (let i = 0; i < row.length; i++) {
48        const ch = row[flip ? row.length - 1 - i : i]!
49        const color = palette[ch]
50        if (color !== undefined) this.set(x + i, y + j, color)
51      }
52    }
53  }
54
55  /** Writes text into the cell layer; non-printable or wide characters become '?'. */
56  text(col: number, row: number, s: string, fg: number, bg = DEFAULT) {
57    col = Math.round(col)
58    if (row < 0 || row >= this.rows) return
59    let i = 0
60    for (const ch of s) {
61      const c = col + i++
62      if (c < 0 || c >= this.columns) continue
63      const at = row * this.columns + c
64      this.glyph[at] = safe(ch)
65      this.fg[at] = fg
66      this.bg[at] = bg
67    }
68  }
69
70  /** A rounded box around `lines`, top-left at (col, row). */
71  box(col: number, row: number, lines: readonly string[], fg: number, bg: number, border: number) {
72    if (!lines.length) return
73    const inner = Math.max(...lines.map(l => [...l].length))
74    this.text(col, row, '╭' + '─'.repeat(inner + 2) + '╮', border, bg)
75    lines.forEach((l, i) => {
76      this.text(col, row + 1 + i, '│', border, bg)
77      this.text(col + 1, row + 1 + i, ' ' + l + ' '.repeat(inner - [...l].length + 1), fg, bg)
78      this.text(col + inner + 3, row + 1 + i, '│', border, bg)
79    })
80    this.text(col, row + lines.length + 1, '╰' + '─'.repeat(inner + 2) + '╯', border, bg)
81  }
82
83  /** The Raster `cells`: base64 of [codePoint, fg, bg] u32 triplets, row-major. */
84  encode(): string {
85    const { columns, rows } = this
86    const out = new Uint32Array(columns * rows * 3)
87    for (let r = 0; r < rows; r++) {
88      for (let c = 0; c < columns; c++) {
89        const cell = r * columns + c
90        const o = cell * 3
91        if (this.glyph[cell]) {
92          out[o] = this.glyph[cell]!
93          out[o + 1] = this.fg[cell]!
94          out[o + 2] = this.bg[cell] === DEFAULT ? this.px[(2 * r + 1) * columns + c]! : this.bg[cell]!
95          continue
96        }
97        const top = this.px[2 * r * columns + c]!
98        const bottom = this.px[(2 * r + 1) * columns + c]!
99        if (top === DEFAULT && bottom === DEFAULT) {
100          out[o] = 0x20; out[o + 1] = DEFAULT; out[o + 2] = DEFAULT
101        } else if (top === bottom) {
102          // a solid cell is a coloured space, not a full block: no glyph for a terminal's
103          // minimum-contrast setting (VS Code's is on by default) to brighten against its own colour
104          out[o] = 0x20; out[o + 1] = top; out[o + 2] = top
105        } else if (top === DEFAULT) {
106          out[o] = LOWER; out[o + 1] = bottom; out[o + 2] = DEFAULT
107        } else {
108          out[o] = UPPER; out[o + 1] = top; out[o + 2] = bottom
109        }
110      }
111    }
112    return base64(new Uint8Array(out.buffer))
113  }
114}
115
116function safe(ch: string): number {
117  const cp = ch.codePointAt(0) ?? 0x3f
118  if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0) || cp > 0xffff) return 0x3f
119  // East Asian wide ranges and emoji draw two cells wide: refuse them here.
120  if ((cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) ||
121      (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60) ||
122      (cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0xd800 && cp <= 0xdfff)) return 0x3f
123  // emoji (✅ ⚡ ⭐) draw two cells wide, marks and format characters none: the host refuses both
124  if (UNFIT.test(ch)) return 0x3f
125  return cp
126}
127
128const UNFIT = /[\p{Emoji_Presentation}\p{Mn}\p{Me}\p{Cf}]/u
129
130/**
131 * Text as the canvas can draw it, one code point to a cell: accents composed onto
132 * their letters, emoji and invisible marks dropped (anything else unfit becomes '?').
133 */
134export function cellText(s: string): string {
135  return s
136    .normalize('NFC')
137    .replace(/\p{Emoji_Presentation}|\p{Extended_Pictographic}️|[\p{Mn}\p{Me}\p{Cf}]/gu, '')
138    .replace(/\s+/g, ' ')
139    .trim()
140}
141
142const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
143
144export function base64(bytes: Uint8Array): string {
145  let out = ''
146  let i = 0
147  for (; i + 2 < bytes.length; i += 3) {
148    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
149    out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]! + ALPHABET[(n >> 6) & 63]! + ALPHABET[n & 63]!
150  }
151  const rest = bytes.length - i
152  if (rest === 1) {
153    const n = bytes[i]! << 16
154    out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]! + '=='
155  } else if (rest === 2) {
156    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
157    out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]! + ALPHABET[(n >> 6) & 63]! + '='
158  }
159  return out
160}
161
hooks/demo.ts 66 lines
1// The tour /comic-demo plays: every mood once, then every action, each beat
2// captioned with what it is meant to show.
3
4import type { Beat, Mood, Scene } from './scene'
5
6const MOOD_TOUR: [Exclude<Mood, 'neutral'>, string][] = [
7  ['happy', '^ ^ eyes, a music note, sparkles'],
8  ['proud', '^ ^ eyes, a star, lots of sparkles'],
9  ['love', '^ ^ eyes, a heart, hearts floating up'],
10  ['sad', 'drooping eyes, rain cloud, dusty colour, slumped, tears'],
11  ['angry', 'slanted eyes, anger mark, red, shaking, steam'],
12  ['surprised', 'big round eyes, "!"'],
13  ['confused', 'uneven eyes, "?", looking both ways'],
14  ['sleepy', 'closed eyes, Z, z’s drifting up, slow breathing'],
15  ['focused', 'small eyes under a determined brow, "..."'],
16  ['worried', 'sweat drop, trembling'],
17]
18
19const MOOD_SECS = 3.5
20
21/** What each mood is meant to show, for captions. */
22export const MOOD_SHOWS: Record<string, string> = Object.fromEntries(MOOD_TOUR)
23
24/** The beat /comic-feel plays: one mood, acted out for five seconds with its caption. */
25export function feelBeat(mood: Exclude<Mood, 'neutral'>): Beat {
26  return { do: 'emote', mood, secs: 5, caption: `${mood} - ${MOOD_SHOWS[mood]}` }
27}
28
29export function demoScene(): Scene {
30  const n = MOOD_TOUR.length
31  const beats: Beat[] = [
32    { do: 'walk', to: 22, caption: 'Comic tour: every mood, then every action' },
33    ...MOOD_TOUR.map(([mood, shows], i): Beat => ({
34      do: 'emote', mood, secs: MOOD_SECS, caption: `mood ${i + 1}/${n}: ${mood} - ${shows}`,
35    })),
36    { do: 'read', secs: 3, caption: 'action: read - holds an open book, eyes down, a page turns' },
37    { do: 'type', at: 'pc', secs: 3, caption: 'action: type - walks to the computer, arms alternate, cursor races' },
38    { do: 'carry', at: 'box', caption: 'action: carry - lifts the crate overhead' },
39    { do: 'run', to: 92, caption: 'action: run - fast legs, dust behind' },
40    { do: 'drop', caption: 'action: drop - sets the crate down where he stands' },
41    { do: 'look', at: 'bug', react: '!', caption: 'action: look - walks to a prop, faces it, "!" or "?"' },
42    { do: 'squash', at: 'bug', caption: 'action: squash - jumps on it, it vanishes in a puff' },
43    { do: 'dig', secs: 2, caption: 'action: dig - bobs and throws dirt behind him' },
44    { do: 'jump', caption: 'action: jump' },
45    { do: 'wave', caption: 'action: wave - one arm up and down, happy eyes' },
46    { do: 'shrug', caption: 'action: shrug - both arms up, squinting' },
47    { do: 'dance', secs: 3, caption: 'action: dance - hops, turns, swaps arms, sparkles' },
48    { do: 'say', text: 'This is a speech bubble: up to 110 characters, wrapped to fit.', secs: 4, caption: 'action: say' },
49    { do: 'think', text: 'And this is a thought bubble.', secs: 3, caption: 'action: think' },
50    { do: 'celebrate', caption: 'action: celebrate - arms up, hops, sparkles. End of tour!' },
51  ]
52  return {
53    setting: 'meadow',
54    mood: 'neutral',
55    props: [
56      { id: 'pc', kind: 'computer', x: 72 },
57      { id: 'box', kind: 'crate', x: 45 },
58      { id: 'bug', kind: 'bug', x: 58 },
59    ],
60    beats,
61  }
62}
63
64/** Roughly how long the tour runs, with room for the walks between beats. */
65export const DEMO_MS = (MOOD_TOUR.length * MOOD_SECS + 52) * 1000
66
hooks/director.ts 213 lines
1// What the director tells Sonnet, and how the agent's activity is summarized for it.
2
3import { HATS, MOODS, PROP_KINDS, SETTINGS } from './scene'
4import type { Scene } from './scene'
5
6export const SYSTEM = `You are the director of a tiny animated comic strip shown in a terminal while an AI coding agent works.
7The star is the agent itself: a small orange pixel creature ("Claude") who walks around a little world, inspects things and remarks on what it finds.
8Each time you are called you get a log of what the agent just did (files read, commands run, searches, its own remarks) and you stage ONE short scene (about 6-15 seconds) that turns the latest activity into a visual metaphor.
9
10Reply with ONLY a JSON object, no prose, no code fence:
11{
12  "setting": one of ${SETTINGS.map(s => `"${s}"`).join(', ')},
13  "mood": one of ${MOODS.map(m => `"${m}"`).join(', ')}   (Claude's mood for the whole scene),
14  "hat": one of ${HATS.map(h => `"${h}"`).join(', ')}   (first scene of the session only; later, change it with the hat beat),
15  "props": [ { "id": "short-id", "kind": one of ${PROP_KINDS.map(k => `"${k}"`).join(', ')}, "x": 0-100, "label": "optional, max 22 chars" } ],
16  "beats": [ ... ]
17}
18
19Beats run in order:
20  {"do":"walk","to":0-100}   {"do":"run","to":0-100}   (run kicks up dust: urgency, excitement)
21  {"do":"look","at":"<prop id>","react":"!" or "?"}   (walks over to the prop by itself)
22  {"do":"say","text":"aim for 40-80 chars, never over 100"}
23  {"do":"think","text":"aim for 40-80 chars, never over 100"}
24  {"do":"emote","mood":"<mood>","secs":2}   (a passing feeling: surprised at a find, worried at a risk, angry at a failure, love for nice code)
25  {"do":"read","secs":3}   (holds an open book: reading files or docs)
26  {"do":"type","at":"<computer prop id>"}   (walks to it and types: running commands, editing)
27  {"do":"carry","at":"<prop id>"} then later {"do":"drop"}   (lifts a prop overhead and sets it down where Claude stands: moving or refactoring)
28  {"do":"plant_sign","label":"max 22 chars","x":0-100,"id":"optional short id"}   (hammers a new sign into the ground: mark a finding, a file, a TODO)
29  {"do":"pull_sign","at":"<sign id>"}   (pulls a sign out of the ground and it vanishes: done, fixed, or wrong after all)
30  {"do":"ponder","secs":3}   (stands and contemplates: a "..." bubble, looks one way, then the other)
31  {"do":"transform","at":"<prop id>","into":"<prop kind>","label":"optional"}   (lifts it overhead, a puff of magic smoke, it becomes the new thing for good)
32  {"do":"hat","hat":"<hat>"}   (a puff over his head and he wears another hat, for good)
33  {"do":"travel","setting":"<setting>","props":[{"kind":"<prop kind>","label":"optional"}, ... up to 4],"hat":"optional"}
34      (a door appears, he walks through it into new scenery with these props; always the LAST beat of its scene)
35  {"do":"dance"}  {"do":"wave"}  {"do":"shrug"}  {"do":"dig"}  {"do":"jump"}  {"do":"celebrate"}  {"do":"wait","secs":1}
36
37Guidelines:
38- Unhurried. HARD LIMIT: 7 beats per scene (9 for an interlude); 5 is often best. At most 4 props, one idea per scene. Claude is calm and thoughtful,
39  not busy: after picking something up, putting it down or finding something, give him a moment (ponder, wait, or a short emote)
40  before the next thing. Fewer, deliberate actions beat many quick ones.
41- Spread props out (x values at least 15 apart). Leave room near x=0 where Claude may stand.
42- Whimsy over literalness: this is a storybook world, not an office. For each piece of work pick a surprising but fitting metaphor in any setting.
43  The obvious one (a library for reading code) is allowed only now and then. Ideas, each one of many:
44  reading or exploring code: a forest of signposts named after the files; beachcombing scrolls washed up by the sea; lamp-lit inscriptions in a cave; a sign on the moon.
45  searching: digging for a chest in the desert or on the beach; lifting rocks to peek under them; hunting with a lamp at night.
46  running commands or tests: typing at a computer planted somewhere odd (a dune, the moon, a beach) and waiting for its verdict.
47  a bug or an error: a bug crawls out from behind a crate, rock or mushroom; Claude jumps back, surprised or worried.
48  fixing it: stand over the bug and cheer; or carry the crate it hid behind to a better spot.
49  writing or editing files: stacking crates, carrying a scroll to its sign, planting a flag.
50  refactoring or moving code: carrying props from one place to another and setting them down in a new order.
51  web research: a scroll from the sea, a signal from a far planet.  a long wait: night, a lamp, sitting by a mushroom, dozing.
52- One world per session, and you own it. The session's FIRST scene sets it up: a setting, a hat and exactly 4 props that belong
53  together as one small place (a desert dig site: cactus, chest, rock, a sign; a night camp: lamp, crate, scroll, flag). Pick a
54  place that can hold a whole session of work. Every later scene gets the world as it now is in the request: do not send "props"
55  or "hat" then, refer to the props by their ids. Carrying a prop to another spot is fine; squashing is not (props never vanish).
56- Change the world rarely, as a beat of the story, when the WORK changes, never just for variety:
57  transform a prop when the kind of work shifts (a scroll becomes a computer as reading turns into running tests; a crate
58  becomes a chest once the bug is found); hat when the session's spirit changes (tophat for a finished feature, nightcap for
59  a long slog, miner when the digging starts); travel only when the session moves on to clearly different work, and only at a
60  turn's start or end. Most scenes change nothing. The request says how many changes are left this session; never use more.
61- Signs are notes in the world, on top of the 4 props: when something concrete is found (a cause, a number, a file, a TODO),
62  plant_sign it right there with a short label (aim for 14 characters or fewer: long boards crowd the strip);
63  pull_sign one that is done or turned out wrong. At most 2 planted signs stand at once; they stay until pulled.
64- A hat fits the place and the spirit of the work: wizard (library, night, space, deep reading),
65  miner (cave, digging, searching), straw (beach, desert, meadow), tophat (a proud or festive moment), nightcap (night, long waits),
66  or none. It stays on until a hat beat changes it.
67- A "sign" shows its label on a board: use it for file names, commands, or search terms the agent is working on (e.g. "auth.ts", "npm test").
68- Props are toys, not scenery: every scene has at least TWO interactions with its props (look at, carry and drop, type at, dig beside),
69  ideally a tiny story: find something, react to it, do something about it.
70- Use "read" at most once per scene, and not in two scenes in a row. Prefer carry, drop, type, dig, run and the gestures.
71- The setting is chosen once per session: its first scene picks it, every later scene stays in it.
72  Vary the moods, actions and stories instead. Follow the variety notes in each request.
73- Give Claude feelings: pick a scene mood that fits the work, and use at least one emote, action or gesture (wave, shrug, dance) per scene so the strip stays lively. Vary them between scenes.
74- Speech is Claude remarking on REAL findings from the log: concrete, short, a little witty, first person. Never invent facts that are not in the log.
75- Continuity lives in the story: the place and its props carry over from turn to turn, so new turns can call back to earlier ones.`
76
77export function buildPrompt(args: {
78  goal: string | null
79  log: readonly string[]
80  fresh: number
81  previous: Scene | null
82  finished: boolean
83  interlude?: { n: number; quietSecs: number; isLast: boolean }
84  variety?: Variety
85  world?: { setting: string; props: readonly { id: string; kind: string; label?: string; x: number }[]; gone: readonly string[]; hat?: string }
86  /** World changes the director may still make this session. */
87  changesLeft?: { transform: number; hat: number; travel: number }
88}): string {
89  const older = args.log.slice(0, Math.max(0, args.log.length - args.fresh))
90  const latest = args.log.slice(Math.max(0, args.log.length - args.fresh))
91  const parts = [
92    `The person asked the agent: ${args.goal ?? '(unknown)'}`,
93    older.length ? `Earlier activity:\n${older.slice(-12).join('\n')}` : '',
94    args.interlude
95      ? interludeAsk(args.interlude)
96      : `Latest activity (stage THIS):\n${latest.join('\n') || '(the agent is thinking)'}`,
97    args.previous
98      ? `Previous scene: setting=${args.previous.setting}, mood=${args.previous.mood}, props=${args.previous.props.map(p => `${p.kind}${p.label ? `(${p.label})` : ''}`).join(', ') || 'none'}`
99      : 'This is the first scene.',
100    args.world ? worldAsk(args.world, args.changesLeft) : 'This is the FIRST scene of the session: set up its world (setting, hat and 4 related props). It carries over to every later turn.',
101    args.variety ? varietyAsk(args.variety, Boolean(args.interlude)) : '',
102    args.finished
103      ? 'The agent has just FINISHED its turn. Stage a short wrap-up scene: Claude sums up the outcome in one remark and celebrates or shrugs as fits.'
104      : '',
105  ]
106  return parts.filter(Boolean).join('\n\n')
107}
108
109/** What the recent scenes leaned on, so the next one can lean elsewhere. */
110export type Variety = { recentSettings: string[]; overused: string[]; suggest: string; banned?: string; locked?: string }
111
112/**
113 * Reads the last few scenes for the actions they used most, and the settings of
114 * recent turns (`settings`, oldest first; the scenes' own when absent) for a setting to try.
115 * `locked` is the session's setting once its first scene picked one.
116 */
117export function varietyNotes(
118  recent: readonly Scene[],
119  rand: () => number = Math.random,
120  settings?: readonly string[],
121  locked?: string,
122): Variety {
123  const recentSettings = [...(settings ?? recent.map(s => s.setting))]
124  const counts = new Map<string, number>()
125  for (const scene of recent.slice(-4)) for (const b of scene.beats) counts.set(b.do, (counts.get(b.do) ?? 0) + 1)
126  const overused = [...counts].filter(([, n]) => n >= 3).sort((a, b) => b[1] - a[1]).map(([act, n]) => `${act} x${n}`)
127  const lately = new Set(recentSettings.slice(-3))
128  const fresh = SETTINGS.filter(s => !lately.has(s))
129  const pool = fresh.length ? fresh : [...SETTINGS]
130  const suggest = pool[Math.floor(rand() * pool.length)]!
131  const [a, b] = recentSettings.slice(-2)
132  return { recentSettings, overused, suggest, banned: a !== undefined && a === b ? a : undefined, locked }
133}
134
135function varietyAsk(v: Variety, isInterlude: boolean): string {
136  const lines = ['Variety notes:']
137  if (v.locked) {
138    lines.push(`- the setting is fixed for this session: "${v.locked}". Stay in it; vary mood, actions and stories instead`)
139  } else {
140    if (v.recentSettings.length) lines.push(`- settings of recent turns, oldest first: ${v.recentSettings.join(', ')}`)
141    lines.push('- this is the first scene of the session: it picks the setting for the whole session')
142  }
143  if (!isInterlude && !v.locked) {
144    lines.push(`- suggested setting this time: ${v.suggest} (use it unless it truly cannot fit the work)`)
145    if (v.banned) lines.push(`- do NOT use "${v.banned}" this time: the last two turns used it`)
146  }
147  if (v.overused.length) lines.push(`- used a lot lately, so go easy on them: ${v.overused.join(', ')}`)
148  return lines.join('\n')
149}
150
151function worldAsk(w: NonNullable<Parameters<typeof buildPrompt>[0]['world']>, left?: { transform: number; hat: number; travel: number }): string {
152  const props = w.props.map(p => `${p.id} = ${p.kind}${p.label ? ` "${p.label}"` : ''} at x=${p.x}`).join('; ')
153  const world = `The world as it is now (do NOT send "props" or "hat"): setting ${w.setting}; Claude wears: ${w.hat ?? 'none'}; props: ${props || 'none left'}` +
154    (w.gone.length ? `; gone: ${w.gone.join(', ')}` : '') + '. Use these ids.'
155  if (!left) return world
156  return `${world}
157World changes left this session (use only when the work really changes): transform ${left.transform}, hat ${left.hat}, travel ${left.travel}.`
158}
159
160/** What to stage when nothing new has happened for a while but the agent is still at work. */
161function interludeAsk(i: { n: number; quietSecs: number; isLast: boolean }): string {
162  if (i.isLast) {
163    return `No new activity for ${i.quietSecs}s; the agent is still busy. Stage a short scene in the same setting where Claude, ` +
164      'still waiting, grows drowsy: a yawn-like remark, then {"do":"emote","mood":"sleepy","secs":4}. Invent no findings.'
165  }
166  return `INTERLUDE ${i.n}: no new activity for ${i.quietSecs}s, but the agent is still working (thinking, or a long command). ` +
167    "Continue the previous scene's story in the same setting with 5-8 unhurried beats, pauses included (ponder, wait), so the strip does not stall: Claude keeps busy - " +
168    'rereading or pacing, typing, carrying something, thinking aloud about what IS in the log, a small gesture, a light joke about the wait. ' +
169    'Chain varied actions. Invent no findings and do not repeat the previous remarks.'
170}
171
172const cut = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s).replace(/\s+/g, ' ')
173
174/** One log line for a tool call, from its input and how it ended. */
175export function describeCall(e: Record<string, unknown>, outcome: { isError?: boolean; deny?: string; result?: unknown }): string {
176  const tool = String(e.tool)
177  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
178  let what: string
179  switch (tool) {
180    case 'Read': what = `read ${s('file_path')}`; break
181    case 'Edit': what = `edited ${s('file_path')}`; break
182    case 'Write': what = `wrote ${s('file_path')}`; break
183    case 'Bash': what = `ran \`${cut(s('command'), 80)}\``; break
184    case 'Grep': what = `searched for "${cut(s('pattern'), 40)}"${s('path') ? ` in ${s('path')}` : ''}`; break
185    case 'Glob': what = `listed files matching ${s('pattern')}`; break
186    case 'WebFetch': what = `fetched ${s('url')}`; break
187    case 'WebSearch': what = `searched the web for "${cut(s('query'), 50)}"`; break
188    case 'Agent': what = `sent a helper agent: ${cut(s('description') || s('prompt'), 60)}`; break
189    default: {
190      const args = JSON.stringify(Object.fromEntries(Object.entries(e).filter(([k]) => !['tool', 'tool_use_id', 'agentId'].includes(k))))
191      what = `used ${tool} ${cut(args ?? '', 70)}`
192    }
193  }
194  if (e.agentId) what = `(helper) ${what}`
195  if (outcome.deny) return `${what} -> blocked`
196  const result = summarize(outcome.result)
197  if (outcome.isError) return `${what} -> FAILED${result ? `: ${result}` : ''}`
198  return result ? `${what} -> ${result}` : what
199}
200
201function summarize(result: unknown): string {
202  if (result === undefined || result === null) return ''
203  let text: string
204  if (typeof result === 'string') text = result
205  else {
206    const r = result as Record<string, unknown>
207    text = typeof r.stdout === 'string' || typeof r.stderr === 'string'
208      ? `${r.stdout ?? ''} ${r.stderr ?? ''}`
209      : typeof r.content === 'string' ? r.content : (JSON.stringify(result) ?? '')
210  }
211  return cut(text.trim(), 140)
212}
213
hooks/pacing.ts 121 lines
1// When to ask the director for the next scene, and how long each step took on the way to the screen.
2
3import type { Stamp } from './scene'
4
5/** Per pace: the least time between two requests, and how quiet a turn must be before an interlude. */
6export const TIMING = {
7  calm: { minGapMs: 10000, quietMs: 6000 },
8  normal: { minGapMs: 8000, quietMs: 5000 },
9  lively: { minGapMs: 6000, quietMs: 4000 },
10}
11export const MAX_INTERLUDES = 4 // per quiet stretch; the last one sends Claude to sleep
12const LEAD_MS = 1500 // a scene should land a little before the stage runs dry, not just after
13const INTERLUDE_GAP_MS = 6000
14
15export type AskState = {
16  now: number
17  pace: keyof typeof TIMING
18  /** Log lines not staged yet. */
19  fresh: number
20  lastAsk: number
21  lastActivity: number
22  isTurnRunning: boolean
23  isWrapPending: boolean
24  /** True until the turn's first scene has set up its world. */
25  isWorldless: boolean
26  interludes: number
27  /** What the stage has left to play, playing and queued (Stage.remainingMs). */
28  remainingMs: number
29  /** How soon a scene of news would get on stage (Stage.untilFreeMs). */
30  untilFreeMs: number
31  /** How long the director usually takes to answer. */
32  latencyMs: number
33}
34
35/**
36 * What to ask for now, if anything. A scene is asked for about when the stage will
37 * run dry, so it is ready as the current one ends instead of waiting in line behind it.
38 */
39export function nextAsk(s: AskState): 'wrap' | 'scene' | 'interlude' | null {
40  if (s.isWrapPending) return 'wrap'
41  const t = TIMING[s.pace]
42  const isAhead = (ms: number) => ms <= s.latencyMs + LEAD_MS
43  if (s.fresh > 0) {
44    // the turn's first scene sets up the world: it is the slowest to stage, so it goes at once
45    if (s.now - s.lastAsk < t.minGapMs) return null
46    // news may end the playing scene at a pause, so it is timed to that pause, not to the scene's end
47    return isAhead(s.untilFreeMs) || (s.isWorldless && s.isTurnRunning) ? 'scene' : null
48  }
49  const isQuiet = s.isTurnRunning && s.now - s.lastActivity >= t.quietMs && s.now - s.lastAsk >= INTERLUDE_GAP_MS
50  // an interlude carries the story on: it waits for the current scene to play out
51  return isQuiet && isAhead(s.remainingMs) && s.interludes < MAX_INTERLUDES ? 'interlude' : null
52}
53
54/** A rolling estimate of the director's answer time: recent calls count most. */
55export function blendLatency(current: number, measured: number): number {
56  return Math.round(current * 0.7 + measured * 0.3)
57}
58
59/** One played scene's way to the screen (ms). */
60export type SceneTiming = {
61  kind: Stamp['kind']
62  waitMs?: number
63  modelMs: number
64  queueMs: number
65  totalMs?: number
66  expectedLeftMs: number
67  /** What the stage really had left when it was asked: from the request to the scene's start. */
68  actualLeftMs: number
69}
70
71const KEEP_RECENT = 8
72
73export type Timings = { scenes: number; staged: number; waitMs: number; modelMs: number; queueMs: number; totalMs: number; recent: SceneTiming[] }
74
75export const emptyTimings = (): Timings => ({ scenes: 0, staged: 0, waitMs: 0, modelMs: 0, queueMs: 0, totalMs: 0, recent: [] })
76
77/** Adds a scene that just began to play. `staged` counts the ones that stage real activity (not interludes). */
78export function addTiming(t: Timings, stamp: Stamp, playedAt: number): Timings {
79  const hasActivity = stamp.activity !== undefined
80  const row: SceneTiming = {
81    kind: stamp.kind,
82    waitMs: hasActivity ? stamp.asked - stamp.activity! : undefined,
83    modelMs: stamp.answered - stamp.asked,
84    queueMs: playedAt - stamp.answered,
85    totalMs: hasActivity ? playedAt - stamp.activity! : undefined,
86    expectedLeftMs: stamp.expectedLeftMs,
87    actualLeftMs: playedAt - stamp.asked,
88  }
89  return {
90    scenes: t.scenes + 1,
91    staged: t.staged + (hasActivity ? 1 : 0),
92    waitMs: t.waitMs + (row.waitMs ?? 0),
93    modelMs: t.modelMs + row.modelMs,
94    queueMs: t.queueMs + row.queueMs,
95    totalMs: t.totalMs + (row.totalMs ?? 0),
96    recent: [...t.recent, row].slice(-KEEP_RECENT),
97  }
98}
99
100export function formatTimings(t: Timings, latencyMs: number, asked: number): string {
101  if (t.scenes === 0) return 'Latency: no director scene has played yet this session.'
102  const s = (ms: number, n: number) => (n ? `${(ms / n / 1000).toFixed(1)}s` : 'n/a')
103  const one = (ms: number | undefined) => (ms === undefined ? '–' : `${(ms / 1000).toFixed(1)}s`)
104  return [
105    `Latency, averaged over ${t.scenes} scenes this session (${asked - t.scenes} more still queued or dropped):`,
106    '',
107    '| activity → request | request → answer | answer → on stage | activity → on stage |',
108    '|---|---|---|---|',
109    `| ${s(t.waitMs, t.staged)} | ${s(t.modelMs, t.scenes)} | ${s(t.queueMs, t.scenes)} | ${s(t.totalMs, t.staged)} |`,
110    '',
111    `The last ${t.recent.length} scenes, oldest first ("stage left": what the stage expected to have left when asked, and what it really had):`,
112    '',
113    '| scene | activity → request | request → answer | answer → on stage | activity → on stage | stage left: expected / real |',
114    '|---|---|---|---|---|---|',
115    ...t.recent.map(r =>
116      `| ${r.kind} | ${one(r.waitMs)} | ${one(r.modelMs)} | ${one(r.queueMs)} | ${one(r.totalMs)} | ${one(r.expectedLeftMs)} / ${one(r.actualLeftMs)} |`),
117    '',
118    `Requests go out when the stage has about ${(latencyMs / 1000).toFixed(1)}s left to play (the director's recent answer time).`,
119  ].join('\n')
120}
121
hooks/hub.ts 60 lines
1// The hub: Claude's own place, where the session opens. It keeps the keepsakes Claude
2// adds with the comic's tool, across sessions, and has the door to each turn's world.
3
4import { cellText } from './canvas'
5import { HUB_KINDS } from './hubart'
6import type { HubKind } from './hubart'
7
8export type HubItem = { kind: HubKind; label: string; at: number }
9
10export const MAX_HUB_ITEMS = 6 // the hub keeps the newest six; a seventh retires the oldest
11export const MAX_HUB_LABEL = 16
12export const HUB_STORE_KEY = 'hub'
13export const HUB_TOOL = 'hub_add'
14
15/** The tool Claude calls, as `$.tool.register` takes it: deferred, so it costs nothing until looked up. */
16export const HUB_TOOL_SPEC = {
17  name: HUB_TOOL,
18  description:
19    'Add a small keepsake to the agent-comic hub, the little home scene shown above the prompt when Claude Code starts. ' +
20    'Use it rarely: only to remember a real milestone the person would be glad to see again (a hard bug finally fixed, ' +
21    'a feature shipped, a long refactor done, tests green after a struggle). At most one per session; never for routine work.',
22  inputSchema: {
23    type: 'object',
24    properties: {
25      kind: { type: 'string', enum: [...HUB_KINDS], description: 'trophy or medal for wins, plant or flower for growth, lantern for insight, banner for a release, gem for a rare find, statue for a big milestone' },
26      label: { type: 'string', description: `What it remembers, at most ${MAX_HUB_LABEL} characters (e.g. "auth bug slain", "v2 shipped")` },
27    },
28    required: ['kind', 'label'],
29    additionalProperties: false,
30  },
31  isDeferred: true,
32} as const
33
34/** A keepsake from the tool's input, or why not. */
35export function hubItemFrom(input: unknown, at: number): HubItem | { error: string } {
36  const i = (input ?? {}) as { kind?: unknown; label?: unknown }
37  if (!HUB_KINDS.includes(i.kind as HubKind)) return { error: `kind must be one of: ${HUB_KINDS.join(', ')}` }
38  const label = typeof i.label === 'string' ? [...cellText(i.label)].slice(0, MAX_HUB_LABEL).join('').trim() : ''
39  if (!label) return { error: 'label must be a few words' }
40  return { kind: i.kind as HubKind, label, at }
41}
42
43/** The stored keepsakes, read back safely: anything malformed is left out. */
44export function asHubItems(v: unknown): HubItem[] {
45  if (!Array.isArray(v)) return []
46  const items: HubItem[] = []
47  for (const raw of v) {
48    const at = typeof raw?.at === 'number' && Number.isFinite(raw.at) ? raw.at : 0
49    const item = hubItemFrom(raw, at)
50    if (!('error' in item)) items.push(item)
51  }
52  return items.slice(-MAX_HUB_ITEMS)
53}
54
55/** The hub with `item` added, and the keepsake it retired to make room, if any. */
56export function addHubItem(items: readonly HubItem[], item: HubItem): { items: HubItem[]; retired?: HubItem } {
57  const all = [...items, item]
58  return { items: all.slice(-MAX_HUB_ITEMS), retired: all.length > MAX_HUB_ITEMS ? all[0] : undefined }
59}
60
hooks/scene.ts 226 lines
1// The scene language Sonnet writes: a setting, a mood, props on the ground, and
2// beats the Claude figure performs one after another.
3
4import { cellText } from './canvas'
5
6export const SETTINGS = ['meadow', 'forest', 'cave', 'night', 'desert', 'library', 'space', 'beach'] as const
7export type Setting = (typeof SETTINGS)[number]
8
9export const PROP_KINDS = [
10  'sign', 'tree', 'rock', 'chest', 'bug', 'crate', 'scroll', 'flag',
11  'lamp', 'books', 'computer', 'cactus', 'mushroom',
12] as const
13export type PropKind = (typeof PROP_KINDS)[number]
14
15export const MOODS = [
16  'neutral', 'happy', 'proud', 'love', 'sad', 'angry', 'surprised', 'confused', 'sleepy', 'focused', 'worried',
17] as const
18export type Mood = (typeof MOODS)[number]
19
20export type Prop = { id: string; kind: PropKind; x: number; label?: string }
21
22export type Beat = BeatAction & { caption?: string }
23
24export type BeatAction =
25  | { do: 'walk'; to: number }
26  | { do: 'run'; to: number }
27  | { do: 'say'; text: string; secs?: number }
28  | { do: 'think'; text: string; secs?: number }
29  | { do: 'look'; at: string; react?: '!' | '?' }
30  | { do: 'squash'; at: string }
31  | { do: 'transform'; at: string; into: PropKind; label?: string }
32  | { do: 'plant_sign'; id: string; label: string; x: number }
33  | { do: 'pull_sign'; at: string }
34  | { do: 'carry'; at: string }
35  | { do: 'drop' }
36  | { do: 'type'; at?: string; secs?: number }
37  | { do: 'read'; secs?: number }
38  | { do: 'emote'; mood: Mood; secs?: number }
39  | { do: 'dance'; secs?: number }
40  | { do: 'wave' }
41  | { do: 'shrug' }
42  | { do: 'jump' }
43  | { do: 'dig'; secs?: number }
44  | { do: 'celebrate' }
45  | { do: 'ponder'; secs?: number }
46  | { do: 'wait'; secs: number }
47  | { do: 'hat'; hat: Hat }
48  | { do: 'travel'; setting: Setting; props: Prop[]; hat?: Hat }
49
50/** `continues`: plays in the current turn's world (its setting and props, as they now are). */
51export const HATS = ['none', 'wizard', 'miner', 'straw', 'tophat', 'nightcap'] as const
52export type Hat = (typeof HATS)[number]
53
54/**
55 * `stamp`: when the director staged it (ms), for the latency figures; scenes made locally have none.
56 * `isNews`: it stages new activity (or the turn's end), so the scene playing may give way to it at a pause.
57 */
58export type Scene = {
59  setting: Setting; mood: Mood; props: Prop[]; beats: Beat[]; hat?: Hat; continues?: boolean
60  stamp?: Stamp
61  isNews?: boolean
62}
63
64export type Stamp = {
65  kind: 'first' | 'scene' | 'interlude' | 'wrap'
66  /** The oldest activity it stages; none for an interlude. */
67  activity?: number
68  asked: number
69  answered: number
70  /** What the stage expected to have left to play when it was asked for (ms). */
71  expectedLeftMs: number
72}
73
74const MAX_PROPS = 4
75const MAX_BEATS = 9 // the strip stays calm: anything past this is dropped
76export const MAX_SPEECH = 110
77const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n))
78const str = (v: unknown, max: number) => (typeof v === 'string' ? clip(cellText(v), max) : '')
79
80/** Cuts at a word boundary and marks the cut, so a remark never ends mid-word. */
81export function clip(text: string, max: number): string {
82  if (text.length <= max) return text
83  const cut = text.slice(0, max - 1)
84  const space = cut.lastIndexOf(' ')
85  return (space > max * 0.6 ? cut.slice(0, space) : cut).replace(/[\s,;:.-]+$/, '') + '…'
86}
87const num = (v: unknown, fallback: number) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback)
88const secs = (v: unknown, lo: number, hi: number) => (v === undefined ? undefined : clamp(num(v, lo), lo, hi))
89const isMood = (v: unknown): v is Mood => MOODS.includes(v as Mood)
90
91/**
92 * Who may change the session's world in a scene played in it: `none` (canned scenes: transform,
93 * hat and travel are dropped) or `director` (it may transform a prop, swap the hat, or travel).
94 * Squashing a prop is never allowed in a world: the world keeps its props.
95 */
96export type WorldChanges = 'none' | 'director'
97
98/**
99 * Pulls the first JSON object out of a reply and holds it to the language. Given the
100 * session's `world`, the scene plays in it: its own setting and props are ignored, and
101 * what may change the world follows `changes`. A travel beat ends the scene.
102 */
103export function parseScene(
104  reply: string,
105  world?: { setting: Setting; props: readonly Prop[] },
106  opts: { changes?: WorldChanges } = {},
107): Scene | { error: string } {
108  const mayChange = !world || opts.changes === 'director'
109  const start = reply.indexOf('{')
110  const end = reply.lastIndexOf('}')
111  if (start < 0 || end <= start) return { error: 'no JSON object in the reply' }
112
113  let raw: any
114  try {
115    raw = JSON.parse(reply.slice(start, end + 1))
116  } catch (err) {
117    return { error: `bad JSON: ${String(err).slice(0, 80)}` }
118  }
119
120  const setting: Setting = world?.setting ?? (SETTINGS.includes(raw?.setting) ? raw.setting : 'meadow')
121  const mood: Mood = isMood(raw?.mood) ? raw.mood : 'neutral'
122  // the hat comes with the world: a later scene of the turn keeps the one Claude already wears
123  const hat: Hat | undefined = world ? undefined : HATS.includes(raw?.hat) ? raw.hat : 'none'
124
125  const props: Prop[] = []
126  for (const p of world ? [] : Array.isArray(raw?.props) ? raw.props.slice(0, MAX_PROPS) : []) {
127    if (!PROP_KINDS.includes(p?.kind)) continue
128    const id = str(p.id, 20) || `p${props.length}`
129    const label = str(p.label, 22) || undefined
130    props.push({ id, kind: p.kind, x: clamp(num(p.x, 50), 0, 100), label })
131  }
132  if (world) props.push(...world.props.map(p => ({ ...p })))
133  const ids = new Set(props.map(p => p.id))
134  const signs = new Set(props.filter(p => p.kind === 'sign').map(p => p.id))
135
136  const beats: Beat[] = []
137  for (const b of Array.isArray(raw?.beats) ? raw.beats.slice(0, MAX_BEATS) : []) {
138    switch (b?.do) {
139      case 'walk':
140      case 'run':
141        beats.push({ do: b.do, to: clamp(num(b.to, 50), 0, 100) })
142        break
143      case 'say':
144      case 'think': {
145        const text = str(b.text, MAX_SPEECH)
146        if (text) beats.push({ do: b.do, text, secs: secs(b.secs, 1, 8) })
147        break
148      }
149      case 'look':
150        if (ids.has(b.at)) beats.push({ do: 'look', at: b.at, react: b.react === '!' || b.react === '?' ? b.react : undefined })
151        break
152      case 'squash':
153      case 'carry':
154        if (b.do === 'squash' && world) break
155        if (ids.has(b.at)) beats.push({ do: b.do, at: b.at })
156        break
157      case 'plant_sign': {
158        const label = str(b.label, 22)
159        if (!label) break
160        // a fresh id, so later beats (and scenes) can look at or pull this sign
161        let id = str(b.id, 20) || 'note'
162        for (let n = 2; ids.has(id); n++) id = `${str(b.id, 18) || 'note'}${n}`
163        ids.add(id)
164        signs.add(id)
165        beats.push({ do: 'plant_sign', id, label, x: clamp(num(b.x, 50), 0, 100) })
166        break
167      }
168      case 'pull_sign':
169        if (signs.has(b.at)) beats.push({ do: 'pull_sign', at: b.at })
170        break
171      case 'transform':
172        if (mayChange && ids.has(b.at) && PROP_KINDS.includes(b.into)) {
173          beats.push({ do: 'transform', at: b.at, into: b.into, label: str(b.label, 22) || undefined })
174        }
175        break
176      case 'type':
177        beats.push({ do: 'type', at: ids.has(b.at) ? b.at : undefined, secs: secs(b.secs, 1, 5) })
178        break
179      case 'emote':
180        if (isMood(b.mood)) beats.push({ do: 'emote', mood: b.mood, secs: secs(b.secs, 1, 5) })
181        break
182      case 'read':
183      case 'dance':
184        beats.push({ do: b.do, secs: secs(b.secs, 1, 5) })
185        break
186      case 'sleep': // sleeping is the sleepy mood, not an action of its own
187        beats.push({ do: 'emote', mood: 'sleepy', secs: secs(b.secs, 1, 5) ?? 4 })
188        break
189      case 'ponder':
190        beats.push({ do: 'ponder', secs: secs(b.secs, 1.5, 6) })
191        break
192      case 'drop':
193      case 'wave':
194      case 'shrug':
195      case 'jump':
196      case 'celebrate':
197        beats.push({ do: b.do })
198        break
199      case 'dig':
200        beats.push({ do: 'dig', secs: secs(b.secs, 1, 4) })
201        break
202      case 'wait':
203        beats.push({ do: 'wait', secs: clamp(num(b.secs, 1), 0.3, 4) })
204        break
205      case 'hat':
206        if (mayChange && HATS.includes(b.hat)) beats.push({ do: 'hat', hat: b.hat })
207        break
208      case 'travel': {
209        if (!world || !mayChange || !SETTINGS.includes(b.setting) || b.setting === world.setting) break
210        const props: Prop[] = []
211        for (const p of Array.isArray(b.props) ? b.props.slice(0, MAX_PROPS) : []) {
212          if (!PROP_KINDS.includes(p?.kind)) continue
213          props.push({ id: `${p.kind}${props.length}`, kind: p.kind, x: [24, 44, 64, 84][props.length]!, label: str(p.label, 22) || undefined })
214        }
215        beats.push({ do: 'travel', setting: b.setting, props, hat: HATS.includes(b.hat) ? b.hat : undefined })
216        break
217      }
218    }
219    // through the door, the scene is over: anything after it belonged to the old place
220    if (beats[beats.length - 1]?.do === 'travel') break
221  }
222  if (beats.length === 0) return { error: 'the scene has no usable beats' }
223
224  return { setting, mood, props, beats, hat, continues: world ? true : undefined }
225}
226
hooks/world.ts 58 lines
1// The session's world: a setting, a hat and a few props, set up by the session's first scene.
2// It stays put for the whole session; only the director changes it, now and then, in its scenes:
3// a prop transformed, a new hat, or a door into new scenery.
4
5import { cannedProps } from './canned'
6import type { Beat, Hat, Prop, Scene, Setting } from './scene'
7
8export type SessionWorld = { setting: Setting; props: Prop[]; hat: Hat }
9
10/** How often the director may change the world in one session: rarely, when the work really shifts. */
11export const WORLD_LIMITS = { transform: 6, hat: 4, travel: 2 } as const
12export type WorldChangeKind = keyof typeof WORLD_LIMITS
13export type WorldChangeCounts = Record<WorldChangeKind, number>
14
15export const noChanges = (): WorldChangeCounts => ({ transform: 0, hat: 0, travel: 0 })
16
17/** What the director may still change this session, for its request. */
18export function changesLeft(used: WorldChangeCounts): WorldChangeCounts {
19  return {
20    transform: Math.max(0, WORLD_LIMITS.transform - used.transform),
21    hat: Math.max(0, WORLD_LIMITS.hat - used.hat),
22    travel: Math.max(0, WORLD_LIMITS.travel - used.travel),
23  }
24}
25
26/**
27 * Holds a director's scene to the session's limits: world changes past them are dropped, and the
28 * ones kept are counted. A hat it already wears is no change; a travel with no props gets fitting ones.
29 */
30export function limitChanges(scene: Scene, used: WorldChangeCounts, rand: () => number = Math.random): { scene: Scene; used: WorldChangeCounts } {
31  const counts = { ...used }
32  let hat = scene.hat
33  const beats: Beat[] = []
34  for (const b of scene.beats) {
35    if (b.do === 'transform' || b.do === 'hat' || b.do === 'travel') {
36      if (b.do === 'hat' && b.hat === hat) continue
37      if (counts[b.do] >= WORLD_LIMITS[b.do]) continue
38      counts[b.do]++
39      if (b.do === 'hat') hat = b.hat
40      if (b.do === 'travel') {
41        beats.push(b.props.length ? b : { ...b, props: cannedProps(b.setting, rand) })
42        continue
43      }
44    }
45    beats.push(b)
46  }
47  // a scene left with nothing to do still gives him a moment
48  return { scene: { ...scene, beats: beats.length ? beats : [{ do: 'ponder', secs: 2 }] }, used: counts }
49}
50
51/**
52 * The session's world after the stage reported a change: what played is what the session keeps.
53 * `moved`: it is a new place, so the turn's setting and the director's work in flight follow it.
54 */
55export function followWorld(current: SessionWorld | null, shown: SessionWorld): { world: SessionWorld; moved: boolean } {
56  return { world: { ...shown, props: shown.props.map(p => ({ ...p })) }, moved: current !== null && current.setting !== shown.setting }
57}
58
hooks/stage.ts 2233 lines
1// The animation: plays a scene's beats with the Claude figure and draws each frame.
2
3import { Canvas, DEFAULT, cellText } from './canvas'
4import type { Beat, Hat, Mood, Prop, Scene } from './scene'
5import { BALLOON, BITS, HATS_ART, MINI_HEIGHT, MINI_WIDTH, miniClaude, CLAUDE_HEIGHT, MOOD_ICONS, CLAUDE_WIDTH, FLAG_WAVE, LOOKS, ORANGE, P, PROPS, SKY_ART, TINTS, bookColor, claude } from './sprites'
6import type { Arm, Face, Look, Tint } from './sprites'
7import type { HubItem } from './hub'
8import {
9  BED, BED_HEAD_X, BED_OVER, CLOUD, COUCH_BACK, COUCH_FRONT, DEN_LOOK, DOOR, FLOWER_COLORS, FLOWER_STAGES, GARDEN_LOOK, HUB_ART, HUB_LOOK,
10  RAIN, RAIN_CLOUD, SLEEP_CAP, TV, TV_SCREEN, WATERING_CAN, claudeLying, flowerPalette,
11} from './hubart'
12
13const MAX_HELPERS = 3 // small Claudes (subagents) on screen at once
14const DOZE_MS = 120_000 // idle this long and Claude falls asleep (or, between turns, goes home)
15const HOME_DOZE_MS = 15_000 // back home, he naps soon
16const DOOR_WIDTH = DOOR.closed[0]!.length
17const HUB_GAP = 5 // columns between two keepsakes
18const MAX_SCENE_MS = 30_000
19const CUT_AFTER = 3 // beats a scene plays before news may end it at its next pause
20const MAX_PLANTED_SIGNS = 2 // a scene this old gives way to a waiting one mid-play
21const BUBBLE = { fg: P.sand, bg: P.wine, border: ORANGE }
22const THOUGHT = { fg: P.silver, bg: P.night, border: P.mist }
23export const SIGN = { fg: P.white, bg: P.bark, border: P.tan }
24const CALL = { fg: P.ink, bg: P.yellow, border: P.amber } // "needs you": the one loud thing on the stage
25const PENNANT = { rows: ['phh', 'pyy', 'p..', 'p..', 'p..'], palette: { p: P.silver, h: P.yellow, y: P.amber } }
26const GOAL_FLAG = { rows: ['phh', 'prr', 'p..', 'p..'], palette: { p: P.silver, h: P.coral, r: P.red } }
27const GOAL_FLAG_DONE = { rows: ['phh', 'pyy', 'p..', 'p..'], palette: { p: P.silver, h: P.yellow, y: P.amber } }
28const BALLOON_UP = { rows: ['.r.', 'rhr', 'rrr', '.r.', '.s.', 's..'], palette: { r: P.red, h: P.coral, s: P.silver } }
29
30/** The hub's rooms, left to right; the door to the turn's world stands in each of them. */
31const ROOMS = ['garden', 'hall', 'den'] as const
32export type Room = (typeof ROOMS)[number]
33const ROOM_LOOK: Record<Room, Look> = { garden: GARDEN_LOOK, hall: HUB_LOOK, den: DEN_LOOK }
34/** What he does at home while nothing calls him away: a calm round at random, like a screensaver. */
35export type Pastime = 'admire' | 'tend' | 'rest' | 'movie'
36const PASTIMES: Record<Pastime, { room: Room; weight: number; secs: [number, number] }> = {
37  admire: { room: 'hall', weight: 2, secs: [15, 30] },
38  tend: { room: 'garden', weight: 3, secs: [25, 45] },
39  rest: { room: 'den', weight: 2, secs: [30, 60] },
40  movie: { room: 'den', weight: 3, secs: [30, 60] },
41}
42const HOME_PACE = 0.6 // he strolls about at home
43const FLOWER_COUNT = 5
44const FLOWER_STEP = 7 // columns from one flower to the next
45const WATER_SECS = 5 // one flower's watering
46const SIT_LIFT = 2 // seated on the couch he sits this many pixels up, his legs behind its front
47
48/** The session's sky, kept quiet: the local hour, and weather that follows how the work goes. */
49export type Weather = 'clear' | 'cloudy' | 'rain'
50export type Ambience = { hour: number; weather: Weather }
51/** Why Claude needs the person: a permission prompt, or a question put to them. */
52export type Attention = 'permission' | 'question'
53
54/** How each mood shows: the face it wears, its tint, and what it gives off per second. */
55const MOOD_LOOK: Record<Mood, { face: Face; tint: Tint; effect?: Effect['kind']; rate?: number }> = {
56  neutral: { face: 'ahead', tint: 'normal' },
57  happy: { face: 'happy', tint: 'normal', effect: 'sparkle', rate: 1 },
58  proud: { face: 'happy', tint: 'normal', effect: 'sparkle', rate: 3 },
59  love: { face: 'happy', tint: 'normal', effect: 'heart', rate: 1.3 },
60  sad: { face: 'sad', tint: 'sad', effect: 'tear', rate: 0.9 },
61  angry: { face: 'angry', tint: 'angry', effect: 'steam', rate: 4 },
62  surprised: { face: 'wide', tint: 'normal' },
63  confused: { face: 'confused', tint: 'normal' },
64  sleepy: { face: 'shut', tint: 'normal', effect: 'zzz', rate: 0.7 },
65  focused: { face: 'focused', tint: 'normal' },
66  worried: { face: 'ahead', tint: 'normal', effect: 'sweat', rate: 0.7 },
67}
68
69/** How busy the stage is: walk speed, and how long the rests between actions last. */
70export type Pace = { walk: number; rest: number }
71export const PACES: Record<'calm' | 'normal' | 'lively', Pace> = {
72  calm: { walk: 9, rest: 1 },
73  normal: { walk: 12, rest: 0.6 },
74  lively: { walk: 15, rest: 0.3 },
75}
76
77/** A subagent at work: a small Claude that walks in, mirrors its tool calls, and reports back. */
78type Helper = {
79  id: string
80  agentId?: string
81  label: string
82  x: number
83  facing: 1 | -1
84  target: number | null
85  state: 'arriving' | 'working' | 'leaving' | 'reporting'
86  activity: 'read' | 'type' | 'dig' | null
87  activityUntil: number
88  born: number
89  lastActive: number
90  isBackground: boolean
91  reportAt: number
92  moving: boolean
93}
94
95type Run = { beat: Beat; start: number; phase: number; phaseAt: number; fromX?: number; target?: number }
96type Particle = { x: number; y: number; vx: number; vy: number; until: number; color: number }
97
98type Effect =
99  | { kind: 'sparkle'; x: number; y: number; color: number; start: number; until: number }
100  | { kind: 'poof' | 'heart' | 'tear' | 'steam' | 'sweat' | 'zzz' | 'dust' | 'balloon'; x: number; y: number; start: number; until: number }
101
102const EMPTY: Scene = { setting: 'meadow', mood: 'neutral', props: [], beats: [] }
103
104export class Stage {
105  constructor(private readonly rand: () => number = Math.random) {}
106
107  /** Walk speed and rest length; /config's pace sets it. */
108  pace: Pace = PACES.calm
109  private helpers: Helper[] = []
110
111  private scene: Scene = EMPTY
112  private waiting: Scene[] = [] // scenes queued to play, oldest first
113  private index = 0
114  private run: Run | null = null
115  private removed = new Set<string>()
116  private placed = new Map<string, number>() // props moved by carry/drop: their column
117  private carrying: string | null = null
118
119  private x = 4
120  private facing: 1 | -1 = 1
121  private lift = 0
122  private looking = false
123  private bob = 0
124  private wanderAt = 0
125  private wanderTo: number | null = null
126  private moving = false
127  private emote: { mood: Mood; until: number } | null = null
128  private balloon: { mood: Mood; until: number } | null = null
129  private spriteTop: number | null = null
130  private idleSince: number | null = null
131  private restUntil = 0 // a breath between two actions
132  private sceneStart = 0
133  private isPendingUrgent = false
134  /** A sign being hammered in, wobbled loose or pulled out: how its drawing moves. */
135  private bubbleArea: Rect | null = null // what the bubble covers this frame
136  private planted = new Set<string>() // signs Claude hammered in this turn
137  private signMotion = new Map<string, { kind: 'grow' | 'shake' | 'rise'; start: number; ms: number }>()
138
139  private bubble: { text: string; kind: 'say' | 'think'; until: number } | null = null
140  private react: { glyph: string; until: number } | null = null
141  private cueMark: { glyph: string; color: number; until: number } | null = null
142  private dirt: Particle[] = []
143  private effects: Effect[] = []
144
145  private columns = 80
146  private rows = 8
147  private last = 0
148  private now = 0
149
150  /** Set while Sonnet is drawing the next scene: a thought bubble of dots. */
151  pondering = false
152
153  /** The hour and the weather the sky shows (outdoor settings only, and only a little). */
154  ambience: Ambience = { hour: 12, weather: 'clear' }
155  /** The agent's task list: a trail along the ground, lit as tasks are done. */
156  progress: { done: number; total: number } | null = null
157  /** Signs at the band's edges to the places beside this one, by name; null draws none. */
158  routeSigns: { left: string | null; right: string | null } | null = null
159
160  // home: the hub, its keepsakes and the door to the turn's world
161  private isHome = false
162  private isAway = false // through the door, the turn's world not yet loaded
163  private isNapping = false // came home after a long idle: he naps soon
164  private hidden = false
165  private homeItems: HubItem[] = []
166  private leaving: { phase: 'wake' | 'walk' | 'open' | 'in'; at: number; isSprint: boolean } | null = null
167  private doorOpenUntil = 0
168  /** A door Claude summoned into the world: he walks through it into new scenery, queued behind it. */
169  private portal: { col: number; phase: 'appear' | 'walk' | 'open' | 'in'; at: number } | null = null
170  private newItem: { at: number; item: HubItem } | null = null
171  private pennants: number[] = [] // commits: little flags planted in this world, by column
172  private homeLabels: { col: number; row: number; text: string; color: number }[] = [] // drawn over the figure
173
174  private room: Room = 'hall'
175  private pastime: { kind: Pastime; phase: 'go' | 'do'; until: number; next: number; flower: number } | null = null
176  private lastPastime: Pastime | null = null
177  private nextPastime: Pastime | null = null // asked for: home after a long idle he goes to bed
178  private pastimeAt = -1 // when the next may start; -1: a few seconds after the first step at home
179  private trip: { to: Room; dir: 1 | -1 } | null = null
180  private flowers: { stage: number; color: number }[] = []
181
182  private calling: Attention | null = null
183  private callingSince = 0
184
185  /** Called as a queued scene begins to play. */
186  onPlay: ((scene: Scene) => void) | null = null
187  /** Called once the world on stage changed: a prop transformed, a new hat, new scenery stepped into. */
188  onWorldChange: ((world: { setting: Scene['setting']; props: Prop[]; hat: Hat }) => void) | null = null
189
190  private tellWorld() {
191    const props = this.scene.props.filter(p => !this.removed.has(p.id)).map(p => ({ ...p }))
192    this.onWorldChange?.({ setting: this.scene.setting, props, hat: this.scene.hat ?? 'none' })
193  }
194
195  /** The figure's column, for tools that follow it. */
196  get figureColumn() {
197    return Math.round(this.x)
198  }
199
200  /** How long the figure has had nothing left to play (0 while a beat runs or a scene waits). */
201  get idleMs(): number {
202    return this.idleSince === null || this.waiting.length ? 0 : this.now - this.idleSince
203  }
204
205  /**
206   * Plays `scene` once the current one has finished (or run past MAX_SCENE_MS),
207   * so stories end before the next begins; `urgent` (the person's own commands) cuts in.
208   */
209  queue(scene: Scene, urgent = false) {
210    if (!scene.continues) this.waiting = [] // a new world supersedes everything queued for the old one
211    this.waiting.push(scene)
212    // keep the queue short: drop the oldest continuation, never a scene that sets up a world
213    while (this.waiting.length > 3) this.waiting.splice(this.waiting[0]!.continues ? 0 : 1, 1)
214    if (urgent) this.isPendingUrgent = true
215  }
216
217  /**
218   * About how long until everything playing and queued has played out (ms): the
219   * beats left, the walks between them and the rests after them. Close, not exact.
220   */
221  get remainingMs(): number {
222    return this.left(false)
223  }
224
225  /**
226   * About how long until a scene of news queued now would start (ms): the playing
227   * scene gives way at its first pause after CUT_AFTER beats with nothing in hand.
228   * With a scene already waiting, that one goes first and plays out.
229   */
230  get untilFreeMs(): number {
231    return this.waiting.length ? this.left(false) : this.left(true)
232  }
233
234  private left(isCut: boolean): number {
235    let x = this.x
236    const span = Math.max(1, this.columns - CLAUDE_WIDTH)
237    // each beat's own time, and the walk to where it happens
238    const beatSecs = (b: Beat, scene: Scene, isCurrent: boolean): number => {
239      const colOf = (id: string) => {
240        const p = scene.props.find(q => q.id === id)
241        if (!p || (isCurrent && this.removed.has(id))) return null
242        return isCurrent ? this.col(p.x, p.id) : (p.x / 100) * span
243      }
244      const go = (to: number | null, speed = this.pace.walk) => {
245        if (to === null) return 0
246        const d = Math.abs(clamp(to, 0, this.maxX()) - x)
247        x = clamp(to, 0, this.maxX())
248        return d / speed
249      }
250      const at = 'at' in b && b.at ? colOf(b.at) : null
251      switch (b.do) {
252        case 'walk': return go(isCurrent ? this.col(b.to) : (b.to / 100) * span)
253        case 'run': return go(isCurrent ? this.col(b.to) : (b.to / 100) * span, this.pace.walk * 2.4)
254        case 'say':
255        case 'think': return b.secs ?? sayTime(b.text)
256        case 'look': return go(at) + 1.8
257        case 'squash': return go(at) + 0.6
258        case 'carry': return go(at) + 0.4
259        case 'type': return go(at) + (b.secs ?? 2.5)
260        case 'drop': return 0.4
261        case 'transform': return go(at) + 2.1
262        case 'plant_sign': return go(isCurrent ? this.col(b.x) : (b.x / 100) * span) + 2.4
263        case 'pull_sign': return go(at) + 2.1
264        case 'read': return b.secs ?? 3
265        case 'emote': return b.secs ?? 3.5
266        case 'ponder': return b.secs ?? 3
267        case 'dance': return b.secs ?? 2.5
268        case 'wave': return 1.6
269        case 'shrug': return 1.3
270        case 'jump': return 0.6
271        case 'celebrate': return 1.6
272        case 'dig': return b.secs ?? 2
273        case 'wait': return b.secs
274        case 'hat': return 1.4
275        case 'travel': return 6
276      }
277    }
278    const rest = (b: Beat) => restAfter(b, 0.5) * this.pace.rest
279    const sceneMs = (scene: Scene, from: number, isCurrent: boolean) => {
280      let ms = 0
281      // what he holds by each pause, as the beats will leave it
282      let holds = isCurrent && (this.carrying !== null || this.run?.beat.do === 'carry')
283      if (isCurrent && this.run && (this.run.beat.do === 'drop' || this.run.beat.do === 'transform')) holds = false
284      for (let i = from; i < scene.beats.length; i++) {
285        if (isCut && isCurrent && i >= CUT_AFTER && !holds) break
286        const b = scene.beats[i]!
287        ms += beatSecs(b, scene, isCurrent) * 1000 + rest(b)
288        if (b.do === 'carry') holds = true
289        if (b.do === 'drop' || b.do === 'transform') holds = false
290      }
291      return ms
292    }
293
294    let current = Math.max(0, this.restUntil - this.now)
295    if (this.run) {
296      // the beat under way: what is left of it, roughly
297      current = Math.max(0, beatSecs(this.run.beat, this.scene, true) * 1000 - (this.now - this.run.start)) + rest(this.run.beat)
298    }
299    current += sceneMs(this.scene, this.index, true)
300    if (this.index < this.scene.beats.length || this.run) {
301      current = Math.min(current, Math.max(0, MAX_SCENE_MS - (this.now - this.sceneStart)))
302    }
303    let total = current
304    for (const scene of this.waiting) total += Math.min(MAX_SCENE_MS, sceneMs(scene, 0, false))
305    return Math.round(total)
306  }
307
308  /**
309   * A tool the agent just started (or one that failed): a small mark over Claude's
310   * head at once, while the director is still staging it. He does not stop what he is doing.
311   */
312  cue(kind: 'read' | 'search' | 'edit' | 'run' | 'web' | 'fail') {
313    const marks = {
314      read: { glyph: '≡', color: P.silver },
315      search: { glyph: '⌕', color: P.silver },
316      edit: { glyph: '✎', color: P.silver },
317      run: { glyph: '»', color: P.silver },
318      web: { glyph: '◌', color: P.silver },
319      fail: { glyph: '!', color: P.coral },
320    }
321    // a failure stays up through the cues of the calls after it
322    if (this.cueMark?.glyph === '!' && kind !== 'fail' && this.cueMark.until > this.now) return
323    this.cueMark = { ...marks[kind], until: this.now + (kind === 'fail' ? 2200 : 1400) }
324    if (kind === 'fail') this.give('sweat')
325  }
326
327  /** Plays `beats` next in the current world, then idles. */
328  interject(beats: Beat[]) {
329    this.waiting.unshift({ ...this.scene, mood: 'neutral', beats, continues: true })
330    this.isPendingUrgent = true
331  }
332
333  /**
334   * The world moves to new scenery (the director's travel beat): whatever he was doing, he stops,
335   * a door appears beside him with a puff, he walks through it and steps out into `scene` from the left.
336   * At home (or already on his way through a door) the new world simply waits to load.
337   */
338  changeScenery(scene: Scene) {
339    this.queue({ ...scene, continues: undefined })
340    if (this.isHome || this.isAway || this.leaving || this.portal) return
341    if (this.carrying) this.putDown()
342    this.run = null
343    this.bubble = null
344    this.lift = 0
345    this.bob = 0
346    this.looking = false
347    const ahead = this.facing > 0 ? this.x + CLAUDE_WIDTH + 6 : this.x - DOOR_WIDTH - 6
348    const room = Math.max(0, this.columns - DOOR_WIDTH - 1)
349    const col = ahead >= 1 && ahead <= room ? Math.round(ahead) : clamp(Math.round(this.facing > 0 ? this.x - DOOR_WIDTH - 6 : this.x + CLAUDE_WIDTH + 6), 1, room)
350    this.portal = { col, phase: 'appear', at: this.now }
351    this.facing = col > this.x ? 1 : -1
352    this.emote = { mood: 'surprised', until: this.now + 900 }
353    const ground = this.rows * 2 - 2
354    this.give('poof', col + Math.floor(DOOR_WIDTH / 2) - 1, ground - DOOR.closed.length)
355    for (let k = 0; k < 4; k++) this.give('sparkle', col + Math.round(this.rand() * DOOR_WIDTH))
356  }
357
358  /** True while a summoned door is up or he is going through it. */
359  get isTravelling(): boolean {
360    return this.portal !== null
361  }
362
363  /** At home, the room he is in and what he is doing there (`hall:admire`); null away. */
364  get homeDoing(): string | null {
365    return this.isHome ? `${this.room}:${this.trip ? 'walking' : this.pastime?.kind ?? 'idle'}` : null
366  }
367
368  /** True at home: the hub, before the first turn or after a long idle. */
369  get home(): boolean {
370    return this.isHome
371  }
372
373  /** The keepsakes on show at home, oldest first. */
374  setHome(items: readonly HubItem[]) {
375    this.homeItems = [...items]
376  }
377
378  /** A new keepsake: if Claude is home it pops in with a sparkle, its label up for a while. */
379  addHomeItem(item: HubItem) {
380    this.homeItems = [...this.homeItems, item]
381    if (!this.isHome) return
382    this.newItem = { at: this.now, item }
383    this.nextPastime = 'admire'
384    const spot = this.homeSpots().find(h => h.item === item)
385    for (let k = 0; k < 5; k++) this.give('sparkle', spot ? spot.col + 3 + Math.round(this.rand() * 4 - 2) : undefined)
386  }
387
388  /** The session opens at home, Claude standing by. */
389  startHome() {
390    this.settleHome()
391    this.x = clamp(this.doorCol() - CLAUDE_WIDTH - 2, 0, this.maxX())
392  }
393
394  /** Idle long enough between turns, and not home yet: time to go home. */
395  get wantsHome(): boolean {
396    return !this.isHome && !this.leaving && !this.portal && this.idleMs >= DOZE_MS - 1500
397  }
398
399  /** Back home through the door: it swings open and he steps out, to nap there soon. */
400  goHome() {
401    if (this.isHome || this.leaving) return
402    this.settleHome()
403    this.x = this.doorSpot()
404    this.facing = -1
405    this.doorOpenUntil = this.now + 900
406    this.isNapping = true
407    this.nextPastime = 'rest'
408  }
409
410  /**
411   * A new turn while home: he sprints for the door and goes through (the turn's world
412   * loads once he is in, so he never keeps it waiting); woken from a nap, he starts up first.
413   * False when he is not home (nothing to do).
414   */
415  leaveHome(): boolean {
416    if (!this.isHome || this.leaving) return false
417    const isWaking = this.isDozing || this.isResting
418    // whatever he was doing at home, he drops it
419    this.pastime = null
420    this.trip = null
421    this.lift = 0
422    this.leaving = { phase: isWaking ? 'wake' : 'walk', at: this.now, isSprint: true }
423    this.emote = isWaking ? { mood: 'surprised', until: this.now + 700 } : null
424    this.wanderTo = null
425    return true
426  }
427
428  /** Why the person is needed now, if they are: Claude stops, turns to them and waves. */
429  get attention(): Attention | null {
430    return this.calling
431  }
432
433  set attention(why: Attention | null) {
434    if (why && !this.calling) this.callingSince = this.now
435    if (!why && this.calling) {
436      // the scene stood still while he waited: it carries on where it was
437      const d = Math.max(0, this.now - this.callingSince)
438      if (this.run) {
439        this.run.start += d
440        this.run.phaseAt += d
441      }
442      this.restUntil += d
443      this.sceneStart += d
444      if (this.bubble) this.bubble.until += d
445    }
446    this.calling = why
447  }
448
449  /** A pat from the person: hearts and a love balloon (and, napping, he wakes up for it). */
450  pet() {
451    if (this.hidden) return
452    this.emote = { mood: 'love', until: this.now + 2600 }
453    this.balloon = { mood: 'love', until: this.now + 2600 }
454    for (let k = 0; k < 3; k++) this.give('heart')
455  }
456
457  /** A commit plants a little flag beside him; a push sends a balloon up. */
458  gitMoment(kind: 'commit' | 'push') {
459    if (kind === 'push') {
460      this.give('balloon')
461      return
462    }
463    const col = this.facing > 0 ? this.x - 4 : this.x + CLAUDE_WIDTH + 1
464    this.pennants = [...this.pennants, clamp(Math.round(col), 0, Math.max(0, this.columns - 3))].slice(-3)
465    this.give('sparkle')
466    this.give('sparkle')
467  }
468
469  private settleHome() {
470    // whatever he was doing in the old world stays there
471    this.run = null
472    this.bubble = null
473    this.emote = null
474    this.lift = 0
475    this.bob = 0
476    this.looking = false
477    this.waiting = []
478    this.load({ setting: 'meadow', mood: 'neutral', props: [], beats: [] })
479    this.isHome = true
480    this.isAway = false
481    this.hidden = false
482    this.leaving = null
483    this.isNapping = false
484    this.idleSince = null // idle from the next step, on its clock
485    this.room = 'hall'
486    this.pastime = null
487    this.trip = null
488    this.pastimeAt = -1
489  }
490
491  /** Where he stands to go through the door, or after stepping out of it. */
492  private doorSpot() {
493    return clamp(this.doorCol() + Math.floor((DOOR_WIDTH - CLAUDE_WIDTH) / 2), 0, this.maxX())
494  }
495
496  private doorCol() {
497    return clamp(Math.round(this.columns * 0.8) - DOOR_WIDTH, 0, Math.max(0, this.columns - DOOR_WIDTH - 1))
498  }
499
500  /** The keepsakes that fit left of the door, newest kept, laid out oldest first. */
501  private homeSpots(): { item: HubItem; col: number }[] {
502    const fit = Math.max(0, Math.floor((this.doorCol() - 6) / (7 + HUB_GAP)))
503    if (fit === 0) return []
504    return this.homeItems.slice(-fit).map((item, i) => ({ item, col: 3 + i * (7 + HUB_GAP) }))
505  }
506
507  /** The summoned door: it appears, he walks to it, it opens, he goes in, and the waiting world loads. */
508  private stepPortal() {
509    const p = this.portal!
510    const since = this.now - p.at
511    if (p.phase === 'appear' && since >= 900) this.portal = { ...p, phase: 'walk', at: this.now }
512    else if (p.phase === 'open' && since >= 500) {
513      this.hidden = true
514      this.portal = { ...p, phase: 'in', at: this.now }
515    } else if (p.phase === 'in' && since >= 600) {
516      this.portal = null
517      const next = this.waiting.findIndex(s => !s.continues)
518      if (next < 0) {
519        this.hidden = false
520        return
521      }
522      this.waiting.splice(0, next)
523      // stepping out of a door, like coming from home: in from the left
524      this.isAway = true
525      this.load(this.waiting.shift()!)
526      this.tellWorld()
527    }
528  }
529
530  private stepLeaving(dt: number) {
531    const l = this.leaving!
532    const next = (phase: 'walk' | 'open' | 'in') => {
533      this.leaving = { ...l, phase, at: this.now }
534    }
535    if (l.phase === 'wake') {
536      // a start: a little jump, eyes wide, then off
537      const t = (this.now - l.at) / 1000
538      this.lift = t < 0.35 ? Math.sin((Math.PI * t) / 0.35) * 2 : 0
539      if (t >= 0.6) next('walk')
540    } else if (l.phase === 'walk') {
541      const isThere = this.walk(this.doorSpot(), dt, l.isSprint ? this.pace.walk * 2.4 : this.pace.walk)
542      if (l.isSprint && !isThere && this.rand() < 12 * dt) this.give('dust')
543      if (isThere) {
544        this.facing = 1
545        next('open')
546      }
547    } else if (l.phase === 'open' && this.now - l.at >= 500) {
548      this.hidden = true
549      next('in')
550    } else if (l.phase === 'in' && this.now - l.at >= 500) {
551      this.leaving = null
552      this.isAway = true
553    }
554  }
555
556  /**
557   * The turn's world as the next scene will find it: the props of the newest queued
558   * world-setting scene, else the current props with their moves, changes and losses.
559   */
560  world(): { setting: Scene['setting']; props: Prop[]; gone: string[]; hat: Hat } {
561    const fresh = [...this.waiting].reverse().find(s => !s.continues)
562    if (fresh) return { setting: fresh.setting, props: fresh.props.map(p => ({ ...p })), gone: [], hat: fresh.hat ?? 'none' }
563    const span = Math.max(1, this.columns - CLAUDE_WIDTH)
564    const props = this.scene.props
565      .filter(p => !this.removed.has(p.id))
566      .map(p => ({ ...p, x: Math.round((this.col(p.x, p.id) / span) * 100) }))
567    return { setting: this.scene.setting, props, gone: [...this.removed], hat: this.scene.hat ?? 'none' }
568  }
569
570  private mood(): Mood {
571    // long idle (no scene for a while, e.g. between turns): he dozes off until the next scene
572    if (this.isDozing || this.isResting) return 'sleepy'
573    return this.emote?.mood ?? this.scene.mood
574  }
575
576  private get isDozing(): boolean {
577    return !this.emote && !this.run && !this.leaving && !this.calling && !this.pastime && !this.trip && this.idleMs >= (this.isHome && this.isNapping ? HOME_DOZE_MS : DOZE_MS)
578  }
579
580  /** Advances the animation to `now` (ms). */
581  step(now: number) {
582    const dt = this.last ? Math.min(0.1, (now - this.last) / 1000) : 0
583    this.last = now
584    this.now = now
585    this.moving = false
586
587    this.dirt = this.dirt.filter(p => p.until > now)
588    for (const p of this.dirt) {
589      p.x += p.vx * dt
590      p.y += p.vy * dt
591      p.vy += 40 * dt
592    }
593    this.effects = this.effects.filter(e => e.until > now)
594    if (this.bubble && this.bubble.until < now) this.bubble = null
595    if (this.react && this.react.until < now) this.react = null
596    if (this.cueMark && this.cueMark.until < now) this.cueMark = null
597    if (this.emote && this.emote.until < now) this.emote = null
598    if (this.balloon && this.balloon.until < now) this.balloon = null
599    if (this.newItem && now - this.newItem.at > 6000) this.newItem = null
600
601    // waiting on the person: he holds still (the scene with him) until they have answered
602    if (this.calling) {
603      this.stepHelpers(dt)
604      return
605    }
606    if (this.leaving) {
607      this.stepLeaving(dt)
608      this.stepHelpers(dt)
609      return
610    }
611    if (this.portal) {
612      this.stepPortal()
613      if (this.portal?.phase === 'walk' && this.walk(this.portal.col + Math.floor((DOOR_WIDTH - CLAUDE_WIDTH) / 2), dt)) {
614        this.facing = 1
615        this.portal = { ...this.portal, phase: 'open', at: now }
616      }
617      this.stepHelpers(dt)
618      return
619    }
620
621    if (this.run && this.isDone(this.run)) {
622      // a breath between actions: longer after handling things or finding something
623      this.restUntil = now + restAfter(this.run.beat, this.rand()) * this.pace.rest
624      this.run = null
625      this.lift = 0
626      this.bob = 0
627      this.looking = false
628    }
629    if (!this.run && (now >= this.restUntil || this.isPendingUrgent)) {
630      const isSceneOver = this.index >= this.scene.beats.length || now - this.sceneStart > MAX_SCENE_MS
631      // news waiting: a scene that has had its say gives way at this pause, as long as his hands are free
632      const isYielding = this.waiting[0]?.isNews === true && this.index >= CUT_AFTER && this.carrying === null
633      const next = this.waiting[0]
634      if (next && (isSceneOver || isYielding || this.isPendingUrgent)) this.load(this.waiting.shift()!)
635      const beat = now >= this.restUntil || this.index === 0 ? this.scene.beats[this.index] : undefined
636      if (beat) {
637        this.index++
638        this.run = { beat, start: now, phase: 0, phaseAt: now }
639        this.idleSince = null
640      } else if (this.idleSince === null) {
641        this.idleSince = now
642      }
643    }
644
645    if (this.run) this.play(this.run, dt)
646    else if (this.isHome && !this.isAway) this.stepHome(dt)
647    else if (now >= this.restUntil) this.idle(dt)
648    this.stepHelpers(dt)
649
650    // what the mood gives off, at its rate
651    const look = MOOD_LOOK[this.mood()]
652    if (look.effect && look.rate && this.rand() < look.rate * dt) this.give(look.effect)
653  }
654
655  private load(scene: Scene) {
656    if (scene.continues) {
657      // same world: keep its props as they now are (moved, transformed, squashed)
658      scene = { ...scene, setting: this.scene.setting, props: this.scene.props, hat: this.scene.hat }
659    } else {
660      scene = { ...scene, props: scene.props.map(p => ({ ...p })) }
661      if (this.isHome || this.isAway) {
662        // out of the door and into the turn's world, stepping in from the left
663        this.isHome = false
664        this.isAway = false
665        this.hidden = false
666        this.x = 2
667        this.facing = 1
668        this.give('dust', 3, this.rows * 2 - 3)
669      }
670      this.pennants = []
671      this.removed.clear()
672      this.placed.clear()
673      this.planted.clear()
674      this.signMotion.clear()
675      this.carrying = null
676    }
677    this.scene = scene
678    if (!scene.continues) this.arrange()
679    this.isPendingUrgent = false
680    this.sceneStart = this.now
681    this.restUntil = 0
682    this.index = 0
683    this.emote = null
684    // a new scene announces its mood once, in a balloon
685    this.balloon = scene.mood === 'neutral' ? null : { mood: scene.mood, until: this.now + 2500 }
686    this.onPlay?.(scene)
687  }
688
689  private secs(run: Run) {
690    return (this.now - run.start) / 1000
691  }
692
693  private isDone(run: Run): boolean {
694    const t = this.secs(run)
695    const b = run.beat
696    switch (b.do) {
697      case 'walk':
698      case 'run': return Math.abs(this.x - this.target(this.col(b.to))) < 0.5
699      case 'say':
700      case 'think': return t >= (b.secs ?? sayTime(b.text))
701      case 'look':
702      case 'squash':
703      case 'carry':
704      case 'type': return run.phase === 2
705      case 'drop': return t >= 0.4
706      case 'transform': return run.phase === 4
707      case 'plant_sign': return run.phase === 2
708      case 'pull_sign': return run.phase === 3
709      case 'read': return t >= (b.secs ?? 3)
710      case 'emote': return t >= (b.secs ?? 3.5)
711      case 'ponder': return t >= (b.secs ?? 3)
712      case 'dance': return t >= (b.secs ?? 2.5)
713      case 'wave': return t >= 1.6
714      case 'shrug': return t >= 1.3
715      case 'jump': return t >= 0.6
716      case 'celebrate': return t >= 1.6
717      case 'dig': return t >= (b.secs ?? 2)
718      case 'wait': return t >= b.secs
719      case 'hat': return t >= 1.4
720      case 'travel': return true // the door takes over: changeScenery ends the beat itself
721    }
722  }
723
724  private play(run: Run, dt: number) {
725    const b = run.beat
726    const t = this.secs(run)
727    switch (b.do) {
728      case 'walk':
729        this.walk(this.col(b.to), dt)
730        break
731      case 'run':
732        if (!this.walk(this.col(b.to), dt, this.pace.walk * 2.4) && this.rand() < 12 * dt) this.give('dust')
733        break
734      case 'say':
735      case 'think':
736        if (run.phase === 0) {
737          const secs = b.secs ?? sayTime(b.text)
738          this.bubble = { text: b.text, kind: b.do, until: this.now + (secs + 4) * 1000 }
739          run.phase = 1
740        }
741        break
742      case 'look':
743      case 'squash':
744      case 'carry':
745      case 'type': {
746        const prop = 'at' in b && b.at ? this.prop(b.at) : undefined
747        if (run.phase === 0) {
748          if ('at' in b && b.at && !prop) {
749            run.phase = 2
750            break
751          }
752          if (!prop || this.approach(prop, dt)) {
753            run.phase = 1
754            run.phaseAt = this.now
755            run.fromX = this.x
756            if (b.do === 'look') this.react = { glyph: b.react ?? '?', until: this.now + 1600 }
757          }
758          break
759        }
760        if (run.phase !== 1) break
761        const p = (this.now - run.phaseAt) / 1000
762        if (b.do === 'look') {
763          this.looking = true
764          if (p >= 1.8) run.phase = 2
765        } else if (b.do === 'type') {
766          this.bob = Math.floor(this.now / 160) % 2
767          if (p >= (b.secs ?? 2.5)) run.phase = 2
768        } else if (b.do === 'carry') {
769          this.lift = Math.min(1, p / 0.4) * 1
770          if (p >= 0.4 && prop) {
771            this.carrying = prop.id
772            run.phase = 2
773          }
774        } else if (prop) {
775          // a hop that lands squarely on top of the bug
776          const d = Math.min(1, p / 0.6)
777          const onTop = this.col(prop.x, prop.id) + propWidth(prop) / 2 - CLAUDE_WIDTH / 2
778          this.x = (run.fromX ?? this.x) + (onTop - (run.fromX ?? this.x)) * d
779          this.facing = onTop >= (run.fromX ?? this.x) ? 1 : -1
780          // the arc comes down onto the bug's back, not the grass beside it
781          const perch = prop.kind === 'sign' ? 0 : PROPS[prop.kind].rows.length
782          this.lift = d < 0.5 ? Math.sin(Math.PI * d) * 6 : Math.max(perch, Math.sin(Math.PI * d) * 6)
783          if (d >= 1) {
784            this.removed.add(prop.id)
785            this.give('poof', this.col(prop.x, prop.id) + 2, this.rows * 2 - 5)
786            run.phase = 2
787          }
788        }
789        break
790      }
791      case 'drop':
792        if (run.phase === 0) {
793          this.putDown()
794          run.phase = 1
795        }
796        break
797      case 'plant_sign': {
798        // walk beside the spot, hammer the post in with dust on each blow, the board types itself in
799        // a free spot near where it was asked for; with no room anywhere, no sign goes up
800        if (run.target === undefined) {
801          const spot = this.freeCol({ id: b.id, kind: 'sign', x: b.x, label: b.label }, this.col(b.x))
802          if (spot === null) {
803            run.phase = 2
804            break
805          }
806          run.target = spot
807        }
808        const at = run.target
809        const width = [...b.label].length + 4
810        const p = (this.now - run.phaseAt) / 1000
811        if (run.phase === 0) {
812          const spot = at - CLAUDE_WIDTH - 1 >= 0 ? at - CLAUDE_WIDTH - 1 : at + width + 1
813          if (!this.walk(spot, dt)) break
814          this.facing = spot < at ? 1 : -1
815          const standing = [...this.planted].filter(id => !this.removed.has(id)).length
816          if (standing >= MAX_PLANTED_SIGNS || this.scene.props.some(q => q.id === b.id && !this.removed.has(q.id))) {
817            run.phase = 2 // no room for another sign (or it already stands): the beat passes
818            break
819          }
820          this.removed.delete(b.id)
821          this.planted.add(b.id)
822          this.scene.props.push({ id: b.id, kind: 'sign', x: b.x, label: b.label })
823          this.placed.set(b.id, at)
824          this.signMotion.set(b.id, { kind: 'grow', start: this.now, ms: 2200 })
825          run.phase = 1
826          run.phaseAt = this.now
827        } else if (run.phase === 1) {
828          const strike = Math.floor(p / 0.35)
829          this.bob = strike % 2
830          if (this.bob === 0 && Math.floor((p - dt) / 0.35) !== strike) {
831            this.give('dust', at + Math.floor(width / 2) - 2, this.rows * 2 - 3)
832          }
833          if (p >= 2.4) {
834            this.bob = 0
835            this.signMotion.delete(b.id)
836            run.phase = 2
837          }
838        }
839        break
840      }
841      case 'pull_sign': {
842        // grab the sign, tug it loose, it rises out of the ground and vanishes in a puff
843        const prop = this.prop(b.at)
844        if (!prop || prop.kind !== 'sign') {
845          run.phase = 3
846          break
847        }
848        const p = (this.now - run.phaseAt) / 1000
849        if (run.phase === 0) {
850          if (this.approach(prop, dt)) {
851            run.phase = 1
852            run.phaseAt = this.now
853            this.signMotion.set(prop.id, { kind: 'shake', start: this.now, ms: 1400 })
854          }
855        } else if (run.phase === 1) {
856          this.bob = Math.floor(p / 0.25) % 2
857          if (p >= 1.4) {
858            this.bob = 0
859            this.lift = 1
860            run.phase = 2
861            run.phaseAt = this.now
862            this.signMotion.set(prop.id, { kind: 'rise', start: this.now, ms: 700 })
863          }
864        } else if (run.phase === 2 && p >= 0.7) {
865          const width = [...(prop.label ?? '?')].length + 4
866          this.give('poof', this.col(prop.x, prop.id) + Math.floor(width / 2) - 1, this.rows * 2 - 10)
867          this.removed.add(prop.id)
868          this.signMotion.delete(prop.id)
869          this.lift = 0
870          run.phase = 3
871        }
872        break
873      }
874      case 'transform': {
875        // lift it, a puff of magic smoke overhead, it is something else now, set it down
876        const prop = this.carrying === b.at ? this.scene.props.find(p => p.id === b.at) : this.prop(b.at)
877        if (!prop) {
878          run.phase = 4
879          break
880        }
881        const p = (this.now - run.phaseAt) / 1000
882        const next = (phase: number) => {
883          run.phase = phase
884          run.phaseAt = this.now
885        }
886        if (run.phase === 0) {
887          if (this.carrying === prop.id || this.approach(prop, dt)) next(1)
888        } else if (run.phase === 1) {
889          this.lift = Math.min(1, p / 0.4)
890          if (p >= 0.4) {
891            this.carrying = prop.id
892            next(2)
893          }
894        } else if (run.phase === 2 && p >= 0.7) {
895          const overhead = (this.spriteTop ?? this.rows * 2 - 9) - 3
896          this.give('poof', Math.round(this.x + CLAUDE_WIDTH / 2) - 1, overhead)
897          for (let k = 0; k < 4; k++) this.give('sparkle')
898          prop.kind = b.into
899          prop.label = b.label
900          next(3)
901          this.tellWorld()
902        } else if (run.phase === 3 && p >= 1.0) {
903          this.putDown()
904          next(4)
905        }
906        break
907      }
908      case 'hat':
909        // a puff over his head, and he wears the new hat
910        if (run.phase === 0) {
911          const overhead = (this.spriteTop ?? this.rows * 2 - 9) - 2
912          this.give('poof', Math.round(this.x + CLAUDE_WIDTH / 2) - 1, overhead)
913          for (let k = 0; k < 3; k++) this.give('sparkle')
914          this.scene.hat = b.hat
915          this.emote = { mood: 'happy', until: this.now + 1200 }
916          run.phase = 1
917          this.tellWorld()
918        }
919        break
920      case 'travel': {
921        const props = b.props.map(p => ({ ...p }))
922        const first = props[0]
923        this.changeScenery({
924          setting: b.setting, mood: 'neutral', props, hat: b.hat ?? this.scene.hat,
925          beats: [{ do: 'emote', mood: 'happy', secs: 1.5 }, ...(first ? [{ do: 'look' as const, at: first.id }] : [])],
926        })
927        break
928      }
929      case 'emote':
930        if (run.phase === 0) {
931          this.emote = { mood: b.mood, until: this.now + (b.secs ?? 3.5) * 1000 }
932          this.balloon = b.mood === 'neutral' ? null : { mood: b.mood, until: this.emote.until }
933          run.phase = 1
934        }
935        if (b.mood === 'surprised') this.lift = t < 0.35 ? Math.sin((Math.PI * t) / 0.35) * 2 : 0
936        if (b.mood === 'confused') this.facing = Math.floor(t / 0.6) % 2 === 0 ? 1 : -1
937        break
938      case 'dance':
939        this.lift = Math.abs(Math.sin(Math.PI * t * 2)) * 2
940        this.facing = Math.floor(t * 2) % 2 === 0 ? 1 : -1
941        if (this.rand() < 3 * dt) this.give('sparkle')
942        break
943      case 'jump':
944        this.lift = Math.sin(Math.PI * Math.min(1, t / 0.6)) * 4
945        break
946      case 'celebrate':
947        this.lift = Math.abs(Math.sin((Math.PI * t) / 0.5)) * 3
948        if (this.rand() < 0.5) this.give('sparkle')
949        break
950      case 'dig': {
951        // a stroke every 0.5 s: blade down (crouched), then up, flinging a clod over his shoulder
952        const isDown = (t % 0.5) < 0.25
953        this.bob = isDown ? 1 : 0
954        if (!isDown && (t - dt) % 0.5 < 0.25) for (let k = 0; k < 3; k++) this.throwDirt()
955        break
956      }
957      case 'ponder': {
958        // stands still, looks one way, then turns to look the other way
959        const secs = b.secs ?? 3
960        this.looking = Math.floor(t / 1.2) % 2 === 1
961        if (run.phase === 0 && t > secs / 2) {
962          this.facing = this.facing > 0 ? -1 : 1
963          run.phase = 1
964        }
965        break
966      }
967      case 'read':
968      case 'wave':
969      case 'shrug':
970      case 'wait':
971        break
972    }
973  }
974
975  private idle(dt: number) {
976    this.bob = 0
977    if (this.mood() === 'sleepy') return
978    if (this.wanderTo !== null) {
979      if (this.walk(this.wanderTo, dt * 0.5)) this.wanderTo = null
980    } else if (this.now > this.wanderAt) {
981      this.wanderAt = this.now + 3000 + this.rand() * 4000
982      if (this.rand() < 0.6) this.wanderTo = clamp(this.x + (this.rand() * 16 - 8), 0, this.maxX())
983    }
984  }
985
986  private get isResting(): boolean {
987    return this.isHome && this.pastime?.kind === 'rest' && this.pastime.phase === 'do'
988  }
989
990  private get isWatching(): boolean {
991    return this.isHome && this.pastime?.kind === 'movie' && this.pastime.phase === 'do'
992  }
993
994  private get isTending(): boolean {
995    return this.isHome && this.pastime?.kind === 'tend' && this.pastime.phase === 'do'
996  }
997
998  /** At home with nothing to play: a pastime, the walk to its room, or a pause between two. */
999  private stepHome(dt: number) {
1000    this.bob = 0
1001    if (this.trip) return this.stepTrip(dt)
1002    const p = this.pastime
1003    if (!p) {
1004      if (this.pastimeAt < 0) this.pastimeAt = this.now + 4000
1005      if (this.now < this.pastimeAt) return
1006      const kind = this.pickPastime()
1007      this.pastime = { kind, phase: 'go', until: 0, next: 0, flower: Math.floor(this.rand() * FLOWER_COUNT) }
1008      const room = PASTIMES[kind].room
1009      if (room !== this.room) this.trip = { to: room, dir: ROOMS.indexOf(room) > ROOMS.indexOf(this.room) ? 1 : -1 }
1010      return
1011    }
1012    if (p.phase === 'go') {
1013      const spot = this.pastimeSpot(p)
1014      if (spot === null) return this.endPastime() // no room for it at this width
1015      if (!this.walk(spot, dt * HOME_PACE)) return
1016      const [lo, hi] = PASTIMES[p.kind].secs
1017      p.phase = 'do'
1018      p.until = this.now + (lo + this.rand() * (hi - lo)) * 1000
1019      p.next = this.now
1020      if (p.kind === 'movie' || p.kind === 'tend') this.facing = 1
1021      return
1022    }
1023    if (this.now >= p.until) return this.endPastime()
1024    switch (p.kind) {
1025      case 'admire': return this.admire(dt)
1026      case 'tend': return this.tend(p, dt)
1027      case 'movie':
1028        // now and then the film gets a reaction
1029        if (this.now >= p.next) {
1030          p.next = this.now + 7000 + this.rand() * 7000
1031          if (p.next > this.now + 7500) this.react = { glyph: ['!', '?', '♪', '♥'][Math.floor(this.rand() * 4)]!, until: this.now + 1400 }
1032        }
1033        return
1034      case 'rest':
1035        return
1036    }
1037  }
1038
1039  private endPastime() {
1040    const p = this.pastime
1041    if (p?.kind === 'tend') this.flowerTended(p.flower)
1042    this.lastPastime = p?.kind ?? null
1043    this.pastime = null
1044    this.lift = 0
1045    this.pastimeAt = this.now + 3000 + this.rand() * 5000
1046  }
1047
1048  private pickPastime(): Pastime {
1049    const asked = this.nextPastime
1050    this.nextPastime = null
1051    if (asked) return asked
1052    const options = (Object.keys(PASTIMES) as Pastime[]).filter(k => k !== this.lastPastime && (k !== 'admire' || this.homeItems.length))
1053    const total = options.reduce((sum, k) => sum + PASTIMES[k].weight, 0)
1054    let r = this.rand() * total
1055    for (const k of options) if ((r -= PASTIMES[k].weight) < 0) return k
1056    return options[0] ?? 'tend'
1057  }
1058
1059  /** Out one side of the room and in from the other side of the next, until he is where the pastime is. */
1060  private stepTrip(dt: number) {
1061    const t = this.trip!
1062    if (this.room === t.to) {
1063      this.trip = null // in from the edge: the pastime's own walk takes him to its spot
1064      return
1065    }
1066    this.facing = t.dir
1067    this.moving = true
1068    this.x += t.dir * this.pace.walk * HOME_PACE * dt
1069    const isOut = t.dir > 0 ? this.x >= this.columns + 1 : this.x <= -CLAUDE_WIDTH - 1
1070    if (!isOut) return
1071    this.room = ROOMS[ROOMS.indexOf(this.room) + t.dir]!
1072    this.x = t.dir > 0 ? -CLAUDE_WIDTH : this.columns
1073  }
1074
1075  /** Where he stands for a pastime in its room, or null when the room has no space for it. */
1076  private pastimeSpot(p: { kind: Pastime; flower: number }): number | null {
1077    const den = this.denLayout()
1078    switch (p.kind) {
1079      case 'admire': return clamp(this.x, 0, this.maxX())
1080      case 'tend': {
1081        const col = this.flowerCol(p.flower)
1082        return col === null ? null : clamp(col - CLAUDE_WIDTH + 1, 0, this.maxX())
1083      }
1084      case 'movie': return den.tv === null ? null : den.couch + 3
1085      case 'rest': return den.bed === null ? null : den.bed + BED_HEAD_X
1086    }
1087  }
1088
1089  /** The den's furniture by column, left to right, each null where it would not fit before the door. */
1090  private denLayout(): { bed: number | null; couch: number; tv: number | null } {
1091    const room = this.doorCol() - 2
1092    const bed = 2
1093    const couch = bed + BED.rows[0]!.length + 4
1094    const tv = couch + COUCH_BACK.rows[0]!.length + 5
1095    return { bed: bed + BED.rows[0]!.length <= room ? bed : null, couch, tv: tv + TV.rows[0]!.length <= room ? tv : null }
1096  }
1097
1098  private flowerCol(i: number): number | null {
1099    const start = Math.max(3, Math.round(this.doorCol() * 0.2))
1100    const col = start + i * FLOWER_STEP
1101    return col + FLOWER_STAGES[0]![0]!.length <= this.doorCol() - 2 ? col : null
1102  }
1103
1104  /** At the keepsakes: strolls from one to another, the label up as he stands by it. */
1105  private admire(dt: number) {
1106    if (this.wanderTo !== null) {
1107      if (this.walk(this.wanderTo, dt * HOME_PACE)) this.wanderTo = null
1108      return
1109    }
1110    if (this.now < this.wanderAt) return
1111    this.wanderAt = this.now + 3000 + this.rand() * 4000
1112    const spots = this.homeSpots()
1113    if (!spots.length) return
1114    const s = spots[Math.floor(this.rand() * spots.length)]!
1115    this.wanderTo = clamp(s.col + 3 - Math.floor(CLAUDE_WIDTH / 2) + (this.rand() < 0.5 ? -9 : 9), 0, this.maxX())
1116  }
1117
1118  /** In the garden: kneels by a flower with the can, a few drops falling, then moves on to the next. */
1119  private tend(p: { next: number; flower: number }, dt: number) {
1120    const spot = this.flowerCol(p.flower)
1121    const at = spot === null ? null : clamp(spot - CLAUDE_WIDTH + 1, 0, this.maxX())
1122    if (at !== null && Math.abs(this.x - at) > 0.5) {
1123      this.walk(at, dt * HOME_PACE)
1124      p.next = this.now
1125      return
1126    }
1127    this.facing = 1
1128    this.bob = Math.floor(this.now / 700) % 2
1129    if (this.rand() < 9 * dt) {
1130      const spout = Math.round(this.x) + CLAUDE_WIDTH + 3
1131      const top = this.spriteTop ?? this.rows * 2 - 9
1132      this.dirt.push({ x: spout, y: top + 3, vx: 1 + this.rand(), vy: 1, until: this.now + 500, color: P.sky })
1133    }
1134    if (this.now - p.next >= WATER_SECS * 1000) {
1135      // this one is watered: it grows a stage; on to another
1136      this.flowerTended(p.flower)
1137      let next = Math.floor(this.rand() * FLOWER_COUNT)
1138      if (next === p.flower) next = (next + 1) % FLOWER_COUNT
1139      p.flower = this.flowerCol(next) === null ? 0 : next
1140      p.next = this.now
1141    }
1142  }
1143
1144  private flowerTended(i: number) {
1145    this.ensureFlowers()
1146    const f = this.flowers[i]
1147    if (!f) return
1148    if (f.stage < FLOWER_STAGES.length - 1) f.stage++
1149    else if (this.rand() < 0.25) {
1150      // a bloom that has had its day is picked, and a new seed goes in
1151      f.stage = 0
1152      f.color = Math.floor(this.rand() * FLOWER_COLORS.length)
1153    }
1154  }
1155
1156  private ensureFlowers() {
1157    while (this.flowers.length < FLOWER_COUNT) {
1158      this.flowers.push({ stage: Math.floor(this.rand() * FLOWER_STAGES.length), color: Math.floor(this.rand() * FLOWER_COLORS.length) })
1159    }
1160  }
1161
1162  /** Walks beside a prop and turns to it; true once there. */
1163  private approach(prop: Prop, dt: number): boolean {
1164    const at = this.col(prop.x, prop.id)
1165    const side = this.x + CLAUDE_WIDTH / 2 < at ? -1 : 1
1166    const spot = side < 0 ? at - CLAUDE_WIDTH - 1 : at + propWidth(prop) + 1
1167    if (!this.walk(spot, dt)) return false
1168    this.facing = side < 0 ? 1 : -1
1169    return true
1170  }
1171
1172  private target(col: number) {
1173    return clamp(col, 0, this.maxX())
1174  }
1175
1176  /** Moves toward `target`; true once there. */
1177  private walk(target: number, dt: number, speed = this.pace.walk): boolean {
1178    target = this.target(target)
1179    const d = target - this.x
1180    if (Math.abs(d) < 0.5) {
1181      this.x = target
1182      return true
1183    }
1184    this.facing = d > 0 ? 1 : -1
1185    this.moving = true
1186    this.x += Math.sign(d) * Math.min(Math.abs(d), speed * dt)
1187    return false
1188  }
1189
1190  /** Starts an effect near the head (or at a given pixel). */
1191  private give(kind: Effect['kind'], x?: number, y?: number) {
1192    // the sprite's real top edge as last drawn: a slumped or crouched figure is a pixel lower
1193    const head = this.spriteTop ?? this.rows * 2 - 2 - CLAUDE_HEIGHT - Math.round(this.lift)
1194    const mid = this.x + CLAUDE_WIDTH / 2
1195    const now = this.now
1196    switch (kind) {
1197      case 'sparkle': {
1198        const color = [P.yellow, P.white, P.coral, P.sky][Math.floor(this.rand() * 4)]!
1199        this.effects.push({
1200          kind, color, start: now, until: now + 450,
hooks/usage.ts 49 lines
1// What the director's Sonnet calls cost, in tokens: per session and per day.
2
3import type { ModelUsage } from 'claude-code'
4
5export type Tally = { calls: number; input: number; output: number; cacheRead: number; cacheWrite: number }
6
7export const emptyTally = (): Tally => ({ calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 })
8
9/** Adds one call's usage to a tally (a new one; the old is left as it was). */
10export function addUsage(t: Tally, u: ModelUsage): Tally {
11  return {
12    calls: t.calls + 1,
13    input: t.input + u.input_tokens,
14    output: t.output + u.output_tokens,
15    cacheRead: t.cacheRead + u.cache_read_input_tokens,
16    cacheWrite: t.cacheWrite + u.cache_creation_input_tokens,
17  }
18}
19
20/** Reads a stored tally back, tolerating anything missing or malformed. */
21export function asTally(v: unknown): Tally {
22  const o = (typeof v === 'object' && v !== null ? v : {}) as Record<string, unknown>
23  const n = (k: keyof Tally) => (typeof o[k] === 'number' && Number.isFinite(o[k]) ? (o[k] as number) : 0)
24  return { calls: n('calls'), input: n('input'), output: n('output'), cacheRead: n('cacheRead'), cacheWrite: n('cacheWrite') }
25}
26
27/** Today's date as YYYY-MM-DD in local time: the key the daily tally is stored under. */
28export function today(now = new Date()): string {
29  const pad = (n: number) => String(n).padStart(2, '0')
30  return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
31}
32
33const fmt = (n: number) => (n >= 10_000 ? `${(n / 1000).toFixed(1)}k` : String(n))
34
35export function formatStats(model: string, session: Tally, day: Tally, dayKey: string): string {
36  const row = (label: string, t: Tally) =>
37    `| ${label} | ${t.calls} | ${fmt(t.input)} | ${fmt(t.output)} | ${fmt(t.cacheRead)} | ${fmt(t.cacheWrite)} |`
38  return [
39    `Agent comic director (model: ${model}): tokens spent on staging scenes.`,
40    '',
41    '| | calls | input | output | cache read | cache write |',
42    '|---|---|---|---|---|---|',
43    row('this session', session),
44    row(`today (${dayKey})`, day),
45    '',
46    'Idle time costs nothing: scenes are only requested while Claude works.',
47  ].join('\n')
48}
49
hooks/places.ts 71 lines
1// The comic's world beyond the hub: places other plugins add through $.comic, on a ring that
2// starts at the hub. Wooden signs at the band's edges lead from one stop to the next.
3
4import type { ComicKit, ComicPlace, ComicSide, ComicStop } from '../types'
5import { cellText } from './canvas'
6import { clip } from './scene'
7import { HATS_ART, P, TINTS, claude } from './sprites'
8import { SIGN } from './stage'
9import type { Pose } from './sprites'
10
11export const COMIC_VERSION = 1
12export const HUB: ComicStop = { id: null, name: 'Hub' }
13export const ALIVE_MS = 5000 // a place silent this long has stopped drawing: Claude comes home
14const MAX_NAME = 16
15const MAX_ID = 64
16
17/** A place as a plugin gave it, made safe to draw; null when it is no place. */
18export function asPlace(v: unknown): ComicPlace | null {
19  const p = v as { id?: unknown; name?: unknown } | null
20  if (!p || typeof p.id !== 'string' || typeof p.name !== 'string') return null
21  const id = p.id.trim().slice(0, MAX_ID)
22  const name = clip(cellText(p.name.trim()), MAX_NAME)
23  return id && name ? { id, name } : null
24}
25
26/** The ring with `place` on it (renamed if it was), the hub first, the places by id so signs never jump. */
27export function withPlace(ring: readonly ComicStop[], place: ComicPlace): ComicStop[] {
28  const places = ring.filter((s): s is ComicPlace => s.id !== null && s.id !== place.id)
29  return [HUB, ...[...places, place].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))]
30}
31
32/** The ring without the place `id`. */
33export function withoutPlace(ring: readonly ComicStop[], id: string): ComicStop[] {
34  return [HUB, ...ring.filter(s => s.id !== null && s.id !== id)]
35}
36
37/**
38 * The signs from `here`: its neighbours on the ring. With the hub alone there are none; with one
39 * place, a single sign each way between them, so no stop shows the same sign on both sides.
40 */
41export function routeOf(ring: readonly ComicStop[], here: string | null): { left: ComicStop | null; right: ComicStop | null } {
42  const stops = ring.length ? ring : [HUB]
43  const i = Math.max(0, stops.findIndex(s => s.id === here))
44  if (stops.length < 2) return { left: null, right: null }
45  if (stops.length === 2) return i === 0 ? { left: null, right: stops[1]! } : { left: stops[0]!, right: null }
46  return { left: stops[(i - 1 + stops.length) % stops.length]!, right: stops[(i + 1) % stops.length]! }
47}
48
49/** The side Claude comes in from after following the sign on side `dir`: the other one. */
50export const enteringFrom = (dir: ComicSide): ComicSide => (dir === 1 ? -1 : 1)
51
52/** The comic's art as plain data, for places to draw the same Claude. */
53export function kit(): ComicKit {
54  const pose = (p: Partial<Pose>): string[] =>
55    claude({ face: 'ahead', back: 'down', front: 'down', walk: 0, airborne: false, crouch: false, left: false, ...p })
56  return {
57    claude: {
58      stand: pose({}),
59      blink: pose({ face: 'blink' }),
60      happy: pose({ face: 'happy', back: 'up', front: 'up' }),
61      asleep: pose({ face: 'shut', crouch: true }),
62      wave: pose({ face: 'happy', front: 'up' }),
63      walk: [0, 1, 2, 3].map(walk => pose({ face: 'right', walk })),
64      palette: { ...TINTS.normal },
65    },
66    hats: Object.fromEntries(Object.entries(HATS_ART).map(([k, h]) => [k, { rows: [...h.rows], palette: { ...h.palette }, sit: h.sit }])),
67    sign: { ...SIGN },
68    palette: { ...P },
69  }
70}
71