SLOPSHOPPER

clawd-rpg

Clawd, Claude Code's crab, goes on an adventure above your prompt: your session's work becomes his walks, battles and rest stops, and he levels up as you use…

newbandguardcommandprocesstimer
v1.0.0MITupdated 2026-10-09KaiC5504/clawd-rpg
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd-rpg
› 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 › /rpg ⎿ clawd-rpg: Clawd's adventure is hidden. /rpg brings it back. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

clawd-rpg

Clawd, Claude Code's crab, goes on an adventure in the band above your prompt. Your session plays the game: reading is walking, an edit is a battle, a failing test is the enemy's turn, a passing run is the finisher, subagents join his party, and a finished turn is a victory with loot named after the file you worked on. Between turns he walks off to rest: the pier, a campfire, the inn. He levels up the more you use Claude Code, and new zones open as he does.

<img src="docs/readme/battle.svg" alt="An edit brings a goblin down from the trees and Clawd strikes it with CLAW STRIKE; a failing test is the enemy's turn (COUNTER ✗2) and he's knocked back; a passing run is the ALL-OUT ATTACK that finishes it. The HUD sits bottom-right: level, EXP, HP for context left and MP for usage left.">

Unofficial fan mod. Clawd belongs to Anthropic. Sibling of clawd-bar.

Install

claude plugin marketplace add KaiC5504/clawd-rpg
claude plugin install clawd-rpg@clawd-rpg

Then start a new Claude Code session, or run /reload-plugins in the one you have open. If you also use clawd-bar, hide its band with /clawd so the two don't stack.

Built and tested on Claude Code 2.1.294, in Windows Terminal at about 180 columns. /rpg doctor checks the setup.

Update: claude plugin marketplace update clawd-rpg, then claude plugin update clawd-rpg@clawd-rpg, then restart. Uninstall: claude plugin uninstall clawd-rpg@clawd-rpg. What changed is in the changelog.

Commands

CommandWhat it does
/rpgHide the band, or bring it back (remembered across sessions)
/rpg statsHis level and EXP, the zone and how close its boss is, what's open, and every foe he's met
/rpg demoPlays a scripted day on the road above the prompt: every beat, zone and rest spot. Run it again to stop. It never touches your progress
/rpg doctorIs the band up and painting, is the save healthy, is clawd-bar's band on too

How your session plays

Claude CodeOn the road
Thinking, reading, searchingHe walks through the zone; many calls in a few seconds and he hurries
An edit, a new file, a command, an MCP toolA foe drops in and each call is a skill: CLAW STRIKE, SCRIBE SLASH, SHELL SHOCK, LINK BEAM. Two to four hits win the battle
A failing test or a tool errorThe enemy's turn: COUNTER ✗N, he's knocked back, and an elite stands for the failure
A passing test runALL-OUT ATTACK finishes the foe
A subagent startsA mini Clawd joins the party: Explore is a scout, Plan a mage, general-purpose a knight. Its edits are its own attacks, and when it's done its class attack lands
The turn endsVictory: a chest, loot named after the file you edited most, EXP
Waiting for youHe walks to the pier and fishes
A minute idleOn to the zone's rest spot: a campfire, the Dungeon's crystal room, Neon City's ramen stall
Ten minutes idle, or /compactThe inn: asleep in bed, HP refilling
Claude needs youHe turns to you with a !
You interruptHe skids to a stop and the foe flees
Usage limit used upMP is empty and he trudges
A task listThe quest line in the top-left: ☐ 2/5 fix login

<img src="docs/readme/party.svg" alt="Subagents drop in behind Clawd as mini Clawds in his orange, a knight with a helmet and shield and a mage with a hat and staff, each named as it joins; when the mage's subagent finishes, its ARCANE BOLT flies at the foe.">

Levels, zones and bosses

EXP comes from deeds, not tokens: a battle won +10, an elite (a failing test, then fixed) +25, a boss or a turn with a party +60, any finished turn +5. Each level needs 50 + 25 × level.

Every zone has a meter of 12 battles. When it's full, the next turn that fights is a boss fight. Beat the boss and a gate stands on the road; he walks through it into the next zone he has unlocked, and once he has seen them all the road comes back round to the forest.

ZoneOpensFoesBossRest spot
Enchanted ForestLv.1goblins, mushroomsTreantcampfire
DungeonLv.3slimes, skeletonsMerge Hydracrystal room
Neon CityLv.6drones, glitch bugsMechramen stall

Progress is shared by every session and repo.

<img src="docs/readme/boss.svg" alt="The Treant fills the band with its health along the top and ★ BOSS · TREANT in the corner; it falls, the chest gives Treant Heartwood and LEVEL UP flashes over the HUD, then Clawd walks through a stone gate with a purple portal into the Dungeon.">

<img src="docs/readme/rest.svg" alt="Between turns: Clawd fishes at the end of a pier under the moon (waiting for you…), turns to you with a ! when Claude needs you, then after a minute sits on a log by the campfire with the tent on the right (idle · warming up).">

<img src="docs/readme/zones.svg" alt="The Dungeon's brick halls and torches with a slime; the crystal room where he sits by the floating save crystal; Neon City's towers and rain with a glitch bug; the ramen stall where he sits at the counter with a steaming bowl; and the inn, asleep under a red blanket by the fireplace (z Z · HP refilling).">

The band

Five rows of half-block pixels across the whole terminal, redrawn about six times a second. The HUD sits bottom-right: Lv.7 ▰▰▰▱▱ HP █████ MP ████ ⚑ repo/branch. HP is the context left and MP the 5-hour usage left. As the terminal narrows the HUD drops the repo, then MP; below 100 columns the band shows only Clawd and the place he's in, and below 40 it hides.

The desktop app's Code tab and the phone app play short loops of the same scene.

Credits

Clawd is Anthropic's. This is an unofficial fan project, not made or endorsed by Anthropic. Every picture here is drawn by the plugin's own code (bun run tools/readme-art.ts).

