A flip-screen jungle run above the prompt, drawn in octant blocks. Each tool call lays the next screen's hazard, finished tasks are treasure, and the explorer…

A flip-screen jungle run in a band above your Claude Code prompt. Every tool call Claude makes lays the next screen's hazard, and a little explorer crosses it on autopilot. You just watch. There's nothing to play.

/jungle demo: one screen of every hazard, from a single log to the fire.
It's drawn in the style of the early 80s home console jungle games: flat bands of canopy, jungle and dirt, black tar pits, swinging vines, crocodile heads in a pond. Every pixel is our own.
| Happening in Claude Code | On the next screen |
|---|---|
Read | A log to hop |
Edit, Write | Logs rolling at him |
Bash | A tar pit to jump |
Bash running tests, a build or an install, or in the background | Quicksand that opens and closes, so he has to time it |
Grep, Glob, a subagent | A pit with a vine to swing across |
WebFetch, WebSearch | A pond of three crocodiles to cross on their heads |
| An MCP tool | A pond with two crocodiles |
| A call that fails or is refused | Fire |
A new task (TaskCreate or TodoWrite) | He stops to mark it on his map |
| A task finished | Treasure lies on the ground, and he holds it up when he gets there |
| A permission dialog opens | A red ! beside his hat and a red caption |
| The turn ends | He makes camp by a fire |
The caption under the band names the file, pattern, command or host the call was about. With a task list, a tally shows how many are done and what's next. A finished task only counts once he has picked up its treasure.
At camp he waves, wipes his brow, reads the map or looks back down the trail. After 20 seconds he sits and pokes the fire, after 75 he dozes, and after 3 minutes the band hides until your next prompt.
The art is drawn in octants: Unicode 16 block characters that split each cell into 2x4 pixels. Your terminal or font has to draw them.
Needs a Claude Code build with mods (function hooks). Built and tested on 2.1.304. Run claude --version to see yours.
claude plugin marketplace add plaxagoras/jungle-run
claude plugin install jungle-run@jungle-run
Start a new session, then run /jungle demo.
To try it for one session without installing:
git clone https://github.com/plaxagoras/jungle-run
claude --plugin-dir ./jungle-run
It draws in the same spot above the prompt as other band mods (clawd-tales, for one), so run one at a time.
| Command | What it does |
|---|---|
/jungle on | Wakes the run for this session |
/jungle off | Hides it |
/jungle demo | One screen of every hazard |
Every session starts off, so you choose per session whether it runs.
tool.call, turn.start, turn.complete, classic.PermissionRequest and the task events, and draws with ui.render on the AbovePrompt band.The explorer's body frames come from tools/rig.py, and everything else is drawn by hand in art/frames.txt. Rebuild with python3 -I tools/build.py (Pillow is only needed for the preview image). Tests: claude plugin test .
MIT. See LICENSE.
hooks/register.tsx 374 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3import type { Scene } from '../types'
4import { FRAMES } from './frames'
5import type { FrameName } from './frames'
6import { STAGE_ROWS, TICK_MS, blank, fill, octantLines, paint } from './art'
7import type { Pose } from './art'
8import { detail, ending, newlyDone, readTodos, tally } from './story'
9import { STAND, exitX, footing, hash, jungleCaption, layout, plan, scenery, screenFor, treasureX } from './jungle'
10import { camp } from './idle'
11import type { Kind, Screen } from './jungle'
12import type { Todo } from './story'
13
14const IDLE: Pose = { frame: 'idle', x: 0, lift: 0, flip: false, label: 'idle' }
15const HIDDEN: Scene = { visible: false, hero: IDLE, fx: [], bang: false, caption: '', alert: false, tally: null, jungle: null }
16const scene = atom({ plugin: 'jungle-run', key: 'scene' } as const, HIDDEN)
17const isOn = atom({ plugin: 'jungle-run', key: 'isOn' } as const, false)
18
19const HIDE_MS = 180_000
20/** Picking up treasure: stoop for it, then hold it up. */
21const PICKUP: [FrameName, number][] = [['tuck', 2], ['cheer', 9]]
22/** A new task: he stops to mark it on the map. */
23const NOTE: [FrameName, number][] = [['map', 12]]
24/** Where the held-up treasure sits over the cheer pose's fist (frame x, lift over his). */
25const HELD = { dx: 12, lift: 23 }
26const QUEUE_MAX = 4
27/** /jungle demo: one screen of every hazard, in this order. */
28const DEMO: [Kind, number][] = [['log', 1], ['pit', 1], ['logs', 2], ['vine', 1], ['quicksand', 1], ['crocs', 3], ['fire', 1]]
29
30// Stage state. Module variables: a reload starts the run over, which is fine for a toy.
31let timer: Timer | null = null
32let mode: 'hidden' | 'working' | 'done' = 'hidden'
33let caption = ''
34let alertText: string | null = null
35/** The task list by id: TaskCreate ids, or `todo:<n>` for TodoWrite lists. */
36let tasks = new Map<string, Todo>()
37/** activeForm from TaskCreate, by subject, until the TaskCreated event gives the id. */
38const activeBySubject = new Map<string, string>()
39/** Finished tasks waiting to be laid down as treasure. */
40let loot: string[] = []
41let sparkles: { x: number; t: number }[] = []
42/** New tasks waiting to be marked on the map, and the caption while he marks them. */
43let notes: string[] = []
44let noteText = ''
45let doneAt = 0
46let startedAt = 0
47let found = 0
48let stageW = 200
49let tick = 0
50let demoLeft = 0
51
52// The run: tool calls queue screens, finished tasks become treasure, and the autopilot
53// (hooks/jungle.ts plan) crosses each screen.
54let screen: Screen | null = null
55let t = 0
56let path: Pose[] = []
57let pi = 0
58let x = 0
59/** Where this screen's walk ends: the exit, or the camp spot on a resting screen. */
60let stopAt: number | undefined
61/** Ticks since he stopped at camp. */
62let campT = 0
63let taken = false
64let seed = 0
65let queue: { kind: Kind; n: number; caption: string }[] = []
66
67const todoList = () => [...tasks.values()]
68const pending = () => todoList().filter(t => t.status !== 'completed').length
69
70async function begin($: EngineInterface) {
71 if (mode !== 'working') {
72 mode = 'working'
73 startedAt = await $.clock.now()
74 found = 0
75 }
76 ensure($)
77}
78
79function ensure($: EngineInterface) {
80 if (timer) return
81 timer = $.clock.every(TICK_MS, async () => step($))
82}
83
84function stop() {
85 timer?.cancel()
86 timer = null
87}
88
89/** Queue the screen a tool call builds; back-to-back edits pile more logs onto one screen. */
90function queueScreen(kind: Kind, n: number, text: string) {
91 const last = queue[queue.length - 1]
92 if (last && last.kind === 'logs' && kind === 'logs') {
93 last.n = Math.min(3, last.n + 1)
94 last.caption = text
95 return
96 }
97 queue.push({ kind, n, caption: text })
98 if (queue.length > QUEUE_MAX) queue.shift()
99}
100
101/** The next screen: a queued one, an empty stretch while working, or a resting glade after. */
102function flip(working: boolean) {
103 const spec = queue.shift() ?? { kind: 'clear' as const, n: 1, caption: working ? 'runs on' : 'makes camp' }
104 if (demoLeft > 0) demoLeft -= 1
105 const resting = !working && spec.kind === 'clear'
106 // Treasure waits for the next run: the camp spot is short of where it lies.
107 screen = layout(spec.kind, spec.n, stageW, ++seed, resting ? null : (loot.shift() ?? null), spec.caption)
108 t = 0
109 x = 0
110 taken = false
111 stopAt = resting ? Math.floor(stageW / 2 / 4) * 4 - 8 : undefined
112 if (stopAt !== undefined) screen.camp = stopAt
113 campT = 0
114 path = plan(screen, 0, 0, stopAt)
115 pi = 0
116 caption = screen.caption
117}
118
119const beat = (frames: [FrameName, number][], label: string): Pose[] =>
120 frames.flatMap(([frame, n]) => Array.from({ length: n }, () => ({ frame, x, lift: 0, flip: false, label })))
121
122/** Stop where he stands for a beat, then pick the walk back up. False if standing still here isn't safe. */
123function pause(frames: Pose[]): boolean {
124 const s = screen!
125 if (frames.some((_, k) => footing(s, x, t + k + 1) !== 0)) return false
126 path = [...frames, ...plan(s, x, t + frames.length, stopAt)]
127 pi = 0
128 return true
129}
130
131/** One tick: move along the path, flip at the edge, pick up treasure, then publish the scene. */
132async function step($: EngineInterface) {
133 tick += 1
134 const now = await $.clock.now()
135 sparkles = sparkles.map(p => ({ ...p, t: p.t + 1 })).filter(p => p.t < 6)
136 const working = mode === 'working' || demoLeft > 0
137 if (mode === 'done' && !alertText && demoLeft === 0 && now - doneAt >= HIDE_MS) mode = 'hidden'
138 if (!screen) flip(working)
139 t += 1
140 let hero: Pose
141 let props: Pose[] = []
142 if (pi < path.length) {
143 hero = path[pi++]!
144 } else if (x >= exitX(screen!.w)) {
145 flip(working)
146 hero = path[pi++] ?? IDLE
147 } else if (working) {
148 path = plan(screen!, x, t)
149 pi = 0
150 hero = path[pi++] ?? { ...IDLE, x }
151 } else {
152 // Camped: idle bits standing, then sat by the fire, then dozing (hooks/idle.ts).
153 ;({ hero, fx: props } = camp(x, campT++, now - doneAt, screen!.seed))
154 }
155 x = hero.x
156 const s = screen!
157 const grounded = hero.lift === 0 && (hero.label === 'run' || hero.label === 'wait')
158 if (s.treasure && !taken && hero.x + 8 >= treasureX(s.w)) {
159 taken = true
160 found += 1
161 caption = `picks up treasure: ${s.treasure}`
162 sparkles.push({ x: treasureX(s.w) - 4, t: 0 })
163 if (grounded) pause(beat(PICKUP, 'cheer'))
164 } else if (notes.length > 0 && grounded && pause(beat(NOTE, 'note'))) {
165 noteText = notes.length === 1 ? `marks a new task on the map: ${notes[0]}` : `marks ${notes.length} new tasks on the map`
166 notes = []
167 }
168 if (hero.label === 'cheer' && hero.frame === 'cheer' && s.treasure) {
169 // The same gold or silver it lay on the ground as (hooks/jungle.ts scenery).
170 const frame: FrameName = hash(s.seed + 5) < 0.5 ? 'bar' : 'silver'
171 props.push({ frame, x: hero.x + HELD.dx, lift: hero.lift + HELD.lift, flip: false, label: 'treasure' })
172 }
173 const visible = (await read($, isOn)) && (mode !== 'hidden' || alertText !== null || demoLeft > 0)
174 const said = hero.label === 'note' ? noteText : caption
175 const lying = loot.length + (s.treasure && !taken ? 1 : 0)
176 await update($, scene, () => ({
177 visible,
178 hero: { ...hero, lift: hero.lift + STAND },
179 fx: [
180 ...props.map(p => ({ ...p, lift: p.lift + STAND })),
181 ...sparkles.map(p => ({ frame: p.t < 3 ? 'poof1' : 'poof2', x: p.x, lift: STAND, flip: false, label: 'sparkle' })),
182 ],
183 bang: alertText !== null,
184 caption: alertText ?? (demoLeft > 0 ? `${said} (demo)` : said),
185 alert: alertText !== null,
186 tally: tally(todoList(), lying),
187 jungle: { screen: s, t, taken },
188 }))
189 if (!visible && !alertText) stop()
190}
191
192/** A task finished (TaskUpdate or the TaskCompleted hook, whichever comes first): queue its treasure. */
193function complete(id: string, subject?: string) {
194 const task = tasks.get(id) ?? (subject ? { content: subject, status: 'pending' } : undefined)
195 if (!task || task.status === 'completed') return
196 tasks.set(id, { ...task, status: 'completed' })
197 loot.push(task.content)
198}
199
200function fromOtherMod(next: { origin: { plugin: string } }): boolean {
201 return next.origin.plugin !== 'engine'
202}
203
204function topRow(frame: FrameName): number {
205 const i = FRAMES[frame].findIndex(r => /[^.]/.test(r))
206 return i < 0 ? 0 : i
207}
208
209export const register: Register = on => {
210 on('session.start', async ($, e, next) => {
211 await $.command.register({ name: 'jungle', description: 'Jungle run: on, off, demo' })
212 // Every session starts off; /jungle on wakes it for this session only.
213 await update($, isOn, () => false)
214 return next(e)
215 })
216
217 on('session.end', async ($, e, next) => {
218 stop()
219 return next(e)
220 })
221
222 on('command.run', { command: 'jungle' }, async ($, e) => {
223 const arg = (e.args ?? '').trim().toLowerCase()
224 if (arg === 'on' || arg === 'off') {
225 await update($, isOn, () => arg === 'on')
226 if (arg === 'off') {
227 stop()
228 await update($, scene, s => ({ ...s, visible: false }))
229 }
230 return { text: `Jungle run ${arg}.` }
231 }
232 if (arg === 'demo') {
233 await update($, isOn, () => true)
234 queue = DEMO.map(([kind, n]) => ({ kind, n, caption: jungleCaption(kind, '') }))
235 demoLeft = DEMO.length + 1
236 notes = ['a demo task']
237 loot = ['a demo treasure']
238 screen = null
239 ensure($)
240 return { text: 'Jungle demo: one screen of every hazard.' }
241 }
242 return { text: 'Usage: /jungle on|off|demo' }
243 })
244
245 on('turn.start', async ($, e, next) => {
246 await begin($)
247 return next(e)
248 })
249
250 // The dialog is about to show (not just an "ask" verdict auto mode may settle): stop and shout.
251 on('classic.PermissionRequest', async ($, e, next) => {
252 alertText = `needs you: allow ${e.tool_name}?`
253 ensure($)
254 return next(e)
255 })
256
257 on('classic.TaskCreated', async ($, e, next) => {
258 if (!e.teammate_name) {
259 tasks.set(e.task_id, { content: e.task_subject, status: 'pending', activeForm: activeBySubject.get(e.task_subject) })
260 notes.push(e.task_subject)
261 activeBySubject.delete(e.task_subject)
262 await begin($)
263 }
264 return next(e)
265 })
266
267 on('classic.TaskCompleted', async ($, e, next) => {
268 if (!e.teammate_name) complete(e.task_id, e.task_subject)
269 return next(e)
270 })
271
272 on('tool.call', async ($, e, next) => {
273 if (fromOtherMod(next) || e.agentId) return next(e)
274 const args = e as unknown as Record<string, unknown>
275 await begin($)
276 const hazard = screenFor(e.tool, args)
277 if (hazard) queueScreen(hazard.kind, hazard.n, jungleCaption(hazard.kind, detail(e.tool, args)))
278 if (e.tool === 'TodoWrite') {
279 const list = readTodos(args)
280 if (list.length > 0) {
281 const known = new Set(todoList().map(t => t.content))
282 notes.push(...list.filter(t => !known.has(t.content) && t.status !== 'completed').map(t => t.content))
283 loot.push(...newlyDone(todoList(), list).map(t => t.content))
284 tasks = new Map(list.map((t, i) => [`todo:${i}`, t]))
285 }
286 }
287 if (e.tool === 'TaskCreate' && typeof args.subject === 'string' && typeof args.activeForm === 'string') {
288 activeBySubject.set(args.subject, args.activeForm)
289 }
290 if (e.tool === 'TaskUpdate' && typeof args.taskId === 'string') {
291 const task = tasks.get(args.taskId)
292 const status = typeof args.status === 'string' ? args.status : undefined
293 if (task && status === 'deleted') tasks.delete(args.taskId)
294 else if (task && status === 'completed') complete(args.taskId)
295 else if (task) {
296 tasks.set(args.taskId, {
297 content: typeof args.subject === 'string' ? args.subject : task.content,
298 status: status ?? task.status,
299 activeForm: typeof args.activeForm === 'string' ? args.activeForm : task.activeForm,
300 })
301 }
302 }
303 if (e.tool === 'AskUserQuestion') alertText = 'needs you: a question is waiting'
304 if (e.tool === 'ExitPlanMode') alertText = 'needs you: approve the plan?'
305 let result: Awaited<ReturnType<typeof next>>
306 try {
307 result = await next(e)
308 } finally {
309 alertText = null
310 }
311 if (result.deny !== undefined || result.isError) {
312 queueScreen('fire', 1, jungleCaption('fire', `${e.tool} ${result.deny !== undefined ? 'was refused' : 'failed'}`))
313 }
314 return result
315 })
316
317 on('turn.complete', async ($, e, next) => {
318 // A helper's turn ends inside the main one.
319 if (e.agentId) return next(e)
320 if (mode === 'working') {
321 const seconds = Math.round(((await $.clock.now()) - startedAt) / 1000)
322 caption = ending(e.reason === 'error', found, seconds)
323 mode = 'done'
324 doneAt = await $.clock.now()
325 notes = []
326 if (tasks.size > 0 && pending() === 0) tasks = new Map()
327 }
328 return next(e)
329 })
330
331 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
332 if (e.surface === 'mobile' || e.props.hasSurvey || !(await read($, isOn))) return next(e)
333 const s = await read($, scene)
334 if (!s.visible) return next(e)
335 const below = await next(e)
336 const rows = Math.min(STAGE_ROWS, (e.props.maxRows ?? 12) - 1 - (s.tally ? 1 : 0))
337 if (rows < 8) return below
338 const { Box, Text } = $.ui.resolve(e)
339 const cols = Math.max(30, e.props.bodyColumns ?? 80)
340 stageW = cols * 2
341 const tall = rows * 4
342 const grid = blank(stageW, tall)
343 for (const p of [s.hero, ...s.fx]) paint(grid, p as Pose)
344 if (s.bang) {
345 const head = tall - 32 - s.hero.lift + topRow(s.hero.frame as FrameName)
346 // Beside the hat: there's no headroom above it in the lane.
347 fill(grid, s.hero.x + 16, head, 2, 5, 'S')
348 fill(grid, s.hero.x + 16, head + 6, 2, 2, 'S')
349 }
350 const back = s.jungle ? scenery(s.jungle.screen as Screen, s.jungle.t, stageW, tall, s.jungle.taken) : undefined
351 const lines = octantLines(grid.map(r => r.join('')), back)
352 return (
353 <Box flexDirection="column">
354 {lines.map((segs, i) => (
355 <Text key={String(i)}>
356 {segs.map((seg, j) => (
357 <Text key={String(j)} color={seg.color} backgroundColor={seg.backgroundColor}>
358 {seg.text}
359 </Text>
360 ))}
361 </Text>
362 ))}
363 {s.alert ? (
364 <Text color="#e5534b" bold>{`explorer ${s.caption}`}</Text>
365 ) : (
366 <Text dimColor>{`explorer ${s.caption}`}</Text>
367 )}
368 {s.tally ? <Text color="#c9a24a">{s.tally}</Text> : null}
369 {below}
370 </Box>
371 )
372 })
373}
374hooks/frames.ts 35 lines1// Generated by tools/build.py from art/frames.txt. Edit the .txt, then rebuild.
2export const PALETTE: Record<string, string> = {"P": "#e9dfc0", "F": "#e8b48a", "G": "#b8875f", "A": "#d9622b", "M": "#7a4b26", "T": "#5a4426", "Z": "#3e2e18", "K": "#221a12", "S": "#e04848", "W": "#f4f4f4", "g": "#2f5a1a", "h": "#69983f", "t": "#4a4600", "y": "#bdb833", "d": "#807f1c", "k": "#000000", "r": "#6a6410", "w": "#2d6d97", "o": "#eb9a3c", "H": "#f2e24a", "n": "#b8b8c0"}
3export const FRAMES = {
4 run1: ["..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "........PPPP......", ".......PPPPPP.....", ".......PPPPPP.....", ".....PPPPPPPPPP...", "........FFFFF.....", "........FFFFF.....", "........FFFF......", "........FFFF......", "........AAAA...G..", ".....MMMAAAAA.GG..", ".....MMMAAAAAGG...", ".....MMMAAAA......", ".....MMMAAFF......", ".....MMMFFAA......", "........AATA......", ".........TTT......", ".........ZTTT.....", "........ZZZTTT....", ".......ZZZ..TT....", "...KKZZZZ....T....", "....KK.......TT...", ".............TT...", "..............T...", "..............KKK."],
5 run2: ["..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "........PPPP......", ".......PPPPPP.....", ".......PPPPPP.....", ".....PPPPPPPPPP...", "........FFFFF.....", "........FFFFF.....", "........FFFF......", "........FFFF......", "........AAAA......", ".....MMMAAAA......", ".....MMMAAAAA..G..", ".....MMMFAAAGGGG..", ".....MMMAFFAA.....", ".....MMMAAFF......", "........AATA......", ".........TTT......", ".........ZTT......", ".......KKZZTT.....", ".......ZKKZTTT....", "........ZZZTTT....", ".........ZZTT.....", "..........TT......", "..........T.......", "..........KKK....."],
6 run3: ["..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "........PPPP......", ".......PPPPPP.....", ".......PPPPPP.....", ".....PPPPPPPPPP...", "........FFFFF.....", "........FFFFF.....", "........FFFF......", "........FFFF......", "........AAAA......", ".....MMMAAAA......", ".....MMMAAAA..F...", ".....MMMAAAAFFF...", ".....MMMAAAA......", ".....MMMAAAA......", "........AATAZZZ...", ".........TTTZZZ...", ".........TTZZZ....", ".........TT.ZZ....", "........TTT.KK....", "........TT...KK...", ".......TT.........", "......TT..........", "......T...........", "......KKK........."],
7 run4: ["..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "........PPPP......", ".......PPPPPP.....", ".......PPPPPP.....", ".....PPPPPPPPPP...", "........FFFFF.....", "........FFFFF.....", "........FFFF......", "........FFFF......", "........AAAA...F..", ".....MMMAAAAA.F...", ".....MMMAAAAAF....", ".....MMMAAAAA.....", ".....MMMAAAAG.....", ".....MMMAAAA......", "........AATA......", ".........TTT......", "........TTTZZ.....", ".......TTTZZZZZ...", "....TTTTT...ZZZ...", "...KKT.......ZZ...", "....KK........Z...", "..............Z...", "..............Z...", "..............KKK."],
8 run5: ["..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "........PPPP......", ".......PPPPPP.....", ".......PPPPPP.....", ".....PPPPPPPPPP...", "........FFFFF.....", "........FFFFF.....", "........FFFF......", "........FFFF......", "........AAAA......", ".....MMMAAAA......", ".....MMMAAAA..F...", ".....MMMAAAAFFF...", ".....MMMAAAA......", ".....MMMAAAA......", "........AATA......", ".........TTT......", ".........ZTT......", ".......KK.TTZ.....", "........KKTTZZ....", ".........TTTZZ....", "...........ZZ.....", "..........ZZ......", "..........ZZ......", "..........KKK....."],
9 run6: ["..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "........PPPP......", ".......PPPPPP.....", ".......PPPPPP.....", ".....PPPPPPPPPP...", "........FFFFF.....", "........FFFFF.....", "........FFFF......", "........FFFF......", "........AAAA......", ".....MMMAAAA......", ".....MMMAAAAA..G..", ".....MMMFAAAGGGG..", ".....MMMAFFAA.....", ".....MMMAAFF......", "........AATTTTT...", ".........TTTTTT...", ".........ZTZ.TT...", ".........ZZ.TT....", ".........ZZ.KK....", ".........ZZ..KK...", ".........ZZ.......", ".......ZZZZ.......", "......ZZ..........", "......KKK........."],
10 idle: ["..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "........PPPP......", ".......PPPPPP.....", ".......PPPPPP.....", ".....PPPPPPPPPP...", "........FFFFF.....", "........FFFFF.....", "........FFFF......", "........FFFF......", "........AAAA......", ".....MMMAAAA......", ".....MMMAAAA......", ".....MMMAAAA......", ".....MMMAAFFF.....", ".....MMMAAAAFF....", "........AATAG.....", ".........TTT......", ".........ZTTT.....", "..........ZTT.....", "...........ZTT....", "...........ZTT....", "...........ZTZ....", "..........ZTT.....", ".........ZZT......", ".........KKKKK...."],
11 idle2: ["..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "........PPPP......", ".......PPPPPP.....", ".......PPPPPP.....", ".....PPPPPPPPPP...", "........FFFFF.....", "........FFFFF.....", "........FFFF......", "........FFFF......", "........AAAA......", ".....MMMAAAA......", ".....MMMAAAA......", ".....MMMAAAA......", ".....MMMAAAFF.....", ".....MMMAAAAFF....", "........AATAGF....", ".........TTTG.....", ".........ZTTT.....", "..........ZTT.....", "...........ZTT....", "...........ZTT....", "...........ZTZ....", "..........ZTT.....", ".........ZZT......", ".........KKKKK...."],
12 tuck: ["..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "........PPPP......", ".......PPPPPP.....", ".......PPPPPP.....", ".....PPPPPPPPPP...", "........FFFFF.....", "........FFFFF.....", "........FFFF......", "........FFFF......", "........AAAA......", ".....MMMAAAA......", ".....MMMAAAA......", ".....MMMAAAA......", ".....MMMAAFFF.....", ".....MMMAAAAFF....", "........AATA......", ".........TTTTT....", ".........ZTTTTT...", "..........ZZZTT...", "...........ZZT....", "..........ZZTT....", ".........ZZZT.....", ".........KKKKKK..."],
13 launch: ["..................", "..................", "..................", "..................", "..................", "..................", "..................", "........PPPP......", ".......PPPPPP.....", ".......PPPPPP.....", ".....PPPPPPPPPP...", "........FFFFF.....", "........FFFFF.F...", "........FFFF.GF...", "........FFFF.FF...", "........AAAAAFG...", ".....MMMAAAAAAG...", ".....MMMAAAA......", ".....MMMAAAA......", ".....MMMAAAA......", ".....MMMAAAA......", "........AATA......", ".........TTT......", ".........ZTT......", "..........TT......", "..........TTT.....", "...........TT.....", ".........ZZTZ.....", "........KKZT......", ".........KKT......", "...........T......", "...........KKK...."],
14 air1: ["..................", "..................", "..................", "..................", "..................", "..................", "..................", "........PPPP......", ".......PPPPPP.....", ".......PPPPPP.....", ".....PPPPPPPPPP...", "........FFFFF.....", "........FFFFF.....", "........FFFF......", "........FFFF......", "........AAAA..F...", ".....MMMAAAA.FF...", ".....MMMAAAAAF....", ".....MMMAAAAAA....", ".....MMMAAAA......", ".....MMMAAAA......", "........AATA......", ".........TTT......", ".........ZTTT.....", "..........ZTTT....", "..........TTTT....", "........KKKTZ.....", ".........KKKZ.....", "..................", "..................", "..................", ".................."],
15 air2: ["..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "........PPPP......", ".......PPPPPP.....", ".......PPPPPP.....", ".....PPPPPPPPPP...", "........FFFFF.....", "........FFFFF.....", "........FFFF......", "........FFFF......", "........AAAA......", ".....MMMAAAA..F...", ".....MMMAAAA.F....", ".....MMMAAAAF.....", ".....MMMAAAA......", ".....MMMAAAA......", "........AATA......", ".........TTTT.....", ".........ZTTTT....", "..........ZZTTT...", "...........TTT....", ".........KKKZZZ...", "..........KKK.....", "..................", "..................", ".................."],
16 fall: ["..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "........PPPP......", ".......PPPPPP.....", ".......PPPPPP.....", ".....PPPPPPPPPP...", "........FFFFF.....", "........FFFFF.....", "........FFFF......", ".......GFFFF..F...", ".......GAAAA..F...", ".....MMMAAAAAF....", ".....MMMAAAAAF....", ".....MMMAAAA......", ".....MMMAAAA......", ".....MMMAAAA......", "........AATA......", ".........TTTTTT...", ".........ZTTTTT...", "..........ZZ.TT...", "..........ZZZTT...", "...........ZZTT...", ".........ZZZZKK...", "........KKZ...KK..", ".........KK.......", ".................."],
17 land: ["..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "........PPPP......", ".......PPPPPP.....", ".......PPPPPP.....", ".....PPPPPPPPPP...", "........FFFFF.....", "........FFFFF.....", "........FFFF......", "........FFFF......", "........AAAA......", ".....MMMAAAA......", ".....MMMAAAA......", ".....MMMAAAA......", ".....MMMAAAFFFF...", ".....MMMAAAA......", "........AATA......", ".........TTTTTT...", ".........ZTTTTT...", "..........ZZ.TT...", "..........ZZ.TT...", "..........ZZ.TT...", ".......ZZZZZZT....", ".......KKK...KKK.."],
18 hang1: ["..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "........PPPP......", ".......PPPPPP.....", ".......PPPPPF.....", ".....PPPPPPFFPP...", "........FFFFF.....", "........FFFFFG....", "........FFFAGG....", "........FFAAA.....", "........AAAA......", ".....MMMAAAA......", ".....MMMAAAA......", ".....MMMAAAA......", ".....MMMAAAA......", ".....MMMAAAA......", "........AATTTTT...", ".........TTTTTTTTT", ".........ZTZZ..ZTT", "..........ZZZZZZ..", ".............ZZ...", "..................", "..................", "..................", "..................", ".................."],
19 hang2: ["..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "........PPPP......", ".......PPPPPP.....", ".......PPPPPF.....", ".....PPPPPPFFPP...", "........FFFFF.....", "........FFFFFG....", "........FFFAGG....", "........FFAAA.....", "........AAAA......", ".....MMMAAAA......", ".....MMMAAAA......", ".....MMMAAAA......", ".....MMMAAAA......", ".....MMMAAAA.ZZ...", "........AATAZZZ...", ".........TTTTTTT..", ".........ZTTTTTTT.", "..........Z....TTT", "................TK", "..................", "..................", "..................", "..................", ".................."],
20 sit: ["..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "........PPPP......", ".......PPPPPP.....", ".......PPPPPP.....", ".....PPPPPPPPPP...", "........FFFFF.....", "........FFFFF.....", "........FFFF......", "........FFFF......", "........AAAA......", ".....MMMAAAA......", ".....MMMAAAA......", ".....MMMAAAA......", ".....MMMAAAFFFF...", ".....MMMAAAATTT...", "........AATTTTTT..", ".........TTT...TT.", ".........ZTZ...ZT.", "..........ZZZ.ZKKK"],
21 wave1: ["..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "........PPPP......", ".......PPPPPP.....", ".......PPPPPP.F...", ".....PPPPPPPPPA...", "........FFFFFA....", "........FFFFAA....", "........FFFAA.....", "........FFFA......", "........AAAA......", ".....MMMAAAA......", ".....MMMAAAA......", ".....MMMAAAA......", ".....MMMAAAA......", ".....MMMAAAA......", "........AATAG.....", ".........TTT......", ".........ZTTT.....", "..........ZTT.....", "...........ZTT....", "...........ZTT....", "...........ZTZ....", "..........ZTT.....", ".........ZZT......", ".........KKKKK...."],
22 wave2: ["....................", "....................", "....................", "....................", "....................", "....................", "....................", "....................", "........PPPP........", ".......PPPPPP.......", ".......PPPPPP.......", ".....PPPPPPPPPP.F...", "........FFFFF..AA...", "........FFFFF.AA....", "........FFFF.AA.....", "........FFFAAA......", "........AAAA........", ".....MMMAAAA........", ".....MMMAAAA........", ".....MMMAAAA........", ".....MMMAAAA........", ".....MMMAAAA........", "........AATAG.......", ".........TTT........", ".........ZTTT.......", "..........ZTT.......", "...........ZTT......", "...........ZTT......", "...........ZTZ......", "..........ZTT.......", ".........ZZT........", ".........KKKKK......"],
23 brow: ["..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "........PPPP......", ".......PPPPPP.....", ".......PPPPPP.....", ".....PPPPPPPPPP...", "........FFFFF.....", "........FFFFF.....", "........FFFFFF....", "........FFFF.F....", "........AAAAAF....", ".....MMMAAAA......", ".....MMMAAAA......", ".....MMMAAAA......", ".....MMMAAAA......", ".....MMMAAAA......", "........AATAG.....", ".........TTT......", ".........ZTTT.....", "..........ZTT.....", "...........ZTT....", "...........ZTT....", "...........ZTZ....", "..........ZTT.....", ".........ZZT......", ".........KKKKK...."],
24 map: ["..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "........PPPP......", ".......PPPPPP.....", ".......PPPPPP.....", ".....PPPPPPPPPP...", "........FFFFF.....", "........FFFFF.....", "........FFFF......", "........FFFF......", "........AAAAPPPP..", ".....MMMAAAAPPPP..", ".....MMMAAAAPPPP..", ".....MMMAAAAPPPP..", ".....MMMAAAFFFF...", ".....MMMAAAA......", "........AATA......", ".........TTT......", ".........ZTTT.....", "..........ZTT.....", "...........ZTT....", "...........ZTT....", "...........ZTZ....", "..........ZTT.....", ".........ZZT......", ".........KKKKK...."],
25 poke: ["....................", "....................", "....................", "....................", "....................", "....................", "....................", "....................", "....................", "....................", "....................", "....................", "....................", "....................", "........PPPP........", ".......PPPPPP.......", ".......PPPPPP.......", ".....PPPPPPPPPP.....", "........FFFFF.......", "........FFFFF.......", "........FFFF........", "........FFFF........", "........AAAAAA......", ".....MMMAAAAAFF.....", ".....MMMAAAA..FF....", ".....MMMAAAA...F....", ".....MMMAAAAGTT.....", ".....MMMAAAATTT.....", "........AATTTTTT....", ".........TTT...TT...", ".........ZTZ...ZT...", "..........ZZZ.ZKMMK."],
26 cheer: ["..................", "..................", "..................", "..................", "..................", "..................", "..................", "..................", "........PPPP......", ".......PPPPPP.....", ".......PPPPPP.F...", ".....PPPPPPPPPA...", "........FFFFFA....", "........FFFFAA....", "........FFFAA.....", "........FFFA......", "........AAAA......", ".....MMMAAAA......", ".....MMMAAAA......", ".....MMMAAAA......", ".....MMMAAAA......", ".....MMMAAAA......", "........AATAG.....", ".........TTT......", ".........ZTTT.....", "..........ZTTT....", "..........ZZTT....", "...........ZTT....", "..........ZZTT....", ".........ZZZT.....", "........ZZ..T.....", "........KKK.KKK..."],
27 poof1: ["................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "..W..W..........", ".WWWWWW.........", "WWW..WWW........", ".W....W.........", ".W....W.........", "WWW..WWW........", ".WWWWWW.........", "..W..W.........."],
28 poof2: ["................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "W......W........", "..W..W..........", "................", ".W....W.........", ".W....W.........", "................", "..W..W..........", "W......W........"],
29 zz1: ["................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "WWWW............", "..W.............", ".W..............", "WWWW............"],
30 zz2: ["................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "WWW.............", ".W..............", "WWW............."],
31 bar: ["................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", ".HHHHHH.........", "HHHHHHHH........", "HHHHHHHH........", ".HHHHHH........."],
32 silver: ["................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", "................", ".nnnnnn.........", "nnnnnnnn........", "nnnnnnnn........", ".nnnnnn........."],
33} as const satisfies Record<string, readonly string[]>
34export type FrameName = keyof typeof FRAMES
35hooks/art.ts 142 lines1/**
2 * The stage, drawn with octant blocks: each terminal cell is 2x4 pixels, so the 16x32 explorer
3 * fills 8 columns by 8 rows. A cell holds at most two colors (fg + bg); see octantLines.
4 */
5import { FRAMES, PALETTE } from './frames'
6import type { FrameName } from './frames'
7import { OCTANT } from './octants'
8
9export type Action = 'idle' | 'run' | 'jump' | 'sit'
10export type Segment = { text: string; color?: string; backgroundColor?: string }
11/** A frame on the stage: x is the body's left edge, lift raises the feet off the floor. */
12export type Pose = { frame: FrameName; x: number; lift: number; flip: boolean; label: string }
13
14export const SPRITE_H = 32
15/** Stage rows wanted: 8 for the explorer, 2 more of headroom for the jump arc and the "!". */
16export const STAGE_ROWS = 10
17export const TICK_MS = 120
18/** Even, so the sprite never straddles a 2-px octant cell (odd x re-quantizes every frame and shimmers).
19 * Matches tools/rig.py STEP: the planted foot slides back exactly this far per frame. */
20export const RUN_PX = 4
21
22/** Each action's loop, one frame name per tick. jump[1..8] is the airborne arc hooks/jungle.ts plays. */
23export const LOOPS: Record<Action, readonly FrameName[]> = {
24 idle: [...Array<FrameName>(5).fill('idle'), ...Array<FrameName>(5).fill('idle2')],
25 sit: ['sit'],
26 run: ['run1', 'run2', 'run3', 'run4', 'run5', 'run6'],
27 jump: ['tuck', 'launch', 'air1', 'air1', 'air2', 'air2', 'air2', 'fall', 'land', 'land'],
28}
29
30export function mirror(rows: readonly string[]): string[] {
31 return rows.map(r => [...r].reverse().join(''))
32}
33
34export function blank(w: number, h: number): string[][] {
35 return Array.from({ length: h }, () => Array<string>(w).fill('.'))
36}
37
38/** Paint a pose onto a grid, bottom-aligned to the floor. */
39export function paint(grid: string[][], pose: Pose) {
40 const h = grid.length
41 const w = grid[0]?.length ?? 0
42 const art = pose.flip ? mirror(FRAMES[pose.frame]) : FRAMES[pose.frame]
43 const top = h - SPRITE_H - pose.lift
44 // Facing left, the body (x 0-15) moves to the frame's right edge: keep it on the same spot.
45 // x snaps to even so every sprite lands on whole octant cells.
46 const x0 = pose.x - (pose.x & 1)
47 const left = pose.flip ? x0 - (art[0]!.length - 16) : x0
48 art.forEach((row, dy) => {
49 const line = grid[top + dy]
50 if (!line) return
51 for (let dx = 0; dx < row.length; dx++) {
52 const x = left + dx
53 const p = row[dx]!
54 if (p !== '.' && x >= 0 && x < w) line[x] = p
55 }
56 })
57}
58
59/** Fill a rectangle with one palette letter (the "!"). */
60export function fill(grid: string[][], x: number, y: number, w: number, h: number, p: string) {
61 for (let yy = Math.max(0, y); yy < Math.min(grid.length, y + h); yy++) {
62 const line = grid[yy]!
63 for (let xx = Math.max(0, x); xx < Math.min(line.length, x + w); xx++) line[xx] = p
64 }
65}
66
67/** One pose on an empty stage (tests). */
68export function canvas(w: number, h: number, pose: Pose): string[] {
69 const grid = blank(w, h)
70 paint(grid, pose)
71 return grid.map(r => r.join(''))
72}
73
74/** Pixel rows (height a multiple of 4) into octant lines, adjacent same-style cells merged.
75 * `back` is an optional backdrop the same size, drawn behind the sprites (see layered). */
76export function octantLines(rows: readonly string[], back?: readonly string[]): Segment[][] {
77 const out: Segment[][] = []
78 const w = rows[0]?.length ?? 0
79 for (let y = 0; y + 3 < rows.length; y += 4) {
80 const line: Segment[] = []
81 for (let x = 0; x < w; x += 2) {
82 const px: string[] = []
83 const bk: string[] = []
84 for (let dy = 0; dy < 4; dy++) {
85 for (let dx = 0; dx < 2; dx++) {
86 px.push(rows[y + dy]?.[x + dx] ?? '.')
87 bk.push(back?.[y + dy]?.[x + dx] ?? '.')
88 }
89 }
90 push(line, back ? layered(px, bk) : cell(px))
91 }
92 out.push(line)
93 }
94 return out
95}
96
97/** A cell with a backdrop: the sprite keeps its exact shape in its main color and the
98 * backdrop's main color fills the rest. Sprite second colors drop out where they overlap scenery. */
99function layered(px: string[], bk: string[]): Segment {
100 if (px.every(p => p === '.')) return cell(bk)
101 const under = bk.filter((_, i) => px[i] === '.')
102 const shown = under.filter(b => b !== '.')
103 if (shown.length === 0 || shown.length < under.length - shown.length) return cell(px)
104 const fg = top(px.filter(p => p !== '.'))
105 const bg = top(shown)
106 let mask = 0
107 px.forEach((p, i) => {
108 if (p !== '.') mask |= 1 << i
109 })
110 if (fg === bg) return { text: OCTANT[255] ?? ' ', color: PALETTE[fg] }
111 return { text: OCTANT[mask] ?? ' ', color: PALETTE[fg], backgroundColor: PALETTE[bg] }
112}
113
114function top(ps: string[]): string {
115 const counts = new Map<string, number>()
116 for (const p of ps) counts.set(p, (counts.get(p) ?? 0) + 1)
117 return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]![0]
118}
119
120/** One 2x4 cell. fg = most common color; bg = the runner-up, unless the cell has empty pixels. */
121function cell(px: string[]): Segment {
122 const counts = new Map<string, number>()
123 for (const p of px) counts.set(p, (counts.get(p) ?? 0) + 1)
124 const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]).map(e => e[0])
125 const fg = ranked.find(c => c !== '.')
126 if (!fg) return { text: ' ' }
127 const other = ranked.find(c => c !== fg)
128 const bg = other && other !== '.' && !counts.has('.') ? other : undefined
129 let mask = 0
130 px.forEach((p, i) => {
131 // With no bg, every colored pixel joins fg; with one, a third color falls to bg.
132 if (p === fg || (!bg && p !== '.')) mask |= 1 << i
133 })
134 return { text: OCTANT[mask] ?? ' ', color: PALETTE[fg], ...(bg ? { backgroundColor: PALETTE[bg] } : {}) }
135}
136
137function push(line: Segment[], seg: Segment) {
138 const last = line[line.length - 1]
139 if (last && last.color === seg.color && last.backgroundColor === seg.backgroundColor) last.text += seg.text
140 else line.push(seg)
141}
142hooks/story.ts 62 lines1/** Captions for each tool call, and the task list as a treasure tally. Pure, no engine. */
2
3export type Todo = { content: string; status: string; activeForm?: string }
4
5const base = (p: unknown) => (typeof p === 'string' ? p.split('/').filter(Boolean).pop() ?? p : '')
6const clip = (s: string, n = 40) => (s.length > n ? s.slice(0, n - 1) + '…' : s)
7const str = (v: unknown) => (typeof v === 'string' ? v : '')
8
9/** The detail a screen's caption carries: which file, pattern, command or host the call was about. */
10export function detail(tool: string, args: Record<string, unknown>): string {
11 switch (tool) {
12 case 'Read':
13 case 'Edit':
14 case 'Write':
15 return base(args.file_path)
16 case 'NotebookEdit':
17 return base(args.notebook_path)
18 case 'Grep':
19 case 'Glob':
20 return clip(str(args.pattern))
21 case 'Bash':
22 return clip(str(args.description) || str(args.command))
23 case 'WebFetch':
24 return str(args.url).replace(/^https?:\/\//, '').split('/')[0] ?? ''
25 case 'WebSearch':
26 return clip(str(args.query))
27 case 'Agent':
28 return clip(str(args.description) || 'a helper')
29 default:
30 return tool.startsWith('mcp__') ? tool.split('__')[1] ?? '' : ''
31 }
32}
33
34export function readTodos(args: Record<string, unknown>): Todo[] {
35 const list = Array.isArray(args.todos) ? (args.todos as Record<string, unknown>[]) : []
36 return list.map(t => ({ content: str(t.content), status: str(t.status), activeForm: str(t.activeForm) || undefined }))
37}
38
39/** Items newly completed between two lists, matched by content. */
40export function newlyDone(prev: readonly Todo[], next: readonly Todo[]): Todo[] {
41 const was = new Set(prev.filter(t => t.status === 'completed').map(t => t.content))
42 return next.filter(t => t.status === 'completed' && !was.has(t.content))
43}
44
45/**
46 * The tally line: "◆ 2/5 · next: Running the tests", or null with no list.
47 * `lying` is completed tasks whose treasure hasn't been picked up yet; they don't count until it is.
48 */
49export function tally(todos: readonly Todo[], lying = 0): string | null {
50 if (todos.length === 0) return null
51 const done = Math.max(0, todos.filter(t => t.status === 'completed').length - lying)
52 const now = todos.find(t => t.status === 'in_progress')
53 const label = now ? now.activeForm || now.content : undefined
54 return `◆ ${done}/${todos.length}${label ? ` · next: ${clip(label, 50)}` : ''}`
55}
56
57export function ending(isError: boolean, found: number, seconds: number): string {
58 if (isError) return 'lost in the jungle. The turn ended in an error'
59 if (found > 0) return `${found} ${found === 1 ? 'treasure' : 'treasures'} found in ${seconds}s`
60 return `through the jungle in ${seconds}s`
61}
62hooks/jungle.ts 414 lines1/**
2 * Jungle mode: a flip-screen jungle run in the idiom of early-80s home-console jungle games.
3 * Pure, no engine: screens and their hazards, the autopilot's path across one, and the scenery.
4 *
5 * Coordinates are stage pixels. A screen is 40 px tall, stacked in flat bands like the originals:
6 * canopy (y 0-8, stepped lower edge), light-green jungle with trunks, the ground lane (y 28-35;
7 * the explorer's boots land on y 31, mid-lane) and a dirt band (y 36-39). Pits are stepped black
8 * lenses cut into the lane. The hero's feet cover x+4 to x+14 of his pose.
9 */
10import { LOOPS, RUN_PX } from './art'
11import type { Pose } from './art'
12import type { FrameName } from './frames'
13
14export type Kind = 'clear' | 'log' | 'logs' | 'pit' | 'quicksand' | 'vine' | 'crocs' | 'fire'
15export type Screen = {
16 kind: Kind
17 /** logs: how many roll; crocs: how many heads. */
18 n: number
19 /** Width the screen was laid out for. */
20 w: number
21 /** The hazard's left edge. */
22 at: number
23 /** Trees and the treasure's look. */
24 seed: number
25 /** A finished task's name, lying on the ground as treasure, or null. */
26 treasure: string | null
27 caption: string
28 /** A resting screen: where he stops to make camp, with a campfire just ahead. */
29 camp?: number
30}
31
32const H = 40
33const GROUND = 28
34const DIRT = 36
35/** First row under the boots of a hero standing in the lane. */
36const LANE = 32
37/** Lift that stands the hero in the ground lane; add it to every jungle pose's lift. */
38export const STAND = 8
39const FEET: readonly [number, number] = [4, 14]
40/** Where the exit is: the hero's x past this flips to the next screen. */
41export const exitX = (w: number) => w - 18
42/** Treasure's left edge, near the right edge so it's picked up last. */
43export const treasureX = (w: number) => w - 36
44const HAZARD_FROM = 40
45
46// Jump: 8 ticks after takeoff, RUN_PX each, the dojo jump's frames and arc.
47const JUMP_TICKS = 8
48const JUMP_LIFT = [0, 3, 6, 8, 8, 8, 7, 4, 0]
49const JUMP_DX = JUMP_TICKS * RUN_PX
50const CROC_LIFT = 4
51const CROC_W = 12
52const CROC_GAP = 32
53const CROC_PERIOD = 40
54const CROC_SHUT = 28
55const PIT_W = 16
56const LOG_W = 8
57const LOG_SPEED = 2
58const LOG_GAP = 44
59const QUICK_MAX = 56
60const QUICK_PERIOD = 64
61// Vine: anchored far above the stage, so the arc is long and nearly flat.
62export const VINE_L = 40
63const VINE_A = (35 * Math.PI) / 180
64export const VINE_PERIOD = 24
65const VINE_PIT = 40
66const SWING_LIFT = -2
67/** Where the vine meets the fists in hang1/hang2 (frame x, frame row). */
68export const HANG_HAND = { dx: 12, dy: 10 }
69const SPRITE_H = 32
70/** The campfire's left edge, from where he stops: past the poking stick's tip. */
71const CAMPFIRE = 20
72
73const SIZES: Record<Kind, (n: number) => number> = {
74 clear: () => 0,
75 log: () => LOG_W,
76 fire: () => LOG_W,
77 pit: () => PIT_W,
78 quicksand: () => QUICK_MAX,
79 // takeoff, 34 px to the pit's centre, the pit, the landing 22 px past the centre, the feet
80 vine: () => 34 + 22 + 14,
81 crocs: n => 20 + CROC_GAP * (n - 1) + 32,
82 logs: () => 0,
83}
84
85const mult4 = (n: number) => Math.floor(n / 4) * 4
86
87/** A small deterministic hash, so a seed always grows the same trees. */
88export function hash(n: number): number {
89 let t = (n + 0x6d2b79f5) | 0
90 t = Math.imul(t ^ (t >>> 15), t | 1)
91 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
92 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
93}
94
95/** Lay a screen out for a stage `w` px wide. A hazard that can't fit shrinks or becomes a pit. */
96export function layout(kind: Kind, n: number, w: number, seed: number, treasure: string | null, caption: string): Screen {
97 let k = kind
98 let count = n
99 const room = treasureX(w) - 12 - HAZARD_FROM
100 if (k === 'crocs') while (count > 1 && SIZES.crocs(count) > room) count -= 1
101 // Rolling logs need open ground between them to land on.
102 if (k === 'logs') count = Math.max(1, Math.min(count, Math.floor(w / 70)))
103 if (SIZES[k](count) > room) k = SIZES.pit(1) <= room ? 'pit' : 'clear'
104 const size = SIZES[k](count)
105 const at = mult4(HAZARD_FROM + (room - size) / 2)
106 return { kind: k, n: count, w, at, seed, treasure, caption }
107}
108
109/** The vine screen: its takeoff spot is `at`, the pit and the vine's anchor centred 34 px on. */
110function vine(s: Screen) {
111 const cx = s.at + 34
112 return { cx, pit: [cx - VINE_PIT / 2, cx + VINE_PIT / 2] as const }
113}
114export const vineAngle = (t: number) => -VINE_A * Math.cos((2 * Math.PI * t) / VINE_PERIOD)
115
116const quickWidth = (t: number) => {
117 const q = QUICK_MAX * Math.max(0, Math.sin((2 * Math.PI * t) / QUICK_PERIOD))
118 return Math.round(q / 2) * 2
119}
120const crocShut = (t: number) => ((t % CROC_PERIOD) + CROC_PERIOD) % CROC_PERIOD < CROC_SHUT
121const crocs = (s: Screen) => Array.from({ length: s.n }, (_, i) => s.at + 20 + i * CROC_GAP)
122const pond = (s: Screen) => [s.at, s.at + SIZES.crocs(s.n)] as const
123
124/** Left edges of the rolling logs on tick t; they roll left and come back round. */
125export function logsAt(s: Screen, t: number): number[] {
126 const loop = s.w + LOG_W * 2
127 return Array.from({ length: s.n }, (_, i) => {
128 const start = s.w - 20 + i * LOG_GAP
129 return ((((start - LOG_SPEED * t) % loop) + loop) % loop) - LOG_W
130 })
131}
132
133/** Holes in the ground on tick t, as [from, to) spans. */
134function holes(s: Screen, t: number): (readonly [number, number])[] {
135 switch (s.kind) {
136 case 'pit':
137 return [[s.at, s.at + PIT_W]]
138 case 'quicksand': {
139 const q = quickWidth(t)
140 const c = s.at + QUICK_MAX / 2
141 return q > 0 ? [[c - q / 2, c + q / 2]] : []
142 }
143 case 'vine':
144 return [vine(s).pit]
145 case 'crocs':
146 return [pond(s)]
147 default:
148 return []
149 }
150}
151
152/** Things standing on the ground on tick t that the feet can't share. */
153function blocks(s: Screen, t: number): (readonly [number, number])[] {
154 if (s.kind === 'log' || s.kind === 'fire') return [[s.at, s.at + LOG_W]]
155 if (s.kind === 'logs') return logsAt(s, t).map(x => [x, x + LOG_W] as const)
156 return []
157}
158
159/** Feet height for a stand at x on tick t, or null if he'd fall or be hit there. */
160export function footing(s: Screen, x: number, t: number): number | null {
161 const [f0, f1] = [x + FEET[0], x + FEET[1]]
162 for (const [a, b] of blocks(s, t)) if (a < f1 && f0 < b) return null
163 const hs = holes(s, t)
164 if (!hs.some(([a, b]) => a < f1 && f0 < b)) return 0
165 if (s.kind === 'crocs' && crocShut(t)) {
166 // All of the feet on one closed head.
167 for (const c of crocs(s)) if (f0 >= c && f1 <= c + CROC_W) return CROC_LIFT
168 }
169 return null
170}
171
172type Node = { x: number; t: number; lift: number; prev: Node | null; poses: Pose[] }
173
174const pose = (frame: FrameName, x: number, lift: number, label: string): Pose => ({ frame, x, lift, flip: false, label })
175
176/**
177 * The autopilot: the quickest way from x on tick t to the exit, one Pose per tick after t.
178 * A breadth-first search over run / wait / jump / swing, so it never lands in a hole or on a log.
179 * `stopAt` ends the walk there instead of at the exit (a resting screen).
180 */
181export function plan(s: Screen, x0: number, t0: number, stopAt?: number): Pose[] {
182 const goal = stopAt ?? exitX(s.w)
183 const start: Node = { x: x0, t: t0, lift: footing(s, x0, t0) ?? 0, prev: null, poses: [] }
184 const seen = new Set<number>([x0 * 4096 + t0])
185 const queue: Node[] = [start]
186 const v = s.kind === 'vine' ? vine(s) : null
187 const takeoff = v ? v.cx - 34 : -1
188 let runs = 0
189 for (let head = 0; head < queue.length && head < 60_000; head++) {
190 const n = queue[head]!
191 if (n.x >= goal) return unwind(n)
192 if (n.t - t0 > 600) continue
193 const next = (x: number, t: number, lift: number, poses: Pose[]) => {
194 const key = x * 4096 + t
195 if (seen.has(key)) return
196 seen.add(key)
197 queue.push({ x, t, lift, prev: n, poses })
198 }
199
200 // Run: one step, clamped so a stop or a takeoff spot isn't overshot.
201 const step = Math.min(RUN_PX, Math.max(0, goal - n.x), n.x < takeoff ? takeoff - n.x : RUN_PX)
202 if (step > 0 && n.lift === 0) {
203 const lift = footing(s, n.x + step, n.t + 1)
204 if (lift === 0) next(n.x + step, n.t + 1, 0, [pose(LOOPS.run[runs++ % LOOPS.run.length]!, n.x + step, 0, 'run')])
205 }
206 // Jump: grounded at takeoff and landing only; the arc clears anything in between.
207 // Only when running on would meet trouble: on open ground he runs, as in the originals.
208 const useful = n.lift > 0 || Array.from({ length: JUMP_TICKS }, (_, i) => i + 1).some(k => footing(s, n.x + k * RUN_PX, n.t + k) !== 0)
209 const land = useful ? footing(s, n.x + JUMP_DX, n.t + JUMP_TICKS) : null
210 if (land !== null && n.x + JUMP_DX <= Math.max(goal, exitX(s.w))) {
211 const poses = Array.from({ length: JUMP_TICKS }, (_, i) => {
212 const k = i + 1
213 const base = Math.round(n.lift + ((land - n.lift) * k) / JUMP_TICKS)
214 return pose(LOOPS.jump[k]!, n.x + k * RUN_PX, JUMP_LIFT[k]! + base, 'jump')
215 })
216 next(n.x + JUMP_DX, n.t + JUMP_TICKS, land, poses)
217 }
218 // Swing: from the takeoff spot, when the vine is at the near end of its arc.
219 if (v && n.x === takeoff && n.lift === 0 && (((n.t + 1) % VINE_PERIOD) + VINE_PERIOD) % VINE_PERIOD === 0) {
220 const poses = swing(v.cx, n.t + 1)
221 const last = poses[poses.length - 1]!
222 if (footing(s, last.x, n.t + poses.length) === 0) next(last.x, n.t + poses.length, 0, poses)
223 }
224 // Wait a tick where he stands.
225 const stay = footing(s, n.x, n.t + 1)
226 if (stay !== null && stay === n.lift) next(n.x, n.t + 1, stay, [pose(n.lift ? 'idle' : (LOOPS.idle[(n.t + 1) % LOOPS.idle.length] ?? 'idle'), n.x, stay, 'wait')])
227 }
228 // No way across (a stage too narrow to lay out): walk through, he can't be hurt here anyway.
229 const out: Pose[] = []
230 for (let x = x0 + RUN_PX, i = 0; x <= goal + RUN_PX - 1; x += RUN_PX, i++) out.push(pose(LOOPS.run[i % LOOPS.run.length]!, Math.min(x, goal), 0, 'run'))
231 return out
232}
233
234function unwind(n: Node): Pose[] {
235 const chunks: Pose[][] = []
236 for (let c: Node | null = n; c; c = c.prev) chunks.push(c.poses)
237 return chunks.reverse().flat()
238}
239
240/** Where the fists are at vine angle a, as a hero pose's x and lift. */
241export function onVine(cx: number, a: number): { x: number; lift: number } {
242 const x = Math.round(cx + VINE_L * Math.sin(a) - HANG_HAND.dx)
243 return { x: x - (x & 1), lift: Math.round(SWING_LIFT + VINE_L * (1 - Math.cos(a))) }
244}
245
246/** Grab on tick t (the near end), ride half a period to the far end, let go and drop. */
247function swing(cx: number, t: number): Pose[] {
248 const out: Pose[] = []
249 const half = VINE_PERIOD / 2
250 for (let k = 0; k <= half; k++) {
251 const { x, lift } = onVine(cx, vineAngle(t + k))
252 out.push(pose(k % 6 < 3 ? 'hang1' : 'hang2', x, lift, 'swing'))
253 }
254 const from = out[out.length - 1]!
255 const land = cx + 22
256 const drop: [FrameName, number][] = [['fall', 6], ['fall', 3], ['land', 0]]
257 drop.forEach(([frame, lift], i) => {
258 const x = Math.round(from.x + ((land - from.x) * (i + 1)) / drop.length)
259 out.push(pose(frame, x - (x & 1), lift, 'swing'))
260 })
261 return out
262}
263
264/** The vine's top end (far above the stage) and its free end on tick t, in stage px. */
265export function vineLine(s: Screen, t: number, h: number) {
266 const { cx } = vine(s)
267 const a = vineAngle(t)
268 const hand = { x: cx + VINE_L * Math.sin(a), y: h - SPRITE_H - STAND - SWING_LIFT + HANG_HAND.dy + VINE_L * (Math.cos(a) - 1) }
269 const top = { x: cx, y: hand.y - VINE_L * Math.cos(a) }
270 return { top, hand }
271}
272
273type Grid = string[][]
274function rect(g: Grid, x: number, y: number, w: number, h: number, p: string) {
275 for (let yy = Math.max(0, y); yy < Math.min(g.length, y + h); yy++) {
276 const line = g[yy]!
277 for (let xx = Math.max(0, Math.floor(x)); xx < Math.min(line.length, x + w); xx++) line[xx] = p
278 }
279}
280
281/** A stepped lens cut into the lane from a to b: 2 rows at the tips, 4, then 6 in the middle. */
282function lens(g: Grid, a: number, b: number, p: string) {
283 for (let x = Math.floor(a); x < b; x += 2) {
284 const edge = Math.min(x - a, b - 2 - x)
285 const half = edge < 4 ? 1 : edge < 8 ? 2 : 3
286 rect(g, x, LANE - half, 2, half * 2, p)
287 }
288}
289
290/** The screen on tick t, `w` px wide and `h` (at most 40) tall, as palette rows. */
291export function scenery(s: Screen, t: number, w: number, h: number, taken: boolean): string[] {
292 const g: Grid = Array.from({ length: H }, () => Array<string>(w).fill('h'))
293 // Canopy: a solid band whose lower edge steps down and back up in 4-px blocks, 2 rows a step.
294 rect(g, 0, 0, w, 8, 'g')
295 const phase = Math.floor(hash(s.seed * 977) * 8) * 4
296 for (let x = 0; x < w; x += 4) {
297 const k = ((x + phase) / 4) % 8
298 rect(g, x, 8, 4, 2 * (k < 4 ? k : 8 - k), 'g')
299 }
300 // Trunks, 4 px wide with a stepped fork into the canopy, kept clear of the hazard and the treasure.
301 const trees = 3 + Math.floor(hash(s.seed) * 2)
302 const busy = s.kind === 'logs' || s.kind === 'clear' ? [] : [[s.at - 8, s.at + SIZES[s.kind](s.n) + 8]]
303 if (s.treasure) busy.push([treasureX(w) - 6, treasureX(w) + 14])
304 if (s.camp !== undefined) busy.push([s.camp, s.camp + CAMPFIRE + LOG_W + 6])
305 for (let i = 0; i < trees; i++) {
306 const x = mult4(((i + 0.5) / trees) * w + (hash(s.seed + i * 31) - 0.5) * 24)
307 if (busy.some(([a, b]) => x + 6 > a! && x - 2 < b!)) continue
308 rect(g, x, 8, 4, GROUND - 8, 't')
309 rect(g, x - 2, 8, 8, 2, 't')
310 }
311 // Ground lane over a darker dirt band.
312 rect(g, 0, GROUND, w, DIRT - GROUND, 'y')
313 rect(g, 0, DIRT, w, H - DIRT, 'd')
314
315 for (const [a, b] of holes(s, t)) if (s.kind !== 'crocs') lens(g, a, b, s.kind === 'quicksand' ? 'd' : 'k')
316 if (s.kind === 'crocs') {
317 const [a, b] = pond(s)
318 lens(g, a, b, 'w')
319 for (const c of crocs(s)) {
320 // Head out of the water, flat on top so he can stand on it, an eye bump at the back.
321 const top = LANE - CROC_LIFT
322 if (crocShut(t)) {
323 rect(g, c, top, CROC_W, CROC_LIFT + 1, 'g')
324 rect(g, c + CROC_W - 4, top - 1, 2, 1, 'g')
325 } else {
326 rect(g, c + 6, top, 6, CROC_LIFT + 1, 'g')
327 rect(g, c + CROC_W - 4, top - 1, 2, 1, 'g')
328 // Upper jaw raised, wide open toward the hero; lower jaw at the waterline.
329 for (let k = 0; k < 4; k++) rect(g, c + 6 - k * 2, top - k * 2, 2, 2, 'g')
330 rect(g, c, LANE, 6, 1, 'g')
331 }
332 }
333 }
334 // A log is a short upright drum in the lane with a ring on its face.
335 const log = (x: number, ring: number) => {
336 rect(g, x, LANE - 6, LOG_W, 7, 'r')
337 rect(g, x + ring, LANE - 4, 2, 2, 'd')
338 }
339 if (s.kind === 'log') log(s.at, 3)
340 if (s.kind === 'logs') for (const x of logsAt(s, t)) log(x, (t >> 1) % 2 ? 2 : 4)
341 if (s.kind === 'fire') {
342 rect(g, s.at, LANE - 1, LOG_W, 2, 'r')
343 for (let k = 0; k < LOG_W; k += 2) {
344 const tall = 4 + Math.floor(hash(t * 7 + k) * 5)
345 rect(g, s.at + k, LANE - 1 - tall, 2, tall, (t + k) % 4 < 2 ? 'o' : 'S')
346 }
347 }
348 if (s.kind === 'vine') {
349 const { top, hand } = vineLine(s, t, H)
350 // Drawn from the top of the stage in a lighter olive, so it reads against the canopy too.
351 for (let y = Math.max(0, Math.ceil(top.y)); y <= hand.y; y++) {
352 const x = top.x + ((hand.x - top.x) * (y - top.y)) / (hand.y - top.y)
353 rect(g, Math.round(x / 2) * 2, y, 2, 1, 'd')
354 }
355 }
356 if (s.camp !== undefined) {
357 // A campfire: two crossed sticks and low flames, a little smaller than the fire hazard.
358 const fx = s.camp + CAMPFIRE
359 rect(g, fx, LANE, LOG_W, 1, 'r')
360 rect(g, fx + 2, LANE - 1, 4, 1, 'r')
361 for (let k = 0; k < LOG_W; k += 2) {
362 const tall = (k === 0 || k === LOG_W - 2 ? 1 : 2) + Math.floor(hash(t * 5 + k) * 3)
363 rect(g, fx + k, LANE - 1 - tall, 2, tall, (t + k) % 4 < 2 ? 'o' : 'S')
364 }
365 }
366 if (s.treasure && !taken) {
367 const tx = treasureX(w)
368 rect(g, tx, LANE - 4, 8, 4, hash(s.seed + 5) < 0.5 ? 'H' : 'n')
369 if (t % 6 < 3) rect(g, tx + 6, LANE - 7, 2, 2, 'W')
370 }
371 return g.slice(Math.max(0, H - h)).map(r => r.join(''))
372}
373
374/** Which screen a tool call builds; null for calls that don't move the run along. */
375export function screenFor(tool: string, args: Record<string, unknown>): { kind: Kind; n: number } | null {
376 const str = (v: unknown) => (typeof v === 'string' ? v : '')
377 switch (tool) {
378 case 'Read':
379 return { kind: 'log', n: 1 }
380 case 'Edit':
381 case 'Write':
382 case 'NotebookEdit':
383 return { kind: 'logs', n: 1 }
384 case 'Bash': {
385 const long = args.run_in_background === true || /\b(test|build|install|compile|deploy)\b/i.test(str(args.command) + ' ' + str(args.description))
386 return { kind: long ? 'quicksand' : 'pit', n: 1 }
387 }
388 case 'Grep':
389 case 'Glob':
390 case 'Agent':
391 return { kind: 'vine', n: 1 }
392 case 'WebFetch':
393 case 'WebSearch':
394 return { kind: 'crocs', n: 3 }
395 default:
396 return tool.startsWith('mcp__') ? { kind: 'crocs', n: 2 } : null
397 }
398}
399
400/** The caption a screen gets from the call that built it (the dojo caption, told in the jungle). */
401export function jungleCaption(kind: Kind, detail: string): string {
402 const what: Record<Kind, string> = {
403 clear: 'runs on',
404 log: 'hops a log',
405 logs: 'leaps the rolling logs',
406 pit: 'clears a tar pit',
407 quicksand: 'times the quicksand',
408 vine: 'swings across on a vine',
409 crocs: 'crosses on the crocodiles',
410 fire: 'jumps the fire',
411 }
412 return detail ? `${what[kind]}: ${detail}` : what[kind]
413}
414hooks/idle.ts 59 lines1/**
2 * Camp: what the explorer does once the turn is over and he has stopped. Pure, no engine.
3 * Standing, he now and then waves, wipes his brow, reads the map or looks back down the trail;
4 * sat by the fire he pokes it; after a while he dozes off.
5 */
6import { LOOPS } from './art'
7import type { Pose } from './art'
8import type { FrameName } from './frames'
9import { hash } from './jungle'
10
11export const SIT_MS = 20_000
12export const DOZE_MS = 75_000
13/** Ticks per slot; a bit may start REST ticks into each one. */
14const SLOT = 50
15const REST = 14
16const Z_PERIOD = 24
17
18type Bit = { frames: [FrameName, number][]; flip?: boolean }
19const STANDING: Bit[] = [
20 { frames: [['wave1', 3], ['wave2', 3], ['wave1', 3], ['wave2', 3], ['wave1', 3]] },
21 { frames: [['brow', 12]] },
22 { frames: [['map', 24]] },
23 { frames: [['idle', 14]], flip: true },
24]
25const POKE: Bit = { frames: [['poke', 4], ['sit', 3], ['poke', 4], ['sit', 3], ['poke', 4]] }
26
27/** The frame a bit shows k ticks in, or null once it's over. */
28function frameAt(bit: Bit, k: number): FrameName | null {
29 for (const [frame, n] of bit.frames) {
30 if (k < n) return frame
31 k -= n
32 }
33 return null
34}
35
36/**
37 * The camped explorer at x, `campT` ticks after he stopped and `ms` after the turn ended.
38 * fx lifts are relative to his, like the hero's: the caller adds the stand lift to both.
39 */
40export function camp(x: number, campT: number, ms: number, seed: number): { hero: Pose; fx: Pose[] } {
41 const pose = (frame: FrameName, label: string, flip = false): Pose => ({ frame, x, lift: 0, flip, label })
42 if (ms >= DOZE_MS) {
43 // A z drifts up and right off his hat, shrinking; the next one starts as it fades.
44 const k = campT % Z_PERIOD
45 const z: Pose = { frame: k < Z_PERIOD / 2 ? 'zz1' : 'zz2', x: x + 14 + Math.floor(k / 6) * 2, lift: 18 + Math.floor(k / 3), flip: false, label: 'z' }
46 return { hero: pose('sit', 'doze'), fx: [z] }
47 }
48 const sitting = ms >= SIT_MS
49 const slot = Math.floor(campT / SLOT)
50 const k = (campT % SLOT) - REST
51 if (slot > 0 && k >= 0) {
52 const roll = hash(seed * 31 + slot)
53 const bit = sitting ? (roll < 0.6 ? POKE : null) : STANDING[Math.floor(roll * STANDING.length)]!
54 const frame = bit && frameAt(bit, k)
55 if (bit && frame) return { hero: pose(frame, 'bit', bit.flip), fx: [] }
56 }
57 return { hero: sitting ? pose('sit', 'sit') : pose(LOOPS.idle[campT % LOOPS.idle.length]!, 'idle'), fx: [] }
58}
59hooks/octants.ts 3 lines1// Octant glyph per 8-bit mask; bit 0 = top-left, bit 1 = top-right, ... bit 7 = bottom-right.
2export const OCTANT: readonly string[] = [" ", "\u{0001cea8}", "\u{0001ceab}", "🮂", "\u{0001cd00}", "▘", "\u{0001cd01}", "\u{0001cd02}", "\u{0001cd03}", "\u{0001cd04}", "▝", "\u{0001cd05}", "\u{0001cd06}", "\u{0001cd07}", "\u{0001cd08}", "▀", "\u{0001cd09}", "\u{0001cd0a}", "\u{0001cd0b}", "\u{0001cd0c}", "\u{0001fbe6}", "\u{0001cd0d}", "\u{0001cd0e}", "\u{0001cd0f}", "\u{0001cd10}", "\u{0001cd11}", "\u{0001cd12}", "\u{0001cd13}", "\u{0001cd14}", "\u{0001cd15}", "\u{0001cd16}", "\u{0001cd17}", "\u{0001cd18}", "\u{0001cd19}", "\u{0001cd1a}", "\u{0001cd1b}", "\u{0001cd1c}", "\u{0001cd1d}", "\u{0001cd1e}", "\u{0001cd1f}", "\u{0001fbe7}", "\u{0001cd20}", "\u{0001cd21}", "\u{0001cd22}", "\u{0001cd23}", "\u{0001cd24}", "\u{0001cd25}", "\u{0001cd26}", "\u{0001cd27}", "\u{0001cd28}", "\u{0001cd29}", "\u{0001cd2a}", "\u{0001cd2b}", "\u{0001cd2c}", "\u{0001cd2d}", "\u{0001cd2e}", "\u{0001cd2f}", "\u{0001cd30}", "\u{0001cd31}", "\u{0001cd32}", "\u{0001cd33}", "\u{0001cd34}", "\u{0001cd35}", "🮅", "\u{0001cea3}", "\u{0001cd36}", "\u{0001cd37}", "\u{0001cd38}", "\u{0001cd39}", "\u{0001cd3a}", "\u{0001cd3b}", "\u{0001cd3c}", "\u{0001cd3d}", "\u{0001cd3e}", "\u{0001cd3f}", "\u{0001cd40}", "\u{0001cd41}", "\u{0001cd42}", "\u{0001cd43}", "\u{0001cd44}", "▖", "\u{0001cd45}", "\u{0001cd46}", "\u{0001cd47}", "\u{0001cd48}", "▌", "\u{0001cd49}", "\u{0001cd4a}", "\u{0001cd4b}", "\u{0001cd4c}", "▞", "\u{0001cd4d}", "\u{0001cd4e}", "\u{0001cd4f}", "\u{0001cd50}", "▛", "\u{0001cd51}", "\u{0001cd52}", "\u{0001cd53}", "\u{0001cd54}", "\u{0001cd55}", "\u{0001cd56}", "\u{0001cd57}", "\u{0001cd58}", "\u{0001cd59}", "\u{0001cd5a}", "\u{0001cd5b}", "\u{0001cd5c}", "\u{0001cd5d}", "\u{0001cd5e}", "\u{0001cd5f}", "\u{0001cd60}", "\u{0001cd61}", "\u{0001cd62}", "\u{0001cd63}", "\u{0001cd64}", "\u{0001cd65}", "\u{0001cd66}", "\u{0001cd67}", "\u{0001cd68}", "\u{0001cd69}", "\u{0001cd6a}", "\u{0001cd6b}", "\u{0001cd6c}", "\u{0001cd6d}", "\u{0001cd6e}", "\u{0001cd6f}", "\u{0001cd70}", "\u{0001cea0}", "\u{0001cd71}", "\u{0001cd72}", "\u{0001cd73}", "\u{0001cd74}", "\u{0001cd75}", "\u{0001cd76}", "\u{0001cd77}", "\u{0001cd78}", "\u{0001cd79}", "\u{0001cd7a}", "\u{0001cd7b}", "\u{0001cd7c}", "\u{0001cd7d}", "\u{0001cd7e}", "\u{0001cd7f}", "\u{0001cd80}", "\u{0001cd81}", "\u{0001cd82}", "\u{0001cd83}", "\u{0001cd84}", "\u{0001cd85}", "\u{0001cd86}", "\u{0001cd87}", "\u{0001cd88}", "\u{0001cd89}", "\u{0001cd8a}", "\u{0001cd8b}", "\u{0001cd8c}", "\u{0001cd8d}", "\u{0001cd8e}", "\u{0001cd8f}", "▗", "\u{0001cd90}", "\u{0001cd91}", "\u{0001cd92}", "\u{0001cd93}", "▚", "\u{0001cd94}", "\u{0001cd95}", "\u{0001cd96}", "\u{0001cd97}", "▐", "\u{0001cd98}", "\u{0001cd99}", "\u{0001cd9a}", "\u{0001cd9b}", "▜", "\u{0001cd9c}", "\u{0001cd9d}", "\u{0001cd9e}", "\u{0001cd9f}", "\u{0001cda0}", "\u{0001cda1}", "\u{0001cda2}", "\u{0001cda3}", "\u{0001cda4}", "\u{0001cda5}", "\u{0001cda6}", "\u{0001cda7}", "\u{0001cda8}", "\u{0001cda9}", "\u{0001cdaa}", "\u{0001cdab}", "▂", "\u{0001cdac}", "\u{0001cdad}", "\u{0001cdae}", "\u{0001cdaf}", "\u{0001cdb0}", "\u{0001cdb1}", "\u{0001cdb2}", "\u{0001cdb3}", "\u{0001cdb4}", "\u{0001cdb5}", "\u{0001cdb6}", "\u{0001cdb7}", "\u{0001cdb8}", "\u{0001cdb9}", "\u{0001cdba}", "\u{0001cdbb}", "\u{0001cdbc}", "\u{0001cdbd}", "\u{0001cdbe}", "\u{0001cdbf}", "\u{0001cdc0}", "\u{0001cdc1}", "\u{0001cdc2}", "\u{0001cdc3}", "\u{0001cdc4}", "\u{0001cdc5}", "\u{0001cdc6}", "\u{0001cdc7}", "\u{0001cdc8}", "\u{0001cdc9}", "\u{0001cdca}", "\u{0001cdcb}", "\u{0001cdcc}", "\u{0001cdcd}", "\u{0001cdce}", "\u{0001cdcf}", "\u{0001cdd0}", "\u{0001cdd1}", "\u{0001cdd2}", "\u{0001cdd3}", "\u{0001cdd4}", "\u{0001cdd5}", "\u{0001cdd6}", "\u{0001cdd7}", "\u{0001cdd8}", "\u{0001cdd9}", "\u{0001cdda}", "▄", "\u{0001cddb}", "\u{0001cddc}", "\u{0001cddd}", "\u{0001cdde}", "▙", "\u{0001cddf}", "\u{0001cde0}", "\u{0001cde1}", "\u{0001cde2}", "▟", "\u{0001cde3}", "▆", "\u{0001cde4}", "\u{0001cde5}", "█"]
3types/index.d.ts 28 lines1export type JungleOn = boolean
2
3/** One sprite placed on the stage (hooks/art.ts Pose, frame kept as a plain string here). */
4export type Placed = { frame: string; x: number; lift: number; flip: boolean; label: string }
5
6/** Everything the band draws, rebuilt each tick. */
7export type Scene = {
8 visible: boolean
9 hero: Placed
10 fx: Placed[]
11 /** Red "!" over the explorer's head. */
12 bang: boolean
13 caption: string
14 alert: boolean
15 tally: string | null
16 /** The screen (hooks/jungle.ts Screen), its tick, and whether its treasure is taken. */
17 jungle: { screen: { kind: string; n: number; w: number; at: number; seed: number; treasure: string | null; caption: string; camp?: number }; t: number; taken: boolean } | null
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 'jungle-run': {
23 scene: Scene
24 isOn: JungleOn
25 }
26 }
27}
28