SLOPSHOPPER

jungle-run

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…

newbandguardcommandtimer
v0.1.0MITupdated 2026-10-20plaxagoras/jungle-run
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jungle-run
› 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 › /jungle ⎿ jungle-run: Usage: /jungle on|off|demo ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

jungle-run

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-run demo

/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.

What it shows

Happening in Claude CodeOn the next screen
ReadA log to hop
Edit, WriteLogs rolling at him
BashA tar pit to jump
Bash running tests, a build or an install, or in the backgroundQuicksand that opens and closes, so he has to time it
Grep, Glob, a subagentA pit with a vine to swing across
WebFetch, WebSearchA pond of three crocodiles to cross on their heads
An MCP toolA pond with two crocodiles
A call that fails or is refusedFire
A new task (TaskCreate or TodoWrite)He stops to mark it on his map
A task finishedTreasure lies on the ground, and he holds it up when he gets there
A permission dialog opensA red ! beside his hat and a red caption
The turn endsHe 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.

Terminal support

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.

  • kitty draws them itself, no font needed (tested on 0.49).
  • Other terminals need a font with the Symbols for Legacy Computing Supplement block (U+1CD00). GNU Unifont 18 has it and works as a fallback font. Without one the explorer shows up as boxes.
  • The band needs about 10 free rows above the prompt. In a shorter window it stays hidden.

Install

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.

Commands

CommandWhat it does
/jungle onWakes the run for this session
/jungle offHides it
/jungle demoOne screen of every hazard

Every session starts off, so you choose per session whether it runs.

How it works

  • It's a plugin of function hooks. It listens to tool.call, turn.start, turn.complete, classic.PermissionRequest and the task events, and draws with ui.render on the AbovePrompt band.
  • Each tool call queues a screen. Screens are seeded, so the same seed always grows the same trees.
  • Nobody scripted his moves per hazard. On each screen a breadth first search over run, wait, jump and swing finds the quickest way to the exit, tick by tick, so he never lands in a pit or on a log. Rolling logs and quicksand move on a clock, which is why he sometimes stops and waits.
  • Nothing calls a model, so it costs no tokens.

Editing the art

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 .

License

MIT. See LICENSE.

Source 8 files
hooks/register.tsx 374 lines
1import { 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}
374
hooks/frames.ts 35 lines
1// 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
35
hooks/art.ts 142 lines
1/**
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}
142
hooks/story.ts 62 lines
1/** 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}
62
hooks/jungle.ts 414 lines
1/**
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}
414
hooks/idle.ts 59 lines
1/**
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}
59
hooks/octants.ts 3 lines
1// 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}", "█"]
3
types/index.d.ts 28 lines
1export 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