Source 31 files
hooks/register.tsx 460 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { encodeCells } from './rpg/cells'
5import { DEMO, DEMO_MS, demoScene } from './rpg/demo'
6import { DESKTOP_COLS, DESKTOP_FRAME_MS, desktopKey, desktopLoop, resumeAt } from './rpg/desktop'
7import { NO_STORY, beatOf, passGate, step, withLevelUp } from './rpg/director'
8import type { HookPayload, Story } from './rpg/director'
9import { HIDDEN_BELOW, frame } from './rpg/frame'
10import type { Scene } from './rpg/frame'
11import { ROWS } from './rpg/grid'
12import { FRESH, bossDue, gain, loadProgress } from './rpg/progress'
13import type { Award, Progress } from './rpg/progress'
14import { doctorText, statsText } from './rpg/report'
15import type { DoctorFacts } from './rpg/report'
16import { FRAME_MS, GATE_AFTER, GATE_AHEAD, gateAt, gateClear, goalOf, moveRoad, reanchor, zoneAt } from './rpg/road'
17import type { Motion } from './rpg/road'
18import { statsFrom } from './rpg/stats'
19import type { Usage } from './rpg/stats'
20import type { RpgStats } from '../types'
21
22const RASTER_KEY = 'rpg'
23// The width the band was last drawn at, for placing a gate before the band is up.
24const USUAL_WIDTH = 179
25const STATS_MS = 1000
26// The desktop's band scales to the window; this is about how wide a column draws there.
27const DESKTOP_COL_PX = 8
28const DESKTOP_MAX_PX = 1400
29
30const isHidden = atom({ plugin: 'clawd-rpg', key: 'isHidden' } as const, false)
31const stats = atom({ plugin: 'clawd-rpg', key: 'stats' } as const, statsFrom(null, '', FRESH))
32const story = atom({ plugin: 'clawd-rpg', key: 'story' } as const, NO_STORY)
33
34// Module state: a hot reload starts it over, while the atoms live on in $.state.
35let isReady = false
36let place = ''
37let progress: Progress = FRESH
38let road = { distance: 0, isWorking: false, frames: 0, width: 0 }
39let painting: { requestId: string; width: number; painted: string } | null = null
40let frameTimer: { cancel: () => void } | null = null
41// A frame still on its way to the terminal: the next tick skips rather than piling blits up.
42let isBlitting = false
43// When /rpg demo started, while it plays, and the band's frames since. The band plays the demo by
44// frames, as the live road walks: the host answers each frame a little late, and a scene timed by
45// the clock would step 1, 1, 2 and hitch.
46let demoStartedAt: number | null = null
47let demoFrames = 0
48// For /rpg doctor: the terminal band's last width and when its last frame went out.
49let lastWidth: number | null = null
50let lastBlitAt: number | null = null
51// The desktop's loop as built, and what it shows, so a redraw neither rebuilds nor restarts it.
52let desktop: { key: string; svg: string; ms: number; startedAt: number } | null = null
53let isOnDesktop = false
54// The key a redraw was last asked for, so a surface that stopped drawing isn't asked every second.
55let askedFor = ''
56
57function stopPainting(): void {
58  frameTimer?.cancel()
59  frameTimer = null
60  painting = null
61}
62
63// A story saved by an older build may lack fields this one reads.
64const storyNow = async ($: EngineInterface): Promise<Story> => ({ ...NO_STORY, ...(await read($, story)) })
65
66async function refreshStats($: EngineInterface): Promise<void> {
67  const usage = (await $.session.usage().then(u => u, () => null)) as Usage | null
68  const next = statsFrom(usage, place, progress)
69  if (JSON.stringify(next) !== JSON.stringify(await read($, stats))) await update($, stats, () => next)
70}
71
72async function refreshPlace($: EngineInterface): Promise<void> {
73  const branch = await $.process.run(['git', 'branch', '--show-current'], { timeoutMs: 5000 }).catch(() => null)
74  const folder = (await $.session.cwd().catch(() => '')).replace(/\\/g, '/').split('/').pop() ?? ''
75  const name = branch?.exitCode === 0 && branch.stdout.trim() ? `${folder}/${branch.stdout.trim()}` : folder
76  if (name !== place) {
77    place = name
78    await refreshStats($)
79  }
80}
81
82// Progress lives in the store, shared by every session: it is read fresh before each change so
83// two sessions earning EXP at once don't overwrite each other. null: a newer build saved it, so
84// this session plays on in memory and leaves the save alone.
85async function loadSaved($: EngineInterface): Promise<Progress | null> {
86  const { progress: loaded, backup, isNewer } = loadProgress(await $.store.get('progress'))
87  if (isNewer) return null
88  if (backup !== undefined) {
89    // The first backup is the one most likely to hold real progress.
90    if ((await $.store.get('progressBackup')) === undefined) await $.store.set('progressBackup', backup)
91    await $.store.set('progress', loaded)
92  }
93  return loaded
94}
95
96async function saveWith($: EngineInterface, change: (p: Progress) => Progress): Promise<void> {
97  const saved = await loadSaved($)
98  progress = change(saved ?? progress)
99  if (saved) await $.store.set('progress', progress)
100}
101
102async function earn($: EngineInterface, awards: Award[]): Promise<number> {
103  let levelsUp = 0
104  await saveWith($, p => {
105    for (const a of awards) {
106      const r = gain(p, a)
107      p = r.progress
108      levelsUp += r.levelsUp
109    }
110    return p
111  })
112  return levelsUp
113}
114
115async function ensureReady($: EngineInterface): Promise<void> {
116  if (isReady) return
117  isReady = true
118  if ((await $.store.get('isHidden')) === true) await update($, isHidden, () => true)
119  progress = (await loadSaved($)) ?? FRESH
120  road.distance = progress.roadPos
121  $.clock.every(STATS_MS, () => void refreshStats($).then(() => walkUnseen($)).then(() => refreshDesktop($)))
122  await refreshPlace($).catch(() => undefined)
123  await refreshStats($)
124}
125
126// Hooks for parallel tool calls run side by side: each event steps the story only once the one
127// before it has landed, or both would step from the same story and one would be lost.
128let queue: Promise<void> = Promise.resolve()
129
130function observe($: EngineInterface, event: string, payload: HookPayload): Promise<void> {
131  const run = queue.then(() => observeNow($, event, payload))
132  queue = run.catch(() => undefined)
133  return run
134}
135
136async function observeNow($: EngineInterface, event: string, payload: HookPayload): Promise<void> {
137  await ensureReady($)
138  // Another session may have levelled him up or cleared a zone since.
139  if (event === 'UserPromptSubmit') progress = (await loadSaved($)) ?? progress
140  const now = await $.clock.now()
141  const before = await storyNow($)
142  const roadAt = Math.floor(road.distance)
143  // Where he stands on the road is where a trip to the rest spots sets out from; the zone and a
144  // full zone meter come from his progress.
145  const { story: next, awards } = step(before, event, { ...payload, roadAt, zone: progress.zone, boss: bossDue(progress) }, now)
146  let told = next
147  if (awards.length > 0) {
148    const levelsUp = await earn($, awards)
149    if (levelsUp > 0) told = withLevelUp(told, progress.level, now)
150    await refreshStats($)
151  }
152  // A cleared zone: he walks out through a gate, never a cut.
153  if ((event === 'TurnEnded' || event === 'UserPromptSubmit') && told.zone !== progress.zone) {
154    const from = event === 'TurnEnded' && told.trip ? told.trip.origin + GATE_AFTER : roadAt + GATE_AHEAD
155    told = passGate(told, progress.zone, gateAt(told, now, road.width || USUAL_WIDTH, from))
156  } else if (event === 'TurnEnded') {
157    told = gateClear(told, now, road.width || USUAL_WIDTH)
158  }
159  if (told !== before) await update($, story, () => told)
160  if (event === 'TurnEnded' && Math.floor(road.distance) !== progress.roadPos) {
161    await saveWith($, p => ({ ...p, roadPos: Math.floor(road.distance) }))
162  }
163}
164
165// Read once per frame, so the road and the picture are of the same moment.
166type Live = { story: Story; stats: RpgStats }
167const liveNow = async ($: EngineInterface): Promise<Live> => {
168  const [st, s] = await Promise.all([storyNow($), read($, stats)])
169  return { story: st, stats: s }
170}
171
172// He walks while Claude works and nothing stands in his way, and between turns heads for a rest spot.
173// `advance`: this is a frame of the timer, so the road moves; a redraw only looks.
174// The motion's `distance` is where to draw him.
175function motionNow(live: Live, width: number, now: number, advance: boolean): Motion {
176  const frames = advance ? ++road.frames : road.frames
177  const distance = road.width ? reanchor(live.story, now, road.distance, road.width, width) : road.distance
178  const motion = moveRoad(live.story, now, distance, width, frames, road.isWorking, live.stats.mp <= 0)
179  if (!advance) return { ...motion, distance }
180  road.distance = motion.distance
181  road.width = width
182  return motion
183}
184
185function cellsNow(live: Live, width: number, now: number, motion: Motion): string {
186  if (demoStartedAt !== null) return encodeCells(frame(demoScene(demoFrames * FRAME_MS, width).scene))
187  return encodeCells(frame({ width, t: now, distance: motion.distance, isWalking: motion.isWalking, ...live, trudge: live.stats.mp <= 0, legMs: motion.legMs }))
188}
189
190async function paintFrame($: EngineInterface): Promise<void> {
191  const p = painting
192  if (!p || isBlitting) return
193  isBlitting = true
194  try {
195    const now = await $.clock.now()
196    if (demoStartedAt !== null) demoFrames++
197    const live = await liveNow($)
198    // The demo plays over the session: the road waits where he is.
199    const cells = cellsNow(live, p.width, now, motionNow(live, p.width, now, demoStartedAt === null))
200    if (cells === p.painted) return
201    p.painted = cells
202    const blitted = await $.ui.blit({ requestId: p.requestId, key: RASTER_KEY, cells })
203    lastBlitAt = now
204    // Off the screen for now (a dialog or the command menu over it): keep trying. When the band
205    // comes back Claude Code doesn't always draw it again, so waiting for a draw would freeze it.
206    if (blitted.deny !== undefined) p.painted = ''
207  } finally {
208    isBlitting = false
209  }
210}
211
212type BandEvent = Parameters<EngineInterface['ui']['resolve']>[0] & {
213  requestId: string
214  props: { hasSurvey: boolean; isWorking: boolean; bodyColumns: number }
215}
216
217// The desktop has no frame timer: it plays the scene as a loop, the live session's or the demo's.
218// Between turns it shows him at his rest spot rather than on the way there.
219async function desktopScenes($: EngineInterface, now: number): Promise<(i: number) => Scene> {
220  if (demoStartedAt !== null) {
221    const from = now - demoStartedAt
222    return i => demoScene(from + i * DESKTOP_FRAME_MS, DESKTOP_COLS).scene
223  }
224  const { story: st, stats: s } = await liveNow($)
225  return i => {
226    const t = now + i * DESKTOP_FRAME_MS
227    const goal = road.isWorking ? null : goalOf(st, t, DESKTOP_COLS)
228    const isWalking = road.isWorking && beatOf(st, t) === 'walk'
229    const distance = goal ? goal.from : Math.floor(road.distance) + (isWalking ? i : 0)
230    return { width: DESKTOP_COLS, t, distance, isWalking, stats: s, story: st, trudge: s.mp <= 0 }
231  }
232}
233
234const desktopKeyOf = (first: Scene) => `${desktopKey(first)}|${JSON.stringify(first.story)}|${JSON.stringify(first.stats)}`
235
236const ZONE_NAMES = { forest: 'the Enchanted Forest', dungeon: 'the Dungeon', neon: 'Neon City' } as const
237
238function altOf(s: Scene): string {
239  const zone = ZONE_NAMES[zoneAt(s.story, s.distance + 50)]
240  const beat = beatOf(s.story, s.t)
241  if (beat === 'walk') return `Clawd walks through ${zone}`
242  if (beat === 'idle') return `Clawd rests between turns, in ${zone}`
243  return `Clawd fights in ${zone}`
244}
245
246// The 1 s timer: a desktop loop whose scene moved on (a victory over, a rest spot reached, the demo's
247// next scene) is redrawn; nothing else would redraw it.
248async function refreshDesktop($: EngineInterface): Promise<void> {
249  if (!isOnDesktop || !desktop) return
250  const now = await $.clock.now()
251  const key = desktopKeyOf((await desktopScenes($, now))(0))
252  if (key === desktop.key || key === askedFor) return
253  askedFor = key
254  $.ui.invalidate('ui.render')
255}
256
257// With no terminal band painting, nothing else moves the road: a second's worth of frames at a time,
258// so the desktop's scenes (and where a trip or a gate starts) keep up with the session.
259// A second is 12.5 frames: 12 and 13 by turns.
260let unseenSeconds = 0
261async function walkUnseen($: EngineInterface): Promise<void> {
262  if (painting || !isOnDesktop) return
263  const now = await $.clock.now()
264  const frames = Math.floor(STATS_MS / FRAME_MS) + (unseenSeconds++ % 2)
265  const live = await liveNow($)
266  for (let i = 0; i < frames; i++) motionNow(live, DESKTOP_COLS, now, true)
267}
268
269async function drawBand($: EngineInterface, e: BandEvent, next: (e: BandEvent) => unknown) {
270  const width = Math.min(512, e.props.bodyColumns)
271  if (e.surface !== 'terminal') return drawRemote($, e, next)
272  lastWidth = width
273  if (e.props.hasSurvey || width < HIDDEN_BELOW || (await read($, isHidden))) {
274    stopPainting()
275    return next(e)
276  }
277  road.isWorking = e.props.isWorking
278  const now = await $.clock.now()
279  const live = await liveNow($)
280  const cells = cellsNow(live, width, now, motionNow(live, width, now, false))
281  if (!painting || painting.requestId !== e.requestId || painting.width !== width) {
282    stopPainting()
283    painting = { requestId: e.requestId, width, painted: cells }
284    frameTimer = $.clock.every(FRAME_MS, () => void paintFrame($))
285  } else {
286    painting.painted = cells
287  }
288  const { Raster } = $.ui.resolve(e as never) as never as { Raster: any }
289  return <Raster key={RASTER_KEY} columns={width} rows={ROWS} cells={cells} />
290}
291
292// The desktop app and the phone draw the band as a looping Svg; their redraws never touch the
293// terminal's band.
294async function drawRemote($: EngineInterface, e: BandEvent, next: (e: BandEvent) => unknown) {
295  if (e.props.hasSurvey || (await read($, isHidden))) {
296    isOnDesktop = false
297    return next(e)
298  }
299  if (!painting) road.isWorking = e.props.isWorking
300  const now = await $.clock.now()
301  const sceneAt = await desktopScenes($, now)
302  const first = sceneAt(0)
303  const key = desktopKeyOf(first)
304  if (!desktop || desktop.key !== key) desktop = { key, ...desktopLoop(sceneAt), startedAt: now }
305  isOnDesktop = true
306  const px = Math.min(DESKTOP_MAX_PX, Math.max(1, e.props.bodyColumns) * DESKTOP_COL_PX)
307  const { Svg } = $.ui.resolve(e as never) as never as { Svg: any }
308  const source = resumeAt(desktop.svg, (now - desktop.startedAt) % desktop.ms)
309  return <Svg key="rpg-svg" source={source} alt={altOf(first)} width={px} height={Math.round((px * 10) / DESKTOP_COLS)} isInteractive />
310}
311
312async function toggle($: EngineInterface): Promise<string> {
313  const hide = !(await read($, isHidden))
314  await update($, isHidden, () => hide)
315  await $.store.set('isHidden', hide)
316  return hide ? "Clawd's adventure is hidden. /rpg brings it back." : 'Clawd is back on the road.'
317}
318
319async function demo($: EngineInterface): Promise<string> {
320  if (demoStartedAt !== null) {
321    demoStartedAt = null
322    $.ui.invalidate('ui.render')
323    return 'Demo stopped: back to your session.'
324  }
325  if (await read($, isHidden)) return "Clawd's adventure is hidden: /rpg brings it back, then /rpg demo."
326  demoStartedAt = await $.clock.now()
327  demoFrames = 0
328  $.ui.invalidate('ui.render')
329  const narrow = lastWidth !== null && lastWidth < HIDDEN_BELOW ? ` Widen the terminal past ${HIDDEN_BELOW} columns to see it.` : ''
330  return `Playing a day on the road above the prompt: ${DEMO.length} scenes, about ${Math.round(DEMO_MS / 1000)} s, on a loop. /rpg demo again stops it.${narrow}`
331}
332
333// The save as the store has it now, or what this session plays on with when it can't be read.
334async function savedNow($: EngineInterface): Promise<Progress> {
335  return (await loadSaved($).catch(() => null)) ?? progress
336}
337
338async function doctorFacts($: EngineInterface): Promise<DoctorFacts> {
339  const now = await $.clock.now()
340  let save: DoctorFacts['save'] = 'fine'
341  try {
342    const loaded = loadProgress(await $.store.get('progress'))
343    if (loaded.isNewer) save = 'newer'
344    else if (loaded.backup !== undefined || (await $.store.get('progressBackup')) !== undefined) save = 'backup'
345  } catch {
346    save = 'broken'
347  }
348  const settings: Record<string, unknown> = await $.settings.read().catch(() => ({}))
349  const enabled = typeof settings.enabledPlugins === 'object' && settings.enabledPlugins !== null ? (settings.enabledPlugins as Record<string, unknown>) : {}
350  const p = await savedNow($)
351  return {
352    hidden: await read($, isHidden),
353    width: lastWidth,
354    painting: painting !== null,
355    lastBlitMs: lastBlitAt === null ? null : now - lastBlitAt,
356    save,
357    level: p.level,
358    zone: p.zone,
359    clawdBar: Object.entries(enabled).some(([name, on]) => name.startsWith('clawd-bar@') && on === true),
360  }
361}
362
363async function runCommand($: EngineInterface, args: string): Promise<string> {
364  await quietly(ensureReady($))
365  const word = args.trim().toLowerCase()
366  if (word === 'stats') return statsText(await savedNow($))
367  if (word === 'doctor') return doctorText(await doctorFacts($))
368  if (word === 'demo') return demo($)
369  return toggle($)
370}
371
372type Call = { tool?: string; tool_use_id?: string; agentId?: string; consent?: unknown }
373type CallResult = { deny?: string; isError?: boolean; text?: string; result?: unknown }
374
375// Every tool call, the main loop's and a subagent's (`agentId`), before it runs. A command's result
376// comes straight from the call too; PostToolUse brings the same, and the story settles it once.
377async function onCall($: EngineInterface, e: Call, next: (e: Call) => Promise<CallResult>): Promise<CallResult> {
378  const { tool, tool_use_id: toolUseId, consent: _consent, agentId, ...input } = e
379  const by = agentId === undefined ? {} : { agent_id: agentId }
380  await quietly(observe($, 'PreToolUse', { tool_name: tool, tool_input: input, tool_use_id: toolUseId, ...by }))
381  const ran = await next(e)
382  if ((tool === 'Bash' || tool === 'PowerShell') && ran.deny === undefined) {
383    const id = { tool_use_id: toolUseId ?? '' }
384    const payload = ran.isError ? { ...id, error: ran.text ?? '' } : { ...id, tool_response: ran.result }
385    await quietly(observe($, ran.isError ? 'PostToolUseFailure' : 'PostToolUse', payload))
386  }
387  return ran
388}
389
390const quietly = (work: Promise<void>) => work.catch(() => undefined)
391
392export const register: Register = on => {
393  on('session.start', async ($, e, next) => {
394    await $.command.register({
395      name: 'rpg',
396      description: "Show or hide Clawd's adventure above the prompt · /rpg stats: his level, zone and bestiary · /rpg demo plays every scene · /rpg doctor checks the setup",
397      argumentHint: '[stats|demo|doctor]',
398    })
399    await quietly(ensureReady($))
400    return next(e)
401  })
402  on('command.run', { command: 'rpg' }, async ($, e) => ({ text: await runCommand($, e.args ?? '') }))
403  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => drawBand($, e as never, next as never) as never)
404  on('tool.call', ($, e, next) => onCall($, e as never, next as never) as never)
405
406  // A render hook may not write state, so the story moves on session events. The turn ends here and
407  // not at classic.Stop, which a user's own Stop hook can block to keep Claude working.
408  on('turn.complete', async ($, e, next) => {
409    const result = await next(e)
410    if (e.agentId === undefined) await quietly(observe($, 'TurnEnded', { reason: e.reason }))
411    return result
412  })
413  on('classic.SessionStart', async ($, e, next) => {
414    await quietly(observe($, 'SessionStart', e as never))
415    return next(e)
416  })
417  // Claude switches branches mid-session, so the HUD's place is re-read at every prompt.
418  on('classic.UserPromptSubmit', async ($, e, next) => {
419    await quietly(observe($, 'UserPromptSubmit', e as never).then(() => refreshPlace($)))
420    return next(e)
421  })
422  on('classic.PostToolUse', async ($, e, next) => {
423    await quietly(observe($, 'PostToolUse', e as never))
424    return next(e)
425  })
426  on('classic.PostToolUseFailure', async ($, e, next) => {
427    await quietly(observe($, 'PostToolUseFailure', e as never))
428    return next(e)
429  })
430  // A compaction between turns sends him to the inn; the director ignores one mid-turn.
431  on('classic.PreCompact', async ($, e, next) => {
432    await quietly(observe($, 'Compact', e as never))
433    return next(e)
434  })
435  on('classic.TaskCreated', async ($, e, next) => {
436    await quietly(observe($, 'TaskCreated', e as never))
437    return next(e)
438  })
439  on('classic.TaskCompleted', async ($, e, next) => {
440    await quietly(observe($, 'TaskCompleted', e as never))
441    return next(e)
442  })
443  on('classic.SubagentStart', async ($, e, next) => {
444    await quietly(observe($, 'SubagentStart', e as never))
445    return next(e)
446  })
447  on('classic.SubagentStop', async ($, e, next) => {
448    await quietly(observe($, 'SubagentStop', e as never))
449    return next(e)
450  })
451  on('classic.Notification', async ($, e, next) => {
452    await quietly(observe($, 'Notification', e as never))
453    return next(e)
454  })
455  on('classic.Elicitation', async ($, e, next) => {
456    await quietly(observe($, 'Elicitation', e as never))
457    return next(e)
458  })
459}
460
hooks/rpg/cells.ts 47 lines
1import { EMPTY, ROWS } from './grid'
2import type { Grid } from './grid'
3
4export const DEFAULT_COLOR = 0x01000000
5const UPPER = 0x2580
6const LOWER = 0x2584
7const SPACE = 0x20
8
9// Raster words: [codePoint, fg, bg] per cell, row by row.
10export function toWords(g: Grid): Uint32Array {
11  const words = new Uint32Array(g.w * ROWS * 3)
12  let i = 0
13  const cell = (cp: number, fg: number, bg: number) => {
14    words[i++] = cp
15    words[i++] = fg
16    words[i++] = bg
17  }
18  for (let row = 0; row < ROWS; row++) {
19    for (let col = 0; col < g.w; col++) {
20      const top = g.px[2 * row * g.w + col]!
21      const bottom = g.px[(2 * row + 1) * g.w + col]!
22      const text = g.text.get(row * g.w + col)
23      if (text) cell(text.cp, text.fg, top !== EMPTY ? top : bottom !== EMPTY ? bottom : DEFAULT_COLOR)
24      else if (top === EMPTY && bottom === EMPTY) cell(SPACE, DEFAULT_COLOR, DEFAULT_COLOR)
25      else if (bottom === EMPTY) cell(UPPER, top, DEFAULT_COLOR)
26      else if (top === EMPTY) cell(LOWER, bottom, DEFAULT_COLOR)
27      else cell(UPPER, top, bottom)
28    }
29  }
30  return words
31}
32
33const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
34
35function base64(bytes: Uint8Array): string {
36  let out = ''
37  for (let i = 0; i < bytes.length; i += 3) {
38    const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
39    out += BASE64[(n >> 18) & 63]! + BASE64[(n >> 12) & 63]!
40    out += i + 1 < bytes.length ? BASE64[(n >> 6) & 63]! : '='
41    out += i + 2 < bytes.length ? BASE64[n & 63]! : '='
42  }
43  return out
44}
45
46export const encodeCells = (g: Grid) => base64(new Uint8Array(toWords(g).buffer))
47
hooks/rpg/demo.ts 241 lines
1import type { RpgStats, ZoneId } from '../../types'
2import { NO_STORY, VICTORY_MS, passGate, step, withLevelUp } from './director'
3import type { HookPayload, Story } from './director'
4import type { Scene } from './frame'
5import { DOZE_MS, GATE_AFTER, goalOf } from './road'
6import { LEG_STEP_MS } from './sprites/clawd'
7
8type Event = [string, HookPayload]
9
10// The demo's own clock, far from any real session's, so nothing it shows is mistaken for one.
11const T0 = 2_000_000_000
12const WALK_AT = 4000
13const STATS: RpgStats = { level: 7, exp: 0.62, hp: 0.82, mp: 0.64, place: 'clawd-rpg/main' }
14const LEVELLED: RpgStats = { ...STATS, level: 8, exp: 0.08 }
15
16// Events a second apart from `from`; `at` is when the last one happened.
17function tell(events: Event[], from: number, story: Story = NO_STORY): { story: Story; at: number } {
18  let at = from
19  for (const [event, payload] of events) story = step(story, event, payload, (at += 1000)).story
20  return { story, at }
21}
22
23const start = (zone: ZoneId): Event => ['SessionStart', { source: 'startup', zone, roadAt: 0 }]
24const prompt = (boss = false): Event => ['UserPromptSubmit', { boss }]
25const edit = (id: string, file = 'scenes.ts'): Event => ['PreToolUse', { tool_name: 'Edit', tool_input: { file_path: `src/${file}` }, tool_use_id: id }]
26const testRun = (id: string): Event => ['PreToolUse', { tool_name: 'Bash', tool_input: { command: 'npm test' }, tool_use_id: id }]
27const result = (id: string, stdout: string): Event => ['PostToolUse', { tool_use_id: id, tool_response: { stdout, stderr: '' } }]
28const joins = (id: string, type: string): Event => ['SubagentStart', { agent_id: id, agent_type: type }]
29const leaves = (id: string, type: string): Event => ['SubagentStop', { agent_id: id, agent_type: type }]
30const ended = (roadAt: number, reason = 'answer'): Event => ['TurnEnded', { reason, roadAt }]
31const task = (id: string, subject: string): Event => ['TaskCreated', { task_id: id, task_subject: subject }]
32const taskDone = (id: string, subject: string): Event => ['TaskCompleted', { task_id: id, task_subject: subject }]
33
34// `make` builds a scene's story once for a width; `into` is how far into the scene a frame is.
35export type DemoScene = { name: string; ms: number; make: (from: number, width: number) => (into: number) => Scene }
36
37const walking = (story: Story, t0: number, width: number, stats = STATS) => (into: number): Scene => ({
38  width,
39  t: t0 + into,
40  distance: WALK_AT + Math.floor(into / 160),
41  isWalking: true,
42  stats,
43  story,
44})
45
46const standing = (story: Story, t0: number, width: number, stats = STATS) => (into: number): Scene => ({ width, t: t0 + into, distance: WALK_AT, isWalking: false, stats, story })
47
48// At the spot his trip has reached by `t0` (the pier, the zone's own spot, the inn).
49function restingAt(story: Story, t0: number, width: number, stats = STATS) {
50  const distance = goalOf(story, t0, width)?.from ?? 0
51  return (into: number): Scene => ({ width, t: t0 + into, distance, isWalking: false, stats, story })
52}
53
54function fight(zone: ZoneId, from: number) {
55  return tell([start(zone), prompt(), edit('e1')], from)
56}
57
58function bossFight(from: number) {
59  return tell([start('forest'), prompt(true), edit('e1'), edit('e2')], from)
60}
61
62function wonBoss(from: number) {
63  const won = tell([start('forest'), prompt(true), edit('e1'), edit('e2'), ended(WALK_AT)], from)
64  return { story: withLevelUp(won.story, 8, won.at), at: won.at }
65}
66
67function afterTurn(zone: ZoneId, from: number) {
68  return tell([start(zone), prompt(), ended(0)], from)
69}
70
71export const DEMO: readonly DemoScene[] = [
72  {
73    name: 'Walking: Claude reads and thinks',
74    ms: 4000,
75    make: (from, width) => {
76      const { story, at } = tell([start('forest'), prompt(), task('1', 'read the spec'), task('2', 'fix the login form'), task('3', 'run the tests'), taskDone('1', 'read the spec')], from)
77      return walking(story, at, width)
78    },
79  },
80  {
81    name: 'An edit: CLAW STRIKE',
82    ms: 3200,
83    make: (from, width) => {
84      const { story, at } = fight('forest', from)
85      return standing(story, at, width)
86    },
87  },
88  {
89    name: 'A failing test: the enemy turn',
90    ms: 2400,
91    make: (from, width) => {
92      const { story, at } = tell([start('forest'), prompt(), edit('e1'), testRun('t1'), result('t1', 'Tests  2 failed | 4 passed (6)')], from)
93      return standing(story, at, width)
94    },
95  },
96  {
97    name: 'Tests pass: ALL-OUT ATTACK',
98    ms: 2800,
99    make: (from, width) => {
100      const events = [start('forest'), prompt(), edit('e1'), testRun('t1'), result('t1', 'Tests  2 failed (2)'), edit('e2'), testRun('t2'), result('t2', 'Tests  6 passed (6)')]
101      const { story, at } = tell(events, from)
102      return standing(story, at, width)
103    },
104  },
105  {
106    name: 'Subagents join the party',
107    ms: 3600,
108    make: (from, width) => {
109      const { story, at } = tell([start('forest'), prompt(), joins('a1', 'general-purpose'), joins('a2', 'Plan'), joins('a3', 'Explore')], from)
110      return walking(story, at - 2000, width)
111    },
112  },
113  {
114    name: "A subagent's work lands: ARCANE BOLT",
115    ms: 2400,
116    make: (from, width) => {
117      const { story, at } = tell([start('forest'), prompt(), joins('a1', 'general-purpose'), joins('a2', 'Plan'), edit('e1'), leaves('a2', 'Plan')], from)
118      return standing(story, at, width)
119    },
120  },
121  {
122    name: 'An interrupt: the foe flees',
123    ms: 1800,
124    make: (from, width) => {
125      const { story, at } = tell([start('forest'), prompt(), edit('e1'), ended(WALK_AT, 'aborted')], from)
126      return standing(story, at, width)
127    },
128  },
129  {
130    name: 'Waiting for you: the pier',
131    ms: 4000,
132    make: (from, width) => {
133      const { story, at } = afterTurn('forest', from)
134      return restingAt(story, at + VICTORY_MS + 1000, width)
135    },
136  },
137  {
138    name: 'Claude needs you',
139    ms: 2000,
140    make: (from, width) => {
141      const after = afterTurn('forest', from)
142      const { story, at } = tell([['Notification', {}]], after.at + VICTORY_MS, after.story)
143      return restingAt(story, at, width)
144    },
145  },
146  {
147    name: 'A minute idle: the campfire',
148    ms: 3600,
149    make: (from, width) => {
150      const { story, at } = afterTurn('forest', from)
151      return restingAt(story, at + DOZE_MS + 1000, width)
152    },
153  },
154  {
155    name: 'The zone meter is full: the Treant',
156    ms: 4000,
157    make: (from, width) => {
158      const { story, at } = bossFight(from)
159      return standing(story, at - 1000, width)
160    },
161  },
162  {
163    name: 'Victory, loot and a level',
164    ms: 4400,
165    make: (from, width) => {
166      const { story, at } = wonBoss(from)
167      return standing(story, at, width)
168    },
169  },
170  {
171    name: 'Zone clear: through the gate',
172    ms: 4000,
173    make: (from, width) => {
174      const { story, at } = wonBoss(from)
175      const crossed = passGate(story, 'dungeon', WALK_AT + GATE_AFTER)
176      return (into: number): Scene => ({ width, t: at + VICTORY_MS + into, distance: WALK_AT + Math.floor(into / 80), isWalking: true, stats: LEVELLED, story: crossed, legMs: LEG_STEP_MS / 2 })
177    },
178  },
179  {
180    name: 'The Dungeon',
181    ms: 3200,
182    make: (from, width) => {
183      const { story, at } = fight('dungeon', from)
184      return standing(story, at, width, LEVELLED)
185    },
186  },
187  {
188    name: "The Dungeon's crystal room",
189    ms: 3600,
190    make: (from, width) => {
191      const { story, at } = afterTurn('dungeon', from)
192      return restingAt(story, at + DOZE_MS + 1000, width, LEVELLED)
193    },
194  },
195  {
196    name: 'Neon City',
197    ms: 3200,
198    make: (from, width) => {
199      const { story, at } = fight('neon', from)
200      return standing(story, at, width, LEVELLED)
201    },
202  },
203  {
204    name: "Neon City's ramen stall",
205    ms: 3600,
206    make: (from, width) => {
207      const { story, at } = afterTurn('neon', from)
208      return restingAt(story, at + DOZE_MS + 1000, width, LEVELLED)
209    },
210  },
211  {
212    name: 'A compaction: the inn',
213    ms: 4000,
214    make: (from, width) => {
215      const { story, at } = tell([start('neon'), prompt(), ended(0), ['Compact', {}]], from)
216      return restingAt(story, at + VICTORY_MS, width, { ...LEVELLED, hp: 0.95 })
217    },
218  },
219]
220
221export const DEMO_MS = DEMO.reduce((n, d) => n + d.ms, 0)
222
223// Stories are immutable, so each scene's is built once per width rather than every frame.
224const made = new Map<string, (into: number) => Scene>()
225function sceneAt(i: number, width: number): (into: number) => Scene {
226  const key = `${i}:${width}`
227  let at = made.get(key)
228  if (!at) made.set(key, (at = DEMO[i]!.make(T0 + i * 10_000_000, width)))
229  return at
230}
231
232// The demo `ms` in (looping), at a width: the scene's name and what to draw.
233export function demoScene(ms: number, width: number): { name: string; scene: Scene } {
234  let into = ((ms % DEMO_MS) + DEMO_MS) % DEMO_MS
235  for (const [i, d] of DEMO.entries()) {
236    if (into < d.ms) return { name: d.name, scene: sceneAt(i, width)(into) }
237    into -= d.ms
238  }
239  return { name: DEMO[0]!.name, scene: sceneAt(0, width)(0) }
240}
241
hooks/rpg/desktop.ts 32 lines
1import { CALL_MS, beatOf } from './director'
2import { frame } from './frame'
3import type { Scene } from './frame'
4import { CLAWD_COL, restOf, zoneAt } from './road'
5import { SVG_MAX_CHARS, svgLoop } from './svg'
6
7// The desktop draws the band at a fixed width, scaled to fit, as short loops it plays on its own.
8export const DESKTOP_COLS = 120
9export const DESKTOP_FRAME_MS = 160
10const MOST_FRAMES = 12
11
12// As many frames as fit under the Svg cap; `sceneAt(i)` is the scene of frame i.
13export function desktopLoop(sceneAt: (i: number) => Scene): { svg: string; ms: number } {
14  const grids = Array.from({ length: MOST_FRAMES }, (_, i) => frame(sceneAt(i)))
15  for (let n = MOST_FRAMES; n > 1; n--) {
16    const svg = svgLoop(grids.slice(0, n), DESKTOP_FRAME_MS)
17    if (svg.length <= SVG_MAX_CHARS) return { svg, ms: n * DESKTOP_FRAME_MS }
18  }
19  return { svg: svgLoop(grids.slice(0, 1), DESKTOP_FRAME_MS), ms: DESKTOP_FRAME_MS }
20}
21
22// What a loop shows: while this holds, the loop already built still plays it.
23export function desktopKey(s: Scene): string {
24  const { story, t } = s
25  const rest = restOf(story, t, s.distance, s.width)
26  const called = story.calledAt !== null && t >= story.calledAt && t - story.calledAt < CALL_MS
27  return [beatOf(story, t), rest?.kind ?? '', rest?.phase ?? '', zoneAt(story, s.distance + CLAWD_COL), story.foe?.kind ?? '', (story.party ?? []).length, called ? '!' : '', s.isWalking ? 'walking' : ''].join('|')
28}
29
30// The surface starts an Svg over when it redraws; a rebuilt one picks up `ms` into its loop.
31export const resumeAt = (svg: string, ms: number) => svg.replace('<style>', `<style>*{animation-delay:-${Math.round(ms)}ms!important}`)
32
hooks/rpg/director.ts 305 lines
1import type { Foe, FoeKind, Member, PartyClass, QuestTask, Story, Trip, Work, WorkKind, ZoneId } from '../../types'
2import { base, classifyCall, settleCall, str } from '../plumbing/work'
3import { noise } from './noise'
4import { EXP } from './progress'
5import type { Award } from './progress'
6
7export type { Foe, FoeKind, Member, PartyClass, QuestTask, Story, Trip, ZoneId }
8export type HookPayload = Record<string, unknown>
9export type Beat = 'walk' | 'idle' | 'encounter' | 'enemyTurn' | 'finisher' | 'victory' | 'flee'
10
11export const FOE_ENTER_MS = 800
12export const SKILL_MS = 1400
13export const DOWN_MS = 1000
14export const COUNTER_MS = 2000
15export const FINISHER_MS = 2400
16export const VICTORY_MS = 4400
17export const FLEE_MS = 1600
18export const CALL_MS = 6000
19export const LEVEL_UP_MS = 3000
20
21export const ROSTERS: Record<ZoneId, readonly FoeKind[]> = {
22  forest: ['goblin', 'shroom'],
23  dungeon: ['slime', 'skeleton'],
24  neon: ['drone', 'bug'],
25}
26export const BOSSES: Record<ZoneId, FoeKind> = { forest: 'treant', dungeon: 'hydra', neon: 'mech' }
27export const BOSS_HP = 6
28const ZONES = Object.keys(ROSTERS) as ZoneId[]
29
30export const SKILLS: Partial<Record<WorkKind, string>> = {
31  edit: 'CLAW STRIKE',
32  write: 'SCRIBE SLASH',
33  shell: 'SHELL SHOCK',
34  install: 'SHELL SHOCK',
35  mcp: 'LINK BEAM',
36}
37export const CLASS_SKILLS: Record<PartyClass, string> = { knight: 'SHIELD BASH', mage: 'ARCANE BOLT', scout: 'QUICK SHOT' }
38
39const CLASSES: Readonly<Record<string, PartyClass>> = { Explore: 'scout', Plan: 'mage', 'general-purpose': 'knight' }
40const ANY_CLASS = ['knight', 'mage', 'scout'] as const
41
42// Any other agent type gets a class of its own, the same one every time.
43export function classOf(agentType: string): PartyClass {
44  const known = CLASSES[agentType]
45  if (known) return known
46  return ANY_CLASS[[...agentType].reduce((h, ch) => (h * 31 + ch.codePointAt(0)!) % 1_000_003, 7) % ANY_CLASS.length]!
47}
48
49const TROPHY: Record<FoeKind, string> = {
50  goblin: 'Goblin Fang',
51  shroom: 'Glow Cap',
52  slime: 'Slime Jelly',
53  skeleton: 'Old Bone',
54  drone: 'Drone Rotor',
55  bug: 'Glitch Shard',
56  treant: 'Treant Heartwood',
57  hydra: 'Hydra Scale',
58  mech: 'Mech Core',
59}
60const FOUND: Record<ZoneId, string> = { forest: 'Forest Herb', dungeon: 'Torch Stub', neon: 'Neon Token' }
61const LOOT_WORDS = ['Blade', 'Tome', 'Charm', 'Sigil'] as const
62
63export const NO_STORY: Story = {
64  turn: { active: false, at: 0, files: {}, gained: 0, foes: 0, recent: [], boss: false, bossDown: null },
65  foe: null,
66  skill: null,
67  down: null,
68  counter: null,
69  victory: null,
70  fled: null,
71  levelUp: null,
72  calledAt: null,
73  calls: {},
74  trip: null,
75  trail: null,
76  tasks: [],
77  zone: 'forest',
78  gate: null,
79  party: [],
80}
81
82const zoneOf = (value: unknown): ZoneId => (ZONES.includes(value as ZoneId) ? (value as ZoneId) : 'forest')
83
84function spawn(s: Story, now: number): Foe {
85  const zone = s.zone ?? 'forest'
86  if (s.turn.boss && !s.turn.bossDown) return { kind: BOSSES[zone], hp: BOSS_HP, maxHp: BOSS_HP, elite: false, at: now, hitAt: 0, boss: true }
87  const roster = ROSTERS[zone]
88  const seed = s.turn.at / 997 + s.turn.foes * 7.3
89  const kind = roster[Math.floor(noise(seed) * roster.length)]!
90  const maxHp = 2 + Math.floor(noise(seed + 1) * 3)
91  return { kind, hp: maxHp, maxHp, elite: false, at: now, hitAt: 0 }
92}
93
94// The file edited most this turn names the loot; a turn without edits keeps a trophy from the fight.
95export function lootName(files: Record<string, number>, lastFoe: FoeKind | null, zone: ZoneId = 'forest'): string {
96  const top = Object.entries(files).sort((a, b) => b[1] - a[1])[0]?.[0]
97  if (!top) return lastFoe ? TROPHY[lastFoe] : FOUND[zone]
98  const word = LOOT_WORDS[[...top].reduce((n, ch) => n + ch.codePointAt(0)!, 0) % LOOT_WORDS.length]!
99  return `${word} of ${top}`
100}
101
102function defeat(s: Story, foe: Foe, now: number, finisher: boolean, awards: Award[]): Story {
103  const down = { kind: foe.kind, at: now, finisher }
104  if (foe.boss) {
105    awards.push({ kind: 'raid', foe: foe.kind, boss: true, zone: s.zone ?? 'forest' })
106    return { ...s, foe: null, down, turn: { ...s.turn, gained: s.turn.gained + EXP.raid, bossDown: foe.kind } }
107  }
108  const kind = foe.elite ? 'elite' : 'battle'
109  awards.push({ kind, foe: foe.kind })
110  return { ...s, foe: null, down, turn: { ...s.turn, gained: s.turn.gained + EXP[kind] } }
111}
112
113// A blow on the foe in the way, or on a fresh one if nothing stands there yet.
114function hit(s: Story, skill: NonNullable<Story['skill']>, now: number, awards: Award[]): Story {
115  const fresh = s.foe === null
116  const foe = s.foe ?? spawn(s, now)
117  const next: Story = { ...s, skill, turn: { ...s.turn, foes: s.turn.foes + (fresh ? 1 : 0) } }
118  // An elite stands for a failing test: only a passing run finishes it.
119  const hp = foe.elite ? Math.max(1, foe.hp - 1) : foe.hp - 1
120  if (hp <= 0) return defeat(next, foe, now, false, awards)
121  return { ...next, foe: { ...foe, hp, hitAt: now } }
122}
123
124// `by`: a party member made the call, so the blow is its class skill.
125function attack(s: Story, work: Work, path: string, now: number, awards: Award[], by: Member | null): Story {
126  const name = SKILLS[work.kind]
127  if (!name) return s
128  const file = base(path)
129  const files = path && (work.kind === 'edit' || work.kind === 'write') ? { ...s.turn.files, [file]: (s.turn.files[file] ?? 0) + 1 } : s.turn.files
130  const skill = by ? { name: CLASS_SKILLS[by.cls], at: now, party: true as const, by: by.id } : { name, at: now }
131  return hit({ ...s, turn: { ...s.turn, files } }, skill, now, awards)
132}
133
134function settle(s: Story, payload: HookPayload, failed: boolean, now: number, awards: Award[]): Story {
135  const id = str(payload.tool_use_id)
136  const open = s.calls[id]
137  if (!open) return s
138  const { [id]: _, ...calls } = s.calls
139  const work = settleCall(open, payload, failed, now)
140  const next = { ...s, calls }
141  if (work.kind !== 'tests') return failed ? { ...next, counter: { n: 1, at: now } } : next
142  const result = work.result!
143  if (!result.ok || (result.failed ?? 0) > 0) {
144    const foe = next.foe ?? spawn(next, now)
145    return {
146      ...next,
147      counter: { n: Math.max(1, result.failed ?? 0), at: now },
148      foe: { ...foe, elite: true, hp: Math.max(foe.hp, 2) },
149      turn: { ...next.turn, foes: next.turn.foes + (next.foe ? 0 : 1) },
150    }
151  }
152  return next.foe ? defeat(next, next.foe, now, true, awards) : next
153}
154
155function endTurn(s: Story, reason: string, now: number, awards: Award[]): Story {
156  if (!s.turn.active) return s
157  const ended: Story = { ...s, calls: {}, turn: { ...s.turn, active: false } }
158  if (reason !== 'answer') return { ...ended, foe: null, fled: s.foe ? { kind: s.foe.kind, at: now } : null }
159  let won = ended
160  if (won.foe && !won.foe.elite) won = defeat(won, won.foe, now, false, awards)
161  else if (won.foe) won = { ...won, foe: null, fled: { kind: won.foe.kind, at: now } }
162  // A turn the party fought in is a raid.
163  if ((s.party ?? []).length > 0) {
164    awards.push({ kind: 'raid' })
165    won = { ...won, turn: { ...won.turn, gained: won.turn.gained + EXP.raid } }
166  }
167  awards.push({ kind: 'turn' })
168  const gained = won.turn.gained + EXP.turn
169  const loot = won.turn.bossDown ? TROPHY[won.turn.bossDown] : lootName(won.turn.files, won.down?.kind ?? null, s.zone ?? 'forest')
170  return { ...won, turn: { ...won.turn, gained }, victory: { at: now, loot, gained } }
171}
172
173const num = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
174const restFrom = (roadAt: unknown, now: number, zone: ZoneId): Trip => ({ origin: num(roadAt), at: now, compactAt: null, leftAt: null, zone })
175
176const taskStatus = (value: unknown): QuestTask['status'] => (value === 'completed' || value === 'in_progress' ? value : 'pending')
177
178// TodoWrite hands over the whole list; TaskUpdate moves one task along, or deletes it.
179function plan(tasks: QuestTask[], tool: string, input: HookPayload): QuestTask[] {
180  if (tool === 'TodoWrite' && Array.isArray(input.todos)) {
181    return (input.todos as HookPayload[]).map((todo, i) => ({ id: `todo-${i}`, subject: str(todo.content), status: taskStatus(todo.status) }))
182  }
183  if (tool !== 'TaskUpdate') return tasks
184  const id = str(input.taskId)
185  if (input.status === 'deleted') return tasks.filter(task => task.id !== id)
186  return tasks.map(task => (task.id !== id ? task : { ...task, subject: str(input.subject) || task.subject, status: input.status === undefined ? task.status : taskStatus(input.status) }))
187}
188
189function created(tasks: QuestTask[], id: string, subject: string): QuestTask[] {
190  return !id || tasks.some(task => task.id === id) ? tasks : [...tasks, { id, subject, status: 'pending' }]
191}
192
193function completed(tasks: QuestTask[], id: string, subject: string): QuestTask[] {
194  if (!tasks.some(task => task.id === id)) return [...tasks, { id, subject, status: 'completed' }]
195  return tasks.map(task => (task.id === id ? { ...task, status: 'completed' } : task))
196}
197
198function joined(s: Story, id: string, agentType: string, now: number): Story {
199  const party = s.party ?? []
200  return party.some(m => m.id === id) ? s : { ...s, party: [...party, { id, cls: classOf(agentType), at: now, doneAt: null }] }
201}
202
203// Session events in, the story out, plus the deeds that earn EXP.
204export function step(s: Story, event: string, payload: HookPayload, now: number): { story: Story; awards: Award[] } {
205  const awards: Award[] = []
206  const story = ((): Story => {
207    switch (event) {
208      // A compaction happens mid-turn: the fight goes on through it.
209      case 'SessionStart': {
210        if (payload.source === 'compact') return s
211        const zone = zoneOf(payload.zone)
212        return { ...NO_STORY, zone, trip: restFrom(payload.roadAt, now, zone) }
213      }
214      case 'UserPromptSubmit':
215        return {
216          ...NO_STORY,
217          levelUp: s.levelUp,
218          tasks: s.tasks,
219          trip: s.trip && { ...s.trip, leftAt: s.trip.leftAt ?? now },
220          trail: s.trail,
221          zone: s.zone ?? 'forest',
222          gate: s.gate ?? null,
223          turn: { active: true, at: now, files: {}, gained: 0, foes: 0, recent: [], boss: payload.boss === true, bossDown: null },
224        }
225      case 'PreToolUse': {
226        // Background work after the turn has ended has no turn to close its fight.
227        if (!s.turn.active) return s
228        const input = typeof payload.tool_input === 'object' && payload.tool_input !== null ? (payload.tool_input as HookPayload) : {}
229        const id = str(payload.tool_use_id)
230        const tool = str(payload.tool_name)
231        const work = classifyCall(tool, input, id, now)
232        const recent = [...(s.turn.recent ?? []), now].slice(-3)
233        const agent = str(payload.agent_id)
234        // A subagent's own todo list isn't the quest.
235        const opened: Story = { ...s, calls: { ...s.calls, [id]: work }, tasks: agent ? s.tasks : plan(s.tasks, tool, input), turn: { ...s.turn, recent } }
236        const path = str(input.file_path) || str(input.notebook_path)
237        if (!agent) return attack(opened, work, path, now, awards, null)
238        // One left running from an earlier turn never joined this one: its calls aren't this fight's.
239        const member = (s.party ?? []).find(m => m.id === agent)
240        return member ? attack(opened, work, path, now, awards, member) : opened
241      }
242      case 'PostToolUse':
243        return settle(s, payload, false, now, awards)
244      case 'PostToolUseFailure':
245        return settle(s, payload, true, now, awards)
246      case 'TurnEnded': {
247        const ended = endTurn(s, str(payload.reason), now, awards)
248        return ended === s ? s : { ...ended, trip: restFrom(payload.roadAt, now, s.zone ?? 'forest'), trail: s.trip }
249      }
250      // A compaction between turns sends him to the inn; one mid-turn leaves the fight be.
251      case 'Compact':
252        return !s.turn.active && s.trip && s.trip.leftAt === null && s.trip.compactAt === null ? { ...s, trip: { ...s.trip, compactAt: now } } : s
253      case 'TaskCreated':
254        return { ...s, tasks: created(s.tasks, str(payload.task_id), str(payload.task_subject)) }
255      case 'TaskCompleted':
256        return { ...s, tasks: completed(s.tasks, str(payload.task_id), str(payload.task_subject)) }
257      case 'SubagentStart': {
258        const id = str(payload.agent_id)
259        if (!s.turn.active || !id) return s
260        return joined(s, id, str(payload.agent_type), now)
261      }
262      // Its work done, the member's class attack lands.
263      case 'SubagentStop': {
264        const id = str(payload.agent_id)
265        const member = (s.party ?? []).find(m => m.id === id)
266        if (!s.turn.active || !member || member.doneAt !== null) return s
267        const party = s.party.map(m => (m.id === id ? { ...m, doneAt: now } : m))
268        return hit({ ...s, party }, { name: CLASS_SKILLS[member.cls], at: now, party: true, by: id }, now, awards)
269      }
270      case 'Notification':
271      case 'Elicitation':
272        return { ...s, calledAt: now }
273      default:
274        return s
275    }
276  })()
277  return { story, awards }
278}
279
280// The road past `x` is the zone `to`; a trip not yet left rests at that zone's spots.
281export function passGate(s: Story, to: ZoneId, x: number): Story {
282  return { ...s, zone: to, gate: { x, from: s.zone ?? 'forest' }, trip: s.trip && s.trip.leftAt === null ? { ...s.trip, zone: to } : s.trip }
283}
284
285// `☐ 2/5 fix login`: the task list as a quest, led by the task in progress, or the next one up.
286export function questOf(s: Story): string | null {
287  const tasks = s.tasks ?? []
288  const done = tasks.filter(task => task.status === 'completed').length
289  const next = tasks.find(task => task.status === 'in_progress') ?? tasks.find(task => task.status === 'pending')
290  return next ? `☐ ${done}/${tasks.length} ${next.subject}` : null
291}
292
293export const withLevelUp = (s: Story, level: number, now: number): Story => ({ ...s, levelUp: { level, at: now } })
294
295const within = (at: number | undefined, ms: number, now: number) => at !== undefined && now >= at && now - at < ms
296
297export function beatOf(s: Story, now: number): Beat {
298  if (within(s.fled?.at, FLEE_MS, now)) return 'flee'
299  if (within(s.victory?.at, VICTORY_MS, now)) return 'victory'
300  if (s.down?.finisher && within(s.down.at, FINISHER_MS, now)) return 'finisher'
301  if (within(s.counter?.at, COUNTER_MS, now)) return 'enemyTurn'
302  if (s.foe || within(s.down?.at, DOWN_MS, now)) return 'encounter'
303  return s.turn.active ? 'walk' : 'idle'
304}
305
hooks/rpg/frame.ts 317 lines
1import type { FoeKind, RestKind, RpgStats, ZoneId } from '../../types'
2import {
3  CALL_MS,
4  COUNTER_MS,
5  DOWN_MS,
6  FINISHER_MS,
7  FLEE_MS,
8  FOE_ENTER_MS,
9  LEVEL_UP_MS,
10  SKILL_MS,
11  VICTORY_MS,
12  beatOf,
13  questOf,
14} from './director'
15import type { Beat, Story } from './director'
16import { EMPTY, at, grid, line, rect, sprite, write } from './grid'
17import type { Grid } from './grid'
18import { drawHud, drawable, hudLeft } from './hud'
19import { easeOut, lerp, on, through } from './noise'
20import { CAMP } from './places/camp'
21import { CRYSTAL } from './places/crystal'
22import { DUNGEON } from './places/dungeon'
23import { FOREST } from './places/forest'
24import { GATE_W, gateCol } from './places/gate'
25import { INN } from './places/inn'
26import { NEON } from './places/neon'
27import { PIER } from './places/pier'
28import type { Place } from './places/place'
29import { RAMEN } from './places/ramen'
30import { CLAWD_COL, COMPACT_BELOW, PACK_MS, SIGN_BEFORE, camOf, clawdCol, placeWidth, restOf, spotsOnRoad, zoneAt } from './road'
31import type { Rest, Spot } from './road'
32import { LEG_STEP_MS, POSES, asleep, cheering, clawd, hurting, idling, sitting, trudging, walking } from './sprites/clawd'
33import type { Pose } from './sprites/clawd'
34import { drawFoe, drawHp, foeTop, foeWidth } from './sprites/foes'
35import { bang, banner, burst, chest, classAttack, cloud, dust, sparks, sword } from './sprites/fx'
36import { MINI_REACH, mini } from './sprites/mini'
37
38export { CLAWD_COL, COMPACT_BELOW, COMPACT_COL, clawdCol } from './road'
39export const HIDDEN_BELOW = 40
40// Clawd swings from this far left of the foe, so the sword's arc lands on it.
41const REACH = 22
42const PARTY_TEXT = 0x8fb8ff
43const DROP_MS = 400
44
45// Non-ASCII characters the road may write; each must be one cell wide (tests check them).
46export const ROAD_GLYPHS = ['→', '☐', '…', '★'] as const
47
48const REST_PLACES: Record<RestKind, Place> = { pier: PIER, camp: CAMP, crystal: CRYSTAL, ramen: RAMEN, inn: INN }
49const ZONE_PLACES: Record<ZoneId, Place> = { forest: FOREST, dungeon: DUNGEON, neon: NEON }
50const SIGNS: Record<RestKind, string> = { pier: '→ Pier', camp: '→ Camp', crystal: '→ Crystal', ramen: '→ Ramen', inn: '→ Inn' }
51const ZONE_NAMES: Record<ZoneId, string> = { forest: 'FOREST', dungeon: 'DUNGEON', neon: 'NEON CITY' }
52const BOSS_NAMES: Partial<Record<FoeKind, string>> = { treant: 'TREANT', hydra: 'MERGE HYDRA', mech: 'MECH' }
53// The inn's blanket covers him from this pixel row down.
54const TUCKED_FROM = 6
55const FISH_MS = 7600
56
57// `legMs`: how long each of his steps takes at the pace the road moves.
58export type Scene = { width: number; t: number; distance: number; isWalking: boolean; stats: RpgStats; story: Story; trudge?: boolean; legMs?: number }
59
60// Where a foe `w` wide stands: at most 36 columns past Clawd, its right edge 6 clear of the HUD
61// (so an ordinary foe stands 14 clear of it).
62export const foeX = (width: number, stats: RpgStats, w = 8) => Math.min(CLAWD_COL + 36, hudLeft(width, stats) - 6 - Math.max(8, w))
63
64// How wide the foe in this fight is, while it stands, falls or flees.
65const fightW = (story: Story) => foeWidth(story.foe?.kind ?? story.down?.kind ?? story.fled?.kind ?? 'goblin')
66const fightX = (s: Scene) => foeX(s.width, s.stats, fightW(s.story))
67
68const isCompact = (s: Scene) => s.width < COMPACT_BELOW
69// The sword comes down for the last 45% of each 480 ms swing; the first swing waits for the foe to land.
70const swingFrom = (story: Story) => Math.max(story.skill?.at ?? 0, (story.foe?.at ?? 0) + FOE_ENTER_MS)
71const isSwinging = (story: Story, t: number) => story.skill !== null && !story.skill.party && t >= swingFrom(story) && t - swingFrom(story) < SKILL_MS * 0.8
72const swingDown = (story: Story, t: number) => (t - swingFrom(story)) % 480 >= 264
73
74// `tucked`: in bed, under the inn's blanket from TUCKED_FROM down. `counter`: on the ramen stool,
75// the counter over his lap.
76export type ClawdPlace = { x: number; y: number; pose: Pose; strike: boolean; tucked?: boolean; counter?: boolean }
77
78function resting(rest: Rest, x: number, t: number, called: boolean): ClawdPlace | null {
79  if (rest.phase === 'travel') return null
80  if (rest.phase === 'pack') {
81    if (rest.kind === 'pier') return { x, y: 1, pose: POSES.stand, strike: false }
82    const up = t - rest.since >= PACK_MS / 2
83    if (rest.kind === 'inn' && !up) return { x, y: -1, pose: asleep(t), strike: false, tucked: true }
84    if (rest.kind === 'ramen' && !up) return { x, y: 1, pose: POSES.sit, strike: false, counter: true }
85    return { x, y: 1, pose: up ? POSES.stretch : POSES.sit, strike: false }
86  }
87  if (rest.kind === 'inn') return { x, y: -1, pose: called ? { sit: true, top: 8 } : asleep(t), strike: false, tucked: true }
88  // Leaning out on the stool would put his eyes behind the counter.
89  if (rest.kind === 'ramen') return { x, y: 1, pose: POSES.sit, strike: false, counter: true }
90  if (called) return { x, y: 1, pose: POSES.stand, strike: false }
91  return { x, y: 1, pose: rest.kind === 'pier' ? idling(t) : sitting(t), strike: false }
92}
93
94// Where Clawd is and how he stands, or null while the ALL-OUT ATTACK cloud hides him.
95export function clawdAt(s: Scene): ClawdPlace | null {
96  const { t, story } = s
97  const beat = beatOf(story, t)
98  const home = clawdCol(s.width)
99  const walk = s.trudge ? trudging(t, s.legMs) : walking(t, s.legMs)
100  const called = story.calledAt !== null && t - story.calledAt < CALL_MS
101  const rest = beat === 'idle' || beat === 'walk' ? restOf(story, t, s.distance, s.width) : null
102  const still = rest && resting(rest, home, t, called)
103  if (still) return still
104  const standing = { x: home, y: 1, pose: called ? POSES.stand : s.isWalking ? walk : idling(t), strike: false }
105  if (isCompact(s)) {
106    if (beat === 'victory') return { x: home, y: 1, pose: cheering(t), strike: false }
107    if (beat === 'enemyTurn') return { x: home, y: 1, pose: hurting(t), strike: false }
108    return standing
109  }
110  const fx = fightX(s)
111  const stand = Math.min(CLAWD_COL, fx - REACH)
112  switch (beat) {
113    case 'encounter': {
114      const x = Math.round(lerp(CLAWD_COL, stand, through(t, story.foe?.at ?? story.down?.at ?? t, 400)))
115      if (isSwinging(story, t)) return { x: fx - REACH, y: 1, pose: swingDown(story, t) ? POSES.strike : POSES.swing, strike: true }
116      return { x, y: 1, pose: POSES.right, strike: false }
117    }
118    case 'enemyTurn':
119      return { x: stand - (t - story.counter!.at < COUNTER_MS / 2 ? 3 : 0), y: 1, pose: hurting(t), strike: false }
120    case 'finisher':
121      return t - story.down!.at < FINISHER_MS * 0.7 ? null : { x: stand, y: 1, pose: cheering(t), strike: false }
122    case 'victory':
123      return { x: CLAWD_COL, y: 1, pose: cheering(t), strike: false }
124    case 'flee':
125      return { x: stand, y: 1, pose: { fx: 1, armL: 8, armR: 10 }, strike: false }
126    default:
127      return standing
128  }
129}
130
131function drawFight(g: Grid, s: Scene, beat: Beat, before: number): void {
132  const { t, story } = s
133  const fx = fightX(s)
134  const mid = fx + Math.floor(fightW(story) / 2) - 1
135  const foe = story.foe
136  if (foe) {
137    const y = Math.round(lerp(-8, foeTop(foe.kind), easeOut(through(t, foe.at, FOE_ENTER_MS))))
138    const flash = (isSwinging(story, t) && swingDown(story, t)) || (story.skill?.party === true && t - foe.hitAt < 240 && t >= foe.hitAt)
139    drawFoe(g, foe.kind, fx, y, t, { flash, elite: foe.elite && !foe.boss })
140    // A boss fills the band, so its health runs along the top.
141    drawHp(g, fx, foe.boss ? 0 : y - (foe.elite ? 3 : 2), foe.hp, foe.maxHp)
142  }
143  const down = story.down
144  if (down && beat === 'encounter' && t - down.at < DOWN_MS) burst(g, mid, 6, (t - down.at) / 180, [0xffffff, 0xb6f09c, 0x6fbf4e])
145  if (beat === 'finisher' && down) {
146    const q = through(t, down.at, FINISHER_MS)
147    if (q < 0.7) cloud(g, fx, t)
148    else burst(g, mid, 5, (t - down.at - FINISHER_MS * 0.7) / 120, [0xffffff, 0xff9f8a, 0xd6453d], 12)
149    banner(g, 'ALL-OUT ATTACK!!', q / 0.7, 0xe5202e, undefined, before)
150  }
151  if (beat === 'flee' && story.fled) {
152    const q = through(t, story.fled.at, FLEE_MS)
153    drawFoe(g, story.fled.kind, fx + Math.round(q * 6), Math.round(foeTop(story.fled.kind) - q * 14), t)
154  }
155  if (story.skill && !story.skill.party && beat === 'encounter') banner(g, story.skill.name, (t - story.skill.at) / SKILL_MS, 0xe5202e, undefined, before)
156  if (beat === 'enemyTurn' && story.counter) banner(g, `COUNTER  ✗${story.counter.n}`, (t - story.counter.at) / COUNTER_MS, 0x000000, 0xff4b4b, before)
157}
158
159// Up to three of the party walk behind him, drop in as they join, cheer the win, and play their
160// class attack when it lands. `anchor`: Clawd's column. What a member does is called out over its
161// head, not in a cut-in, which would cover the party: the latest thing done, if any. Returns whether
162// anything was called out.
163function drawParty(g: Grid, s: Scene, beat: Beat, anchor: number): boolean {
164  const { t, story } = s
165  const shown = (story.party ?? []).slice(-3)
166  const fx = fightX(s)
167  let said: { label: string; x: number; at: number } | null = null
168  for (const [i, m] of shown.entries()) {
169    const x = anchor - (MINI_REACH + 1) * (shown.length - i)
170    const y = Math.round(lerp(-8, 4, easeOut(through(t, m.at, DROP_MS))))
171    const acting = story.skill?.by === m.id && t >= story.skill.at && t - story.skill.at < SKILL_MS
172    const step = s.isWalking && beat === 'walk' && on(t + i * 160, LEG_STEP_MS)
173    mini(g, x, y, m.cls, { cheer: beat === 'victory', step, glow: acting })
174    if (acting && story.skill) classAttack(g, m.cls, x, fx, t - story.skill.at)
175    // Named once it has landed: dropping in, it passes through the label's row.
176    const landed = m.at + DROP_MS
177    const news = acting && story.skill ? { label: story.skill.name, at: story.skill.at } : t >= landed && t - landed < SKILL_MS ? { label: `${m.cls.toUpperCase()} JOINS!`, at: landed } : null
178    if (news && (!said || news.at >= said.at)) said = { ...news, x }
179  }
180  if (said) write(g, Math.max(0, Math.min(said.x, anchor - 2 - said.label.length)), 0, said.label, PARTY_TEXT)
181  return said !== null
182}
183
184function caption(g: Grid, text: string, fg: number, before: number): void {
185  const room = before - 6
186  const chars = [...text]
187  write(g, 4, 0, chars.length > room ? chars.slice(0, room - 1).join('') + '…' : text, fg)
188}
189
190const spotsOn = (s: Scene): Spot[] => spotsOnRoad(s.story, s.t, s.width)
191
192// Text from any source (a place's sign scrolling past, a caption) never covers his pixels.
193function clearOff(g: Grid, me: ClawdPlace): void {
194  const solo = grid(g.w)
195  clawd(solo, me.x, me.y, me.pose)
196  for (const key of [...g.text.keys()]) {
197    const x = key % g.w
198    const row = Math.floor(key / g.w)
199    if (at(solo, x, row * 2) !== EMPTY || at(solo, x, row * 2 + 1) !== EMPTY) g.text.delete(key)
200  }
201}
202
203function drawRoad(g: Grid, s: Scene, spots: Spot[], cam: number, pw: number): void {
204  const { t, story } = s
205  for (let x = 0; x < s.width; x++) {
206    const wx = cam + x
207    const spot = spots.find(p => wx >= p.from && wx < p.from + pw)
208    if (spot) REST_PLACES[spot.kind].col(g, x, wx - spot.from, t, cam, pw)
209    else ZONE_PLACES[zoneAt(story, wx)].col(g, x, wx, t, cam, Number.POSITIVE_INFINITY)
210  }
211  for (const spot of [...spots].reverse()) {
212    const ox = spot.from - cam
213    if (ox < s.width && ox + pw > 0) REST_PLACES[spot.kind].props?.(g, ox, t, pw)
214  }
215  const gate = story.gate
216  if (gate) for (let lx = 0; lx < GATE_W; lx++) gateCol(g, gate.x - cam + lx, lx, t)
217}
218
219// A signpost just before each rest spot, and the next zone's name over its gate; names stay clear
220// of Clawd and out of the compact view.
221function drawSigns(g: Grid, s: Scene, spots: Spot[], cam: number, me: ClawdPlace | null): void {
222  const clear = (from: number, end: number) => !me || end < me.x - 1 || from > me.x + 16
223  for (const spot of spots) {
224    const x = spot.from - SIGN_BEFORE - cam
225    if (x < -4 || x > s.width) continue
226    rect(g, x, 6, 1, 4, 0x6b4a2b)
227    rect(g, x - 2, 5, 6, 2, 0x8a5a2b)
228    const label = SIGNS[spot.kind]
229    const end = x - 1 + label.length
230    if (!isCompact(s) && clear(x - 1, end) && x - 1 >= 0 && end <= s.width) write(g, x - 1, 1, label, 0xe9e6df)
231  }
232  const gate = s.story.gate
233  if (!gate || isCompact(s)) return
234  const name = ZONE_NAMES[s.story.zone ?? 'forest']
235  const x = gate.x - cam + Math.floor((GATE_W - name.length) / 2)
236  if (x >= 0 && x + name.length <= s.width && clear(x, x + name.length)) write(g, x, 0, name, 0xc58bff)
237}
238
239// At the pier: rod out, the line to a bobbing float, and now and then a fish pulled in.
240function fishing(g: Grid, x: number, t: number): void {
241  line(g, x + 15, 4, x + 20, 0, 0xa0714a)
242  const k = t % FISH_MS
243  const float = x + 34
244  const dip = k > 4800 && k < 5600
245  const y = dip ? 9 : 7 + (Math.floor(t / 1200) % 2 === 1 ? 0 : -1)
246  line(g, x + 20, 0, float, y, 0x6b7390, 2)
247  if (dip) {
248    g.px[7 * g.w + float - 1] = 0xf4f4f4
249    g.px[7 * g.w + float + 1] = 0xf4f4f4
250  } else {
251    sprite(g, float, y - 1, ['R', 'W'], { R: 0xff4b4b, W: 0xf4f4f4 })
252  }
253  if (k > 5600 && k < 7000) {
254    const q = through(k, 5600, 1400)
255    sprite(g, Math.round(lerp(float, x + 18, q)), Math.round(lerp(7, 0, q)), ['GGG', 'GGTT'], { G: 0xc8d0e8, T: 0x9aa0b2 })
256  }
257}
258
259const REST_CAPTIONS: Record<Exclude<RestKind, 'inn'>, [string, number]> = {
260  pier: ['waiting for you…', 0x7fd1ff],
261  camp: ['idle · warming up', 0xffb27a],
262  crystal: ['idle · by the save crystal', 0x7fd1ff],
263  ramen: ['idle · slurp…', 0xffb27a],
264}
265
266function restCaption(s: Scene, rest: Rest | null): [string, number] | null {
267  if (!rest || rest.phase !== 'rest') return null
268  if (rest.kind !== 'inn') return REST_CAPTIONS[rest.kind]
269  return [s.story.trip?.compactAt !== null ? 'z Z  ·  HP refilling' : 'z Z', 0x9be38a]
270}
271
272export function frame(s: Scene): Grid {
273  const g = grid(s.width)
274  const { t, story } = s
275  const cam = camOf(s.distance, s.width)
276  const pw = placeWidth(s.width)
277  const spots = spotsOn(s)
278  drawRoad(g, s, spots, cam, pw)
279  const beat = beatOf(story, t)
280  const me = clawdAt(s)
281  drawSigns(g, s, spots, cam, me)
282  const rest = beat === 'idle' || beat === 'walk' ? restOf(story, t, s.distance, s.width) : null
283  const called = story.calledAt !== null && t - story.calledAt < CALL_MS
284  const anchor = me?.x ?? Math.min(CLAWD_COL, fightX(s) - REACH)
285  const calledOut = !isCompact(s) && (story.turn.active || beat === 'victory') && drawParty(g, s, beat, anchor)
286  if (!isCompact(s)) drawFight(g, s, beat, anchor)
287  if (me) {
288    clawd(g, me.x, me.y, me.pose)
289    if (me.strike) sword(g, me.x, 1, me.pose === POSES.strike)
290    if (beat === 'enemyTurn') sparks(g, me.x, 1, t)
291    if (beat === 'flee') dust(g, me.x, t)
292    if (rest?.phase === 'rest' && rest.kind === 'pier' && !called) fishing(g, me.x, t)
293    if (me.tucked) INN.front?.(g, me.x - CLAWD_COL, t, pw)
294    if (me.counter) RAMEN.front?.(g, me.x - CLAWD_COL, t, pw)
295    if (called) bang(g, me.x, t)
296  }
297  if (beat === 'victory' && story.victory && !isCompact(s)) {
298    const fx = fightX(s)
299    chest(g, Math.max(CLAWD_COL + 18, fx - 4), 4, t - story.victory.at > 400, t)
300    if (t - story.victory.at > 600) caption(g, `◆ ${drawable(story.victory.loot).join('')} · +${story.victory.gained} EXP`, 0xffd54f, me?.x ?? CLAWD_COL)
301  } else if (!isCompact(s) && me && !calledOut) {
302    const boss = story.foe?.boss ? BOSS_NAMES[story.foe.kind] : undefined
303    const quest = questOf(story)
304    // The task's own words may hold characters the band can't draw one cell wide.
305    const said: [string, number] | null = boss
306      ? [`★ BOSS · ${boss}`, 0xffd54f]
307      : (restCaption(s, rest) ?? (quest ? [`☐ ${drawable(quest.slice(2)).join('')}`, 0xd7c9a0] : null))
308    if (said) caption(g, said[0], said[1], me.x)
309  }
310  drawHud(g, s.stats)
311  if (me) clearOff(g, me)
312  if (story.levelUp && t >= story.levelUp.at && t - story.levelUp.at < LEVEL_UP_MS && !isCompact(s)) {
313    write(g, hudLeft(s.width, s.stats), 3, `LEVEL UP!  Lv.${story.levelUp.level}`, 0xffd54f)
314  }
315  return g
316}
317
hooks/rpg/grid.ts 48 lines
1export const ROWS = 5
2export const PX_H = ROWS * 2
3export const EMPTY = -1
4
5export type Grid = { w: number; px: Int32Array; text: Map<number, { cp: number; fg: number }> }
6
7export function grid(w: number): Grid {
8  return { w, px: new Int32Array(w * PX_H).fill(EMPTY), text: new Map() }
9}
10
11export function put(g: Grid, x: number, y: number, c: number): void {
12  const px = Math.round(x)
13  const py = Math.round(y)
14  if (px < 0 || py < 0 || px >= g.w || py >= PX_H) return
15  g.px[py * g.w + px] = c
16}
17
18export const at = (g: Grid, x: number, y: number): number => (x < 0 || y < 0 || x >= g.w || y >= PX_H ? EMPTY : g.px[y * g.w + x]!)
19
20export function rect(g: Grid, x: number, y: number, w: number, h: number, c: number): void {
21  for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) put(g, x + i, y + j, c)
22}
23
24// A straight line of pixels, every `every`th one (2 gives a dotted fishing line).
25export function line(g: Grid, x0: number, y0: number, x1: number, y1: number, c: number, every = 1): void {
26  const n = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0), 1)
27  for (let i = 0; i <= n; i += every) put(g, x0 + ((x1 - x0) * i) / n, y0 + ((y1 - y0) * i) / n, c)
28}
29
30export function sprite(g: Grid, x: number, y: number, rows: readonly string[], pal: Readonly<Record<string, number>>): void {
31  rows.forEach((row, j) => {
32    for (let i = 0; i < row.length; i++) {
33      const c = pal[row[i]!]
34      if (c !== undefined) put(g, x + i, y + j, c)
35    }
36  })
37}
38
39// Text takes whole cells and wins over the pixels there; cells.ts gives it their colour as background.
40export function write(g: Grid, col: number, row: number, s: string, fg: number): void {
41  let i = 0
42  for (const ch of s) {
43    const c = col + i++
44    if (c < 0 || c >= g.w || row < 0 || row >= ROWS) continue
45    g.text.set(row * g.w + c, { cp: ch.codePointAt(0)!, fg })
46  }
47}
48
hooks/rpg/progress.ts 86 lines
1import type { ZoneId } from '../../types'
2
3export type { ZoneId }
4
5export type AwardKind = 'battle' | 'elite' | 'raid' | 'turn'
6// `boss`: the raid was the boss of `zone`, which clears it, unless another session already has.
7export type Award = { kind: AwardKind; foe?: string; boss?: true; zone?: ZoneId }
8
9// `exp` is what he has towards the next level, not a lifetime total.
10export type Progress = {
11  v: 1
12  exp: number
13  level: number
14  zone: ZoneId
15  zoneMeter: number
16  unlocked: ZoneId[]
17  bestiary: Record<string, number>
18  roadPos: number
19}
20
21export const EXP: Record<AwardKind, number> = { battle: 10, elite: 25, raid: 60, turn: 5 }
22export const UNLOCK_LEVEL: Record<ZoneId, number> = { forest: 1, dungeon: 3, neon: 6 }
23const ZONES = Object.keys(UNLOCK_LEVEL) as ZoneId[]
24// Battles won in a zone before its boss turn.
25export const ZONE_BATTLES = 12
26
27export const toNext = (level: number) => 50 + 25 * level
28
29export const FRESH: Progress = { v: 1, exp: 0, level: 1, zone: 'forest', zoneMeter: 0, unlocked: ['forest'], bestiary: {}, roadPos: 0 }
30
31const num = (value: unknown, min: number, fallback: number) => (typeof value === 'number' && Number.isFinite(value) && value >= min ? value : fallback)
32const unlockedAt = (level: number) => ZONES.filter(z => level >= UNLOCK_LEVEL[z])
33
34// Stored progress may come from an older build, another hand, or nothing at all: every field
35// falls back on its own, and anything that isn't a save is kept aside, not overwritten. A newer
36// build's save is never touched: a session still running this build would wipe it.
37export function loadProgress(raw: unknown): { progress: Progress; backup?: unknown; isNewer?: true } {
38  if (raw === undefined || raw === null) return { progress: FRESH }
39  const v = typeof raw === 'object' ? (raw as { v?: unknown }).v : undefined
40  if (typeof v === 'number' && v > 1) return { progress: FRESH, isNewer: true }
41  if (v !== 1) return { progress: FRESH, backup: raw }
42  const r = raw as Record<string, unknown>
43  const level = Math.floor(num(r.level, 1, 1))
44  const zone = ZONES.includes(r.zone as ZoneId) ? (r.zone as ZoneId) : 'forest'
45  const bestiary: Record<string, number> = {}
46  if (typeof r.bestiary === 'object' && r.bestiary !== null) {
47    for (const [k, n] of Object.entries(r.bestiary)) if (typeof n === 'number' && n > 0) bestiary[k] = Math.floor(n)
48  }
49  return {
50    progress: {
51      v: 1,
52      exp: Math.min(num(r.exp, 0, 0), toNext(level) - 1),
53      level,
54      zone: unlockedAt(level).includes(zone) ? zone : 'forest',
55      zoneMeter: Math.min(Math.floor(num(r.zoneMeter, 0, 0)), ZONE_BATTLES),
56      unlocked: unlockedAt(level),
57      bestiary,
58      roadPos: num(r.roadPos, 0, 0),
59    },
60  }
61}
62
63export function gain(p: Progress, award: Award): { progress: Progress; levelsUp: number } {
64  let exp = p.exp + EXP[award.kind]
65  let level = p.level
66  while (exp >= toNext(level)) {
67    exp -= toNext(level)
68    level++
69  }
70  const won = award.kind === 'battle' || award.kind === 'elite'
71  const bestiary = award.foe ? { ...p.bestiary, [award.foe]: (p.bestiary[award.foe] ?? 0) + 1 } : p.bestiary
72  const next: Progress = { ...p, exp, level, unlocked: unlockedAt(level), zoneMeter: won ? Math.min(ZONE_BATTLES, p.zoneMeter + 1) : p.zoneMeter, bestiary }
73  const clears = award.boss && (award.zone ?? p.zone) === p.zone
74  return { progress: clears ? { ...next, zone: nextZone(next), zoneMeter: 0 } : next, levelsUp: level - p.level }
75}
76
77export const bossDue = (p: Progress) => p.zoneMeter >= ZONE_BATTLES
78
79// The unlocked zone after his, in road order, back to the forest after the last.
80export function nextZone(p: Progress): ZoneId {
81  const open = unlockedAt(p.level)
82  return open[(open.indexOf(p.zone) + 1) % open.length] ?? 'forest'
83}
84
85export const expFraction = (p: Progress) => p.exp / toNext(p.level)
86
hooks/rpg/report.ts 68 lines
1import type { ZoneId } from '../../types'
2import { BOSSES } from './director'
3import { COMPACT_BELOW } from './road'
4import { HIDDEN_BELOW } from './frame'
5import { UNLOCK_LEVEL, ZONE_BATTLES, bossDue, toNext } from './progress'
6import type { Progress } from './progress'
7
8const ZONE_NAMES: Record<ZoneId, string> = { forest: 'Enchanted Forest', dungeon: 'Dungeon', neon: 'Neon City' }
9const BOSS_NAMES: Record<string, string> = { treant: 'Treant', hydra: 'Merge Hydra', mech: 'Mech' }
10const ZONE_ORDER: ZoneId[] = ['forest', 'dungeon', 'neon']
11
12// What /rpg stats says: his level, the zone and its boss, what's open, and what he's met.
13export function statsText(p: Progress): string {
14  const boss = BOSS_NAMES[BOSSES[p.zone]]!
15  const meter = bossDue(p) ? `the ${boss} waits: the next turn that fights is a boss fight` : `${p.zoneMeter}/${ZONE_BATTLES} battles to the ${boss}`
16  const open = ZONE_ORDER.filter(z => p.level >= UNLOCK_LEVEL[z])
17  const next = ZONE_ORDER.find(z => p.level < UNLOCK_LEVEL[z])
18  const met = Object.entries(p.bestiary).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
19  return [
20    `Clawd · Lv.${p.level} · ${p.exp}/${toNext(p.level)} EXP to Lv.${p.level + 1}`,
21    `Zone: ${ZONE_NAMES[p.zone]} · ${meter}`,
22    `Zones open: ${open.map(z => ZONE_NAMES[z]).join(', ')}${next ? ` · ${ZONE_NAMES[next]} opens at Lv.${UNLOCK_LEVEL[next]}` : ''}`,
23    `Bestiary: ${met.length ? met.map(([kind, n]) => `${kind} ×${n}`).join(', ') : 'nothing met yet'}`,
24  ].join('\n')
25}
26
27// `width`: the band's last drawn width, null before it has been drawn; `lastBlitMs`: how long ago the
28// last frame went out.
29export type DoctorFacts = {
30  hidden: boolean
31  width: number | null
32  painting: boolean
33  lastBlitMs: number | null
34  save: 'fine' | 'backup' | 'newer' | 'broken'
35  level: number
36  zone: ZoneId
37  clawdBar: boolean
38}
39
40function view(width: number): string {
41  if (width < HIDDEN_BELOW) return `too narrow: the band hides below ${HIDDEN_BELOW}`
42  if (width < COMPACT_BELOW) return 'compact view: Clawd and the place, no fights'
43  return 'full view'
44}
45
46const SAVES: Record<DoctorFacts['save'], (f: DoctorFacts) => string> = {
47  fine: f => `fine (Lv.${f.level}, ${ZONE_NAMES[f.zone]})`,
48  backup: () => "an unreadable save was kept as 'progressBackup' and a new one started",
49  newer: () => 'written by a newer clawd-rpg; this session plays on without saving',
50  broken: () => "the plugin store can't be read; progress lives only in this session",
51}
52
53// What /rpg doctor says: is the band up and painting, is the save healthy, is clawd-bar's band there too.
54export function doctorText(f: DoctorFacts): string {
55  let band = 'hidden (/rpg brings it back)'
56  if (!f.hidden && f.width === null) band = 'shown, not drawn yet this session'
57  else if (!f.hidden && f.width !== null) {
58    const frames = f.painting ? `painting frames${f.lastBlitMs === null ? '' : ` (last one ${(f.lastBlitMs / 1000).toFixed(1)} s ago)`}` : 'not painting (it starts at the next redraw)'
59    band = `shown, last drawn ${f.width} columns wide (${view(f.width)}), ${frames}`
60  }
61  return [
62    'clawd-rpg doctor',
63    `· Band: ${band}`,
64    `· Save: ${SAVES[f.save](f)}`,
65    `· clawd-bar: ${f.clawdBar ? 'also enabled, and its band stacks with this one: /clawd hides it' : 'not enabled, so no second band'}`,
66  ].join('\n')
67}
68
hooks/rpg/road.ts 165 lines
1import type { RestKind, Story, Trip, ZoneId } from '../../types'
2import { CALL_MS, beatOf } from './director'
3import { GATE_W } from './places/gate'
4import { LEG_STEP_MS } from './sprites/clawd'
5
6export const CLAWD_COL = 50
7export const COMPACT_COL = 4
8// Below this a fight can't fit left of the HUD, so the band shows Clawd and the place only.
9export const COMPACT_BELOW = 100
10
11export const clawdCol = (width: number) => (width < COMPACT_BELOW ? COMPACT_COL : CLAWD_COL)
12
13// clawd-bar's DOZE_AFTER_MS and SLEEP_AFTER_MS: a minute idle he moves on to the campfire, ten to the inn.
14export const DOZE_MS = 60_000
15export const SLEEP_MS = 600_000
16// Reeling in, or getting up, before he walks back to work.
17export const PACK_MS = 1200
18// Three tool calls inside this window and he hurries.
19export const HURRY_WINDOW_MS = 8000
20
21// 12.5 frames a second: a hurried walk moves one pixel a frame, and his quickest steps (four
22// poses in 320 ms) show every pose; at 160 ms they alias and his legs freeze or flap.
23export const FRAME_MS = 80
24
25// Paces in road pixels per four frames: whole pixels only, since a fraction scrolls unevenly.
26const WALK = 2
27const HURRY = 4
28
29// Each zone's own rest spot, where he goes after a minute idle.
30export const NOOK: Record<ZoneId, RestKind> = { forest: 'camp', dungeon: 'crystal', neon: 'ramen' }
31// A signpost stands this far before each rest spot.
32export const SIGN_BEFORE = 12
33
34const schedule = (trip: Trip): [RestKind, number][] => [
35  ['pier', 0],
36  [NOOK[trip.zone ?? 'forest'], DOZE_MS],
37  ['inn', SLEEP_MS],
38]
39
40// The rest spots a trip has reached by `now`, in order. A compaction goes straight to the inn, and
41// the inn is always the last stop.
42export function stopsOf(trip: Trip, now: number): RestKind[] {
43  const until = trip.leftAt ?? now
44  const due: [RestKind, number][] = schedule(trip).map(([kind, after]) => [kind, trip.at + after])
45  if (trip.compactAt !== null) due.push(['inn', trip.compactAt])
46  due.sort((a, b) => a[1] - b[1])
47  const stops: RestKind[] = []
48  for (const [kind, at] of due) {
49    if (at > until || stops.includes(kind)) continue
50    stops.push(kind)
51    if (kind === 'inn') break
52  }
53  return stops
54}
55
56// A stopped scene fills the band, so a rest spot is one band wide. In the compact view Clawd stands
57// further left, so the spot stretches to keep its props where he sits.
58export const placeWidth = (width: number) => width + CLAWD_COL - clawdCol(width)
59
60// The road column at the band's left edge, when the camera is at `distance`.
61export const camOf = (distance: number, width: number) => Math.floor(distance) + CLAWD_COL - clawdCol(width)
62
63export type Spot = { kind: RestKind; from: number }
64
65export function spotsOf(trip: Trip, now: number, width: number): Spot[] {
66  const pw = placeWidth(width)
67  return stopsOf(trip, now).map((kind, i) => ({ kind, from: trip.origin + (i + 1) * pw }))
68}
69
70// Where the road takes him while he rests: the last spot his trip has reached.
71export function goalOf(story: Story, now: number, width: number): Spot | null {
72  const trip = story.trip
73  if (!trip || trip.leftAt !== null) return null
74  return spotsOf(trip, now, width).at(-1) ?? null
75}
76
77// The rest spots on the road, the current trip's first. A last-trip spot that overlaps one of
78// them is dropped whole, so its props never show inside the place he's at.
79export function spotsOnRoad(story: Story, now: number, width: number): Spot[] {
80  const pw = placeWidth(width)
81  const mine = story.trip ? spotsOf(story.trip, now, width) : []
82  const old = story.trail ? spotsOf(story.trail, now, width) : []
83  return [...mine, ...old.filter(p => mine.every(q => p.from + pw <= q.from || q.from + pw <= p.from))]
84}
85
86export function placeAt(story: Story, wx: number, now: number, width: number): Spot | null {
87  const pw = placeWidth(width)
88  return spotsOnRoad(story, now, width).find(p => wx >= p.from && wx < p.from + pw) ?? null
89}
90
91// The zone a road pixel is in: behind the last gate, the zone he came from.
92export const zoneAt = (story: Story, wx: number): ZoneId => (story.gate && wx < story.gate.x ? story.gate.from : (story.zone ?? 'forest'))
93
94// A gate stands this far past where a turn ended (just ahead of him, before the pier), or, when
95// the zone changed while he was away, this far past where he picks up the walk.
96export const GATE_AFTER = 66
97export const GATE_AHEAD = 90
98
99// Where a gate can stand at or past `x`: never inside a rest spot or over its signpost.
100export function gateAt(story: Story, now: number, width: number, x: number): number {
101  const pw = placeWidth(width)
102  let at = x
103  for (const spot of spotsOnRoad(story, now, width).sort((a, b) => a.from - b.from)) {
104    if (at + GATE_W > spot.from - SIGN_BEFORE - 3 && at < spot.from + pw) at = spot.from + pw + 2
105  }
106  return at
107}
108
109// A turn ended short of a gate placed earlier: it moves to just past him, so the new trip's spots
110// never stand on it.
111export function gateClear(story: Story, now: number, width: number): Story {
112  const { gate, trip } = story
113  if (!gate || !trip || gate.x < trip.origin) return story
114  return { ...story, gate: { ...gate, x: gateAt({ ...story, gate: null }, now, width, trip.origin + GATE_AFTER) } }
115}
116
117export type Rest = { kind: RestKind; phase: 'travel' | 'rest' | 'pack'; since: number }
118
119// What resting looks like now: on his way to a spot, there, or packing up after a prompt.
120export function restOf(story: Story, now: number, distance: number, width: number): Rest | null {
121  const trip = story.trip
122  if (!trip) return null
123  const d = Math.floor(distance)
124  if (trip.leftAt !== null) {
125    const left = spotsOf(trip, trip.leftAt, width).at(-1)
126    return left && d === left.from && now - trip.leftAt < PACK_MS ? { kind: left.kind, phase: 'pack', since: trip.leftAt } : null
127  }
128  const goal = goalOf(story, now, width)
129  if (!goal) return null
130  return { kind: goal.kind, phase: d === goal.from ? 'rest' : 'travel', since: trip.at }
131}
132
133// The band changed width under him. Spots stand a band apart, so they all move; if he was at one,
134// resting or packing, put him on the same spot at the new width instead of walking him there.
135export function reanchor(story: Story, now: number, distance: number, from: number, to: number): number {
136  const rest = from === to ? null : restOf(story, now, distance, from)
137  if (!rest || rest.phase === 'travel') return distance
138  const spot = spotsOf(story.trip!, story.trip!.leftAt ?? now, to).find(p => p.kind === rest.kind)
139  return spot?.from ?? distance
140}
141
142const isHurried = (story: Story, now: number) => {
143  const recent = story.turn.recent ?? []
144  return recent.length >= 3 && now - recent[recent.length - 3]! <= HURRY_WINDOW_MS
145}
146
147export type Motion = { distance: number; isWalking: boolean; legMs: number }
148
149// One frame of the road: how far the camera moves, given the story and whether Claude is working.
150export function moveRoad(story: Story, now: number, distance: number, width: number, frame: number, isWorking: boolean, trudge: boolean): Motion {
151  const still: Motion = { distance, isWalking: false, legMs: LEG_STEP_MS }
152  const beat = beatOf(story, now)
153  if (beat !== 'walk' && beat !== 'idle') return still
154  if (story.calledAt !== null && now - story.calledAt < CALL_MS) return still
155  if (restOf(story, now, distance, width)?.phase === 'pack') return still
156  const goal = isWorking ? null : goalOf(story, now, width)
157  if (!isWorking && !goal) return still
158  // Past his goal (a resize the timer hasn't caught up with): back onto it, never stranded in the forest.
159  if (goal && distance >= goal.from) return { ...still, distance: goal.from }
160  let rate = goal || isHurried(story, now) ? HURRY : WALK
161  if (trudge) rate /= 2
162  const step = Math.floor(((frame + 1) * rate) / 4) - Math.floor((frame * rate) / 4)
163  return { distance: goal ? Math.min(goal.from, distance + step) : distance + step, isWalking: true, legMs: (LEG_STEP_MS * WALK) / rate }
164}
165
hooks/rpg/stats.ts 12 lines
1import type { RpgStats } from '../../types'
2import { expFraction } from './progress'
3import type { Progress } from './progress'
4
5export type Usage = { context?: { percent?: number }; rateLimits?: { kind: string; percentUsed: number }[] }
6
7export function statsFrom(usage: Usage | null, place: string, progress: Progress): RpgStats {
8  const ctx = usage?.context?.percent ?? 0
9  const fiveHour = usage?.rateLimits?.find(l => l.kind === 'five_hour')?.percentUsed ?? 0
10  return { level: progress.level, exp: expFraction(progress), hp: 1 - ctx / 100, mp: 1 - fiveHour / 100, place }
11}
12
hooks/rpg/sprites/clawd.ts 106 lines
1import { put, rect } from '../grid'
2import type { Grid } from '../grid'
3
4export const ORANGE = 0xde886d
5export const EYE = 0x000000
6export const CLAWD_W = 15
7export const CLAWD_H = 9
8export const LEG_STEP_MS = 320
9
10export type Pose = {
11  armL?: number
12  armR?: number
13  top?: number
14  dx?: number
15  // Which pair of legs is off the ground: 'odd' is his first and third from the left, 'even' the others.
16  lift?: 'odd' | 'even'
17  face?: 'eyes' | 'shut' | 'happy'
18  fx?: number
19  fy?: number
20  fh?: number
21  hy?: number
22  sit?: boolean
23}
24
25// Ported from clawd-bar 0.3.0's hooks/sprites.ts with the shadow row dropped. Pose numbers keep
26// 0.3.0's grid (rows 6..14), so `y` here is the top of his head and row n draws at y + n - 6.
27export function clawd(g: Grid, x: number, y: number, o: Pose = {}): void {
28  const dx = o.dx ?? 0
29  if (o.sit) {
30    const top = o.top ?? 9
31    rect(g, x + 2, y + top - 6, 11, 15 - top, ORANGE)
32    rect(g, x, y + 7, 2, 2, ORANGE)
33    rect(g, x + 13, y + 7, 2, 2, ORANGE)
34    rect(g, x + 4, y + top - 3, 2, 1, EYE)
35    rect(g, x + 9, y + top - 3, 2, 1, EYE)
36    return
37  }
38  const top = o.top ?? 6
39  for (const [i, lx] of [3, 5, 9, 11].entries()) rect(g, x + lx, y + 7, 1, o.lift === (i % 2 === 0 ? 'odd' : 'even') ? 1 : 2, ORANGE)
40  rect(g, x + 2 + dx, y + top - 6, 11, 13 - top, ORANGE)
41  rect(g, x + dx, y + (o.armL ?? 9) - 6, 2, 2, ORANGE)
42  rect(g, x + 13 + dx, y + (o.armR ?? 9) - 6, 2, 2, ORANGE)
43  const face = o.face ?? 'eyes'
44  if (face === 'eyes') {
45    const fx = o.fx ?? 0
46    const fy = o.fy ?? 0
47    const fh = o.fh ?? 2
48    rect(g, x + 4 + fx + dx, y + 2 + fy, 1, fh, EYE)
49    rect(g, x + 10 + fx + dx, y + 2 + fy, 1, fh, EYE)
50  } else if (face === 'shut') {
51    rect(g, x + 4 + dx, y + 3, 2, 1, EYE)
52    rect(g, x + 9 + dx, y + 3, 2, 1, EYE)
53  } else {
54    const hy = o.hy ?? 0
55    for (const [ex, ey] of [[3, 3], [4, 2], [5, 3], [9, 3], [10, 2], [11, 3]] as const) put(g, x + ex + dx, y + ey + hy, EYE)
56  }
57}
58
59export const POSES = {
60  stand: {},
61  right: { fx: 1 },
62  blink: { fy: 1, fh: 1 },
63  ponder: { fx: 1, fy: -1 },
64  cheer: { armL: 8, armR: 8, face: 'happy' },
65  bounce: { armL: 10, armR: 10, top: 7, face: 'happy', hy: 1 },
66  slump: { armL: 10, armR: 10, face: 'shut' },
67  swing: { armR: 7, fx: 1, fy: 1 },
68  strike: { armR: 10, fx: 1, fy: 1 },
69  squeeze: { armL: 10, armR: 10, top: 8, fy: 1, fh: 1 },
70  stretch: { armL: 7, armR: 7, fy: -1 },
71  sit: { sit: true, top: 9 },
72  sitOut: { sit: true, top: 10 },
73} as const satisfies Record<string, Pose>
74
75// Each step plants all four feet for half of `legMs`, then lifts one pair for the other half.
76const stride = (t: number, legMs: number): Pose['lift'] => ([undefined, 'odd', undefined, 'even'] as const)[Math.floor((2 * t) / legMs) % 4]
77
78// A waddle: the arm on the side of the lifted legs swings up, the other down.
79// `legMs`: how long each step takes; he hurries with quicker steps.
80export const walking = (t: number, legMs = LEG_STEP_MS): Pose => {
81  const lift = stride(t, legMs)
82  if (lift === 'odd') return { fx: 1, lift, armL: 8, armR: 10 }
83  if (lift === 'even') return { fx: 1, lift, armL: 10, armR: 8 }
84  return { fx: 1 }
85}
86
87// Out of usage: half-pace steps, arms hanging.
88export const trudging = (t: number, legMs = 2 * LEG_STEP_MS): Pose => ({ fx: 1, fy: 1, armL: 10, armR: 10, lift: stride(t, legMs) })
89
90export const cheering = (t: number): Pose => (Math.floor(t / 480) % 2 === 1 ? POSES.bounce : POSES.cheer)
91
92// Knocked back: eyes shut, arms down, shaking on the spot.
93export const hurting = (t: number): Pose => ({ armL: 10, armR: 10, face: 'shut', dx: Math.floor(t / 160) % 2 === 1 ? 1 : -1 })
94
95export const idling = (t: number): Pose => {
96  if (t % 5200 < 160) return POSES.blink
97  const glance = t % 10400
98  return glance > 6800 && glance < 8800 ? POSES.right : POSES.stand
99}
100
101// By the campfire: sitting, leaning out now and then.
102export const sitting = (t: number): Pose => (Math.floor(t / 2600) % 2 === 1 ? POSES.sitOut : POSES.sit)
103
104// In bed: sitting low, breathing slowly; the inn's blanket covers him from row 6 down.
105export const asleep = (t: number): Pose => ({ sit: true, top: Math.floor(t / 2600) % 2 === 1 ? 10 : 9 })
106