SLOPSHOPPER

token-run

A fully local ASCII runner game for Claude Code request failures and short breaks

newpanebandcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-run
│ ┃ Token Run ✕ › fix the failing auth test and add an audit log call │ ┃ ⟨Claude Code's own drawing⟩ │ ┃ ▣ client module ./game.tsx ⏺ 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 │ │ › /token-run │ ⎿ token-run: Token Run · 트랙 클릭 또는 Jump로 시작. P: 정지 · Q: 닫기. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Token Run
⟨Claude Code's own drawing⟩ ▣ client module ./game.tsx
README

Token Run

A small, fully local ASCII runner for Claude Code. Jump over BUG, 429 and TIMEOUT obstacles while a request is unavailable, or take a short break with /token-run. 한국어.

Install and start

Install while connected; after loading, the game needs no network or model calls. The mod uses the function-hooks/Client API verified in engine tests with Claude Code 2.1.296, 2.1.299, 2.1.300 and 2.1.301, and in real terminal sessions with 2.1.296 and 2.1.301. Older builds without Mods support cannot load it.

claude plugin marketplace add YeonwooSung/my-claude-code-mods
claude plugin install token-run@my-claude-code-mods
# Or use this checkout with a compatible binary:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir ./token-run
/token-run
/token-run help
/token-run close

A main turn ending in an error adds a “request failed” game invitation above the composer. Click it or run /token-run. It opens a Pane beside the transcript in wide fullscreen terminals, or above the prompt otherwise. It requests focus only after your command/click; if the composer or another dialog retains focus, click the game or use ctrl+x tab. It never replaces conversation text or sends a game prompt to the model.

Controls

Key / buttonAction
Track click / Space / Up / JumpStart and jump; no double jump
RRestart the same course
PPause/resume
QClose the game
EscReturn focus to the composer; another Esc at an empty idle prompt closes the pane

Only the focused game receives keys. Ctrl/Meta/Shift combinations are ignored; no global game shortcuts. Click the track or Jump to give the Client keyboard focus. Escape is handled by the engine. Use P to pause before returning to work: the API does not expose Client blur, so leaving focus does not automatically pause. Closing/reopening starts a fresh run. The game needs at least 24 columns and 8 rows; a smaller region shows a resize hint and suspends movement.

Switching between docked and inline layouts may remount the Client. An in-memory checkpoint restores the run; paused/restarted states are saved immediately, while a running game may roll back by about one simulated second. Click the track or a button to regain focus after this transition. Closing clears this checkpoint.

Narrow panes shorten status notices to LOCAL, SAVE! (record save warning) and BACK:Esc (response returned). Game-over/retry and pause controls remain visible. Q closes immediately while its accepted score save finishes.

Score counts survived seconds; collisions freeze it. The plugin-owned store saves the best score, shared across this plugin's sessions in the same local engine store. Scores are checkpointed each survived second, on collision and Q exit. Read/write failures show a warning and leave the game playable, with the best retained in memory. Engine close/Escape uses the latest checkpoint. There is no cross-process locking; simultaneous engine processes may race on records.

Terminal and Desktop use the same Client. VS Code chat, mobile and headless sessions do not run the game. Surveys retain priority over the failure invitation and may take focus away from the game.

What “request failed” means

The verified engine API exposes main-turn failure, not an OS network-status event or readable retry markers. The invitation can also follow server, authentication or context errors, and appears after the turn fails rather than during opaque retries. It does not prove the computer is offline. No health probes or retries are introduced. A later main response shows a return-to-work notice without interrupting play. Subagent outcomes do not affect the invitation.

Development

npm test
npm run typecheck
CLAUDE_BIN=/path/to/claude-2.1.301 npm run test:adapters
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 /path/to/claude-2.1.301 plugin test token-run
/path/to/claude-2.1.301 plugin validate token-run

Typecheck needs matching local .claude-plugin/types declarations; these generated files are ignored like those of other mods. No runtime dependencies are added. Pure tests cover deterministic physics, storage races/failure, request generations and responsive ASCII output. Real engine tests mount the Client on terminal/Desktop and advance its frame clock to test keys, buttons, explicit pause, input with an unfocused parent Pane, resize suspension and checkpoint restoration, scores and lifecycle. These are render-tree tests, not terminal/Desktop pixel captures.

Real terminal smoke test (optional)

On macOS/Linux, run the native CLI in an isolated PTY with a temporary config, a dummy API key and a refused loopback API endpoint. This checks actual input/focus, animation, resize, failure entry and records across process restart. It does not change system network settings or use your account. The optional Python dependencies are only for QA.

python3 -m venv /tmp/token-run-qa
/tmp/token-run-qa/bin/pip install -r token-run/qa/requirements.txt
/tmp/token-run-qa/bin/python token-run/qa/pty_smoke.py --claude-bin /path/to/claude-2.1.301 --artifacts /tmp/token-run-smoke
# Add --combined to load all seven mods together.

Solo runs on 2.1.296/2.1.301 and a combined run on 2.1.301 passed 19 checkpoints each. Artifacts include ANSI output, decoded screen snapshots and result.json. Native Desktop GUI rendering has not been checked; Desktop coverage is engine render-tree coverage. See the verification report for evidence and limits.

Source 5 files
hooks/register.tsx 116 lines
1import type { EngineInterface, Register } from 'claude-code'
2import { decodeGame, score, type Game } from './core/game.ts'
3import { RequestState, Scores, validScore } from './core/state.ts'
4
5const PANE = 'token-run'
6const HELP = 'Token Run: /token-run | /token-run close | /token-run help\nSpace/Up: start or jump · R: restart · P: pause · Q: close; Esc: return focus (again: close)\nClick the track or Jump to start; Space/Up while Client is focused. P pauses explicitly. Fully local; no model calls.'
7function paint($: EngineInterface): void { try { $.ui.invalidate('ui.render') } catch { /* A failed redraw must not affect a turn. */ } }
8
9type AdapterState = { requests: RequestState; scores?: Scores; supported: boolean; open: boolean; runId: number; game?: Game }
10function records($: EngineInterface, state: AdapterState): Scores {
11  return state.scores ??= new Scores({ get: key => $.store.get(key), set: (key, value) => $.store.set(key, value) })
12}
13function detach(state: AdapterState): void { state.open = false; state.runId++; state.game = undefined }
14async function close($: EngineInterface, state: AdapterState): Promise<void> {
15  detach(state); await $.ui.close({ id: PANE }); paint($)
16}
17async function start($: EngineInterface, state: AdapterState): Promise<string> {
18  const opening = state.runId
19  await records($, state).load()
20  if (opening !== state.runId || !state.supported) return 'Token Run · 열기 요청이 취소되었습니다.'
21  if (!state.open) { state.runId++; state.open = true }
22  const current = state.runId
23  try {
24    const result = await $.ui.open({ id: PANE, title: 'Token Run', focus: true, closeOnEscape: true, rows: 8, columns: 64 })
25    if (current !== state.runId || !state.open) return 'Token Run · 열기 요청이 취소되었습니다.'
26    paint($)
27    return result.isPlaced ? 'Token Run · 트랙 클릭 또는 Jump로 시작. P: 정지 · Q: 닫기.' : 'Token Run · 패널을 표시할 공간이 필요해요.'
28  } catch {
29    if (current === state.runId) detach(state)
30    return 'Token Run 패널을 열지 못했어요. /token-run으로 다시 시도하세요.'
31  }
32}
33export const register: Register = on => {
34  const state: AdapterState = { requests: new RequestState(), supported: false, open: false, runId: 0 }
35  on('session.start', async ($, e, next) => {
36    detach(state); state.requests.reset(); state.supported = e.isInteractive && (e.surface === 'terminal' || e.surface === 'desktop')
37    try { await $.ui.close({ id: PANE }) } catch { /* No old game should hold session startup. */ }
38    try { await $.command.register({ name: 'token-run', description: 'Play a local runner: [help|close]' }) } catch { /* Session startup still proceeds. */ }
39    await records($, state).load(); paint($)
40    return next(e)
41  })
42  on('session.end', async ($, e, next) => {
43    detach(state); state.requests.reset()
44    try { await $.ui.close({ id: PANE }) } catch { /* Lifecycle must proceed. */ }
45    paint($)
46    return next(e)
47  })
48  on('turn.start', async ($, e, next) => { state.requests.begin(e.turnId); paint($); return next(e) })
49  on('turn.complete', async ($, e, next) => {
50    state.requests.complete(e.turnId, e.reason, e.agentId); paint($)
51    return next(e)
52  })
53  on('turn.step', async function* ($, e, next) {
54    const generation = state.requests.snapshot().generation
55    const iterator = next(e)
56    let finished = false
57    try {
58      while (true) {
59        const result = await iterator.next()
60        if (result.done) { finished = true; return result.value }
61        const before = state.requests.snapshot().status
62        state.requests.observe(e.turnId, result.value.kind, e.agentId, generation)
63        if (before !== state.requests.snapshot().status) paint($)
64        yield result.value
65      }
66    } finally {
67      if (!finished) await iterator.return?.(undefined as never)
68    }
69  })
70  on('command.run', { command: 'token-run' }, async ($, e) => {
71    const args = (e.args ?? '').trim()
72    if (args === 'help') return { text: HELP }
73    if (args === 'close') { try { await close($, state) } catch { /* Can retry close via engine controls. */ }; return { text: 'Token Run · 작업으로 돌아갑니다.' } }
74    if (args !== '') return { text: 'Usage: /token-run [help|close]' }
75    if (!state.supported) return { text: 'Token Run requires an interactive terminal or desktop session. /token-run help' }
76    return { text: await start($, state) }
77  })
78  on('ui.close', { id: PANE }, async ($, e, next) => { detach(state); paint($); return next(e) })
79  on('ui.message', { element: 'runner' }, async ($, e) => {
80    const data = e.data
81    if (e.component !== 'Pane' || e.requestId !== PANE || !state.open || !data || typeof data !== 'object' || Array.isArray(data)) return {}
82    const message = data as Record<string, unknown>
83    if (message.runId !== state.runId || !validScore(message.score) || (message.type !== 'score' && message.type !== 'exit')) return {}
84    const checkpoint = message.game === undefined ? undefined : decodeGame(message.game)
85    if (message.game !== undefined && (!checkpoint || score(checkpoint) > message.score)) return {}
86    if (checkpoint) state.game = checkpoint
87    const current = state.runId
88    const saving = records($, state).save(message.score)
89    if (message.type === 'exit' && state.open && current === state.runId) {
90      try { await close($, state) } catch { /* Engine close controls remain available. */ }
91    }
92    await saving
93    paint($)
94    return {}
95  })
96  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
97    const existing = await next(e)
98    if (e.props.hasSurvey || e.props.bodyColumns < 24 || e.props.maxRows < 1 || !state.supported || state.requests.snapshot().status !== 'failed' || state.open) return existing
99    if (e.surface !== 'terminal' && e.surface !== 'desktop') return existing
100    const { Box, Button } = $.ui.resolve(e)
101    return <Box flexDirection="column">{existing}<Button key="run-invite" plain label="요청 실패 · 잠깐 달려볼까요?" onPress={async () => { await start($, state) }} /></Box>
102  })
103  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
104    const existing = await next(e)
105    if (e.requestId !== PANE || !state.open || (e.surface !== 'terminal' && e.surface !== 'desktop')) return existing
106    const { Box, Client } = $.ui.resolve(e)
107    const record = records($, state).snapshot()
108    const request = state.requests.snapshot()
109    const columns = Number.isFinite(e.props.bodyColumns) ? Math.max(0, Math.min(80, Math.floor(e.props.bodyColumns))) : 0
110    const rows = Number.isFinite(e.props.scroll.bodyRows) ? Math.max(0, Math.min(8, Math.floor(e.props.scroll.bodyRows))) : 0
111    return <Box flexDirection="column">{existing}<Client key="runner" module="./game.tsx"
112      width={Math.max(1, columns)} height={Math.max(1, rows)}
113      props={{ runId: state.runId, columns, rows, checkpoint: state.game ?? null, best: record.best, error: record.error ?? '', status: request.status, recovered: request.recovered }} /></Box>
114  })
115}
116
hooks/core/game.ts 80 lines
1export type Obstacle = { x: number; width: number; height: number; label: 'BUG' | '429' | 'TIMEOUT' }
2export type Game = {
3  phase: 'ready' | 'running' | 'paused' | 'over'
4  ticks: number
5  y: number
6  vy: number
7  distance: number
8  nextSpawn: number
9  seed: number
10  initialSeed: number
11  obstacles: Obstacle[]
12}
13export type Action = 'jump' | 'restart' | 'pause'
14export const WORLD = 64
15export const PLAYER_X = 6
16export const FRAME_MS = 50
17export function score(game: Game): number { return Math.min(999999, Math.floor(game.ticks / 20)) }
18
19export function newGame(seed = 1): Game {
20  const normalized = Number.isFinite(seed) ? (Math.trunc(seed) >>> 0) || 1 : 1
21  return { phase: 'ready', ticks: 0, y: 0, vy: 0, distance: 0, nextSpawn: 12, seed: normalized, initialSeed: normalized, obstacles: [] }
22}
23export function act(game: Game, action: Action): Game {
24  if (action === 'restart') return newGame(game.initialSeed)
25  if (action === 'pause') {
26    if (game.phase === 'running') return { ...game, phase: 'paused' }
27    if (game.phase === 'paused') return { ...game, phase: 'running' }
28    return game
29  }
30  if ((game.phase === 'ready' || game.phase === 'running') && game.y === 0 && game.vy === 0) {
31    return { ...game, phase: 'running', vy: 0.8 }
32  }
33  return game
34}
35export function step(game: Game): Game {
36  if (game.phase !== 'running') return game
37  const speed = Math.min(1.2, 0.6 + game.ticks / 10000)
38  const y = Math.max(0, game.y + game.vy)
39  const vy = y === 0 ? 0 : game.vy - 0.12
40  let seed = game.seed
41  let nextSpawn = game.nextSpawn
42  const distance = game.distance + speed
43  // Swept horizontal overlap tests the entire movement, including skipped cells.
44  const hit = game.obstacles.some(o => o.x + o.width > PLAYER_X && o.x - speed < PLAYER_X + 1 && y < o.height)
45  const obstacles = game.obstacles.map(o => ({ ...o, x: o.x - speed })).filter(o => o.x + o.width > 0)
46  if (distance >= nextSpawn) {
47    seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0
48    const labels = ['BUG', '429', 'TIMEOUT'] as const
49    const kind = seed % 3
50    obstacles.push({ x: WORLD, width: kind === 2 ? 2 : 1, height: 1, label: labels[kind]! })
51    nextSpawn = distance + 36 + ((seed >>> 8) % 19)
52  }
53  return { ...game, phase: hit ? 'over' : 'running', ticks: game.ticks + (hit ? 0 : 1), y, vy, distance, nextSpawn, seed, obstacles }
54}
55
56/** Validate in-memory Client checkpoints at the host boundary; never trust posted data. */
57export function decodeGame(value: unknown): Game | undefined {
58  if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
59  const g = value as Record<string, unknown>
60  const finite = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n)
61  const uint = (n: unknown): n is number => finite(n) && Number.isSafeInteger(n) && n >= 0
62  if (typeof g.phase !== 'string' || !['ready', 'running', 'paused', 'over'].includes(g.phase)
63    || !uint(g.ticks) || !uint(g.seed) || g.seed > 0xffffffff
64    || !uint(g.initialSeed) || g.initialSeed < 1 || g.initialSeed > 0xffffffff
65    || !finite(g.y) || g.y < 0 || g.y > 4 || !finite(g.vy) || Math.abs(g.vy) > 1
66    || !finite(g.distance) || g.distance < 0 || g.distance > g.ticks * 1.2 + 1
67    || !finite(g.nextSpawn) || g.nextSpawn < g.distance || g.nextSpawn > g.distance + 56
68    || !Array.isArray(g.obstacles) || g.obstacles.length > 3) return undefined
69  const obstacles: Obstacle[] = []
70  for (const item of g.obstacles) {
71    if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined
72    const o = item as Record<string, unknown>
73    if (!finite(o.x) || o.x < -2 || o.x > WORLD || typeof o.label !== 'string' || !['BUG', '429', 'TIMEOUT'].includes(o.label)
74      || o.width !== (o.label === 'TIMEOUT' ? 2 : 1) || o.height !== 1) return undefined
75    obstacles.push({ x: o.x, width: o.label === 'TIMEOUT' ? 2 : 1, height: 1, label: o.label as Obstacle['label'] })
76  }
77  return { phase: g.phase as Game['phase'], ticks: g.ticks, y: g.y, vy: g.vy,
78    distance: g.distance, nextSpawn: g.nextSpawn, seed: g.seed, initialSeed: g.initialSeed, obstacles }
79}
80
hooks/core/state.ts 67 lines
1export type RequestSnapshot = { status: 'unknown' | 'failed' | 'available'; recovered: boolean; generation: number }
2export class RequestState {
3  private turn: string | undefined
4  private value: RequestSnapshot = { status: 'unknown', recovered: false, generation: 0 }
5  snapshot(): RequestSnapshot { return { ...this.value } }
6  reset(): void {
7    this.turn = undefined
8    this.value = { status: 'unknown', recovered: false, generation: this.value.generation + 1 }
9  }
10  begin(turnId: string): void { this.turn = turnId; this.value.generation++ }
11  private accepts(turnId: string, agentId: string | undefined, generation: number): boolean {
12    if (agentId !== undefined || generation !== this.value.generation) return false
13    return this.turn !== undefined && this.turn === turnId
14  }
15  observe(turnId: string, kind: string, agentId?: string, generation = this.value.generation): void {
16    if (!this.accepts(turnId, agentId, generation) || !['text', 'thinking', 'tool', 'input', 'stop'].includes(kind)) return
17    this.value.recovered ||= this.value.status === 'failed'
18    this.value.status = 'available'
19  }
20  complete(turnId: string, reason: string, agentId?: string, generation = this.value.generation): void {
21    if (!this.accepts(turnId, agentId, generation)) return
22    if (reason === 'error') { this.value.status = 'failed'; this.value.recovered = false }
23    else if (reason === 'answer') this.observe(turnId, 'stop', agentId, generation)
24  }
25}
26export type ScoreDocument = { version: 1; best: number }
27type Store = { get(key: string): Promise<unknown>; set(key: string, value: ScoreDocument): Promise<unknown> }
28export const SCORE_KEY = 'token-run.best.v1'
29export function validScore(value: unknown): value is number {
30  return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= 999999
31}
32function decode(value: unknown): number {
33  if (!value || typeof value !== 'object' || Array.isArray(value)) return 0
34  const doc = value as Record<string, unknown>
35  return doc.version === 1 && validScore(doc.best) ? doc.best : 0
36}
37export class Scores {
38  private best = 0
39  private error: string | undefined
40  private queue: Promise<void> = Promise.resolve()
41  constructor(private readonly store: Store) {}
42  snapshot(): { best: number; error?: string } { return { best: this.best, error: this.error } }
43  private enqueue(fn: () => Promise<void>): Promise<void> {
44    const operation = this.queue.then(fn)
45    this.queue = operation.catch(() => {})
46    return operation
47  }
48  load(): Promise<void> {
49    return this.enqueue(async () => {
50      try { this.best = Math.max(this.best, decode(await this.store.get(SCORE_KEY))); this.error = undefined }
51      catch { this.error = '최고 기록을 불러오지 못했어요. 게임은 계속할 수 있어요.' }
52    })
53  }
54  save(value: number): Promise<void> {
55    if (!validScore(value)) return Promise.reject(new Error('Invalid score'))
56    return this.enqueue(async () => {
57      this.best = Math.max(this.best, value)
58      try {
59        const stored = decode(await this.store.get(SCORE_KEY))
60        this.best = Math.max(this.best, stored)
61        if (this.best > stored) await this.store.set(SCORE_KEY, { version: 1, best: this.best })
62        this.error = undefined
63      } catch { this.error = '최고 기록을 저장하지 못했어요. 이번 실행의 기록은 유지돼요.' }
64    })
65  }
66}
67
hooks/game.tsx 79 lines
1import type { ClientModule } from 'claude-code'
2import { newGame, act, step, score, decodeGame, FRAME_MS, type Game, type Action } from './core/game.ts'
3import { gameView } from './core/view.ts'
4
5export type GameProps = { runId: number; columns: number; rows: number; checkpoint: Game | null; best: number; error: string; status: string; recovered: boolean }
6type Local = { game: Game; props: GameProps; lastScore: number; best: number }
7
8const Runner: ClientModule<GameProps, Local> = (props, surface) => {
9  const { Box, Text, Button } = surface.elements
10  const playable = () => !!surface.state && gameView(surface.state.game, Math.min(surface.columns, surface.state.props.columns), Math.min(surface.rows, surface.state.props.rows)).playable
11  const send = (type: 'score' | 'exit', game: Game) => {
12    const current = surface.state
13    if (current) surface.post({ type, runId: current.props.runId, score: Math.max(current.best, score(game)), game })
14  }
15  const apply = (action: Action | 'exit') => {
16    const current = surface.state
17    if (!current) return
18    if (action === 'exit') { send('exit', current.game); return }
19    if (!playable()) return
20    const game = act(current.game, action)
21    if (game === current.game) return
22    surface.setState({ ...current, game, best: Math.max(current.best, score(current.game)), lastScore: score(game) })
23    // Interaction checkpoints preserve pause/restart across a dock/inline remount.
24    send('score', game)
25  }
26  if (!surface.state) {
27    surface.setState({ game: decodeGame(props.checkpoint) ?? newGame(props.runId), props, lastScore: props.checkpoint ? score(props.checkpoint) : 0, best: props.best })
28    // The engine delivers these only while Client itself owns the keys.
29    // Pane.isFocused describes a different focus ring and becomes false on Client focus.
30    surface.onKey(event => {
31      if (event.ctrl || event.meta || event.shift) return
32      const key = event.key.toLowerCase()
33      if (key === ' ' || key === 'up') apply('jump')
34      else if (key === 'r') apply('restart')
35      else if (key === 'p') apply('pause')
36      else if (key === 'q') apply('exit')
37    })
38    // Subscribe to the track's pointer region as well as its child Buttons.
39    // This also lets the real terminal hand keyboard focus to the Client.
40    surface.onPointer(event => {
41      if (event.type === 'down' && event.button === 'left' && !event.ctrl && !event.alt && !event.shift
42        && event.y >= 2 && event.y < 7 && event.x >= 0 && event.x < surface.columns) apply('jump')
43    })
44    surface.every(FRAME_MS, () => {
45      const current = surface.state
46      if (!current || !playable() || current.game.phase !== 'running') return
47      const game = step(current.game)
48      const value = score(game)
49      const checkpoint = value > current.lastScore || game.phase === 'over'
50      surface.setState({ ...current, game, best: Math.max(current.best, value), lastScore: checkpoint ? value : current.lastScore })
51      if (checkpoint) send('score', game)
52    })
53  } else if (surface.state.props.runId !== props.runId) {
54    surface.setState({ game: decodeGame(props.checkpoint) ?? newGame(props.runId), props, lastScore: props.checkpoint ? score(props.checkpoint) : 0, best: props.best })
55  } else if (JSON.stringify(surface.state.props) !== JSON.stringify(props)) {
56    surface.setState({ ...surface.state, props, best: Math.max(surface.state.best, props.best) })
57  }
58  const game = surface.state?.game ?? decodeGame(props.checkpoint) ?? newGame(props.runId)
59  const view = gameView(game, Math.min(surface.columns, props.columns), Math.min(surface.rows, props.rows))
60  if (!view.playable) return <Box key="run-resize"><Text wrap="truncate-end">{view.lines[0] ?? ''}</Text></Box>
61  const status = props.error || (props.recovered ? '응답이 돌아왔어요 · Esc로 작업 복귀' : props.status === 'failed' ? '요청 실패 · 게임은 로컬에서 실행 중' : '트랙 클릭/Space: 점프 · P: 정지 · Q: 닫기')
62  const phase = { ready: 'CLICK + SPACE', running: 'TOKEN RUN', paused: 'PAUSED P', over: 'GAME OVER R' }[game.phase]
63  const detail = Math.min(surface.columns, props.columns) < 48
64    ? props.error ? 'SAVE!' : props.recovered ? 'BACK:Esc' : props.status === 'failed' ? 'LOCAL' : 'Q:exit'
65    : status
66  return <Box flexDirection="column" overflow="hidden">
67    <Box key="run-phase"><Box key="run-status"><Text bold color={game.phase === 'over' ? '#E9A09A' : '#62B8AD'} wrap="truncate-end">{`${phase} | ${detail}`}</Text></Box></Box>
68    <Box key="run-score"><Text wrap="truncate-end">{`SCORE ${score(game)}  BEST ${Math.max(surface.state?.best ?? 0, props.best, score(game))}`}</Text></Box>
69    {view.lines.slice(2, 7).map((line, index) => <Box key={`track-${index + 2}`}><Text wrap="truncate-end">{line}</Text></Box>)}
70    <Box flexDirection="row">
71      <Button key="run-jump" plain label="Jump " onPress={() => apply('jump')} />
72      <Button key="run-restart" plain label="R " onPress={() => apply('restart')} />
73      <Button key="run-pause" plain label="P " onPress={() => apply('pause')} />
74      <Button key="run-close" plain label="Q " onPress={() => apply('exit')} />
75    </Box>
76  </Box>
77}
78export default Runner
79
hooks/core/view.ts 30 lines
1import { PLAYER_X, WORLD, score, type Game } from './game.ts'
2export type GameView = { playable: boolean; lines: string[] }
3export function gameView(game: Game, columns: number, rows: number): GameView {
4  const width = Number.isFinite(columns) ? Math.max(0, Math.min(80, Math.floor(columns))) : 0
5  const height = Number.isFinite(rows) ? Math.max(0, Math.floor(rows)) : 0
6  if (width < 24 || height < 8) {
7    return { playable: false, lines: width > 0 && height > 0 ? ['Resize to 24 cols x 8 rows'.slice(0, width)] : [] }
8  }
9  const trackWidth = Math.min(width, WORLD)
10  const scale = trackWidth < WORLD ? (trackWidth - 1) / (WORLD - 1) : 1
11  const track = Array.from({ length: 4 }, () => Array.from({ length: trackWidth }, () => ' '))
12  for (const o of game.obstacles) {
13    if (o.x >= WORLD || o.x + o.width <= 0) continue
14    const x = Math.floor(o.x * scale)
15    const end = Math.ceil((o.x + o.width) * scale)
16    for (let col = Math.max(0, x); col < Math.min(trackWidth, Math.max(x + 1, end)); col++) track[3]![col] = '#'
17    for (let i = 0; i < o.label.length; i++) {
18      const col = x + i
19      if (col >= 0 && col < trackWidth) track[2]![col] = o.label[i]!
20    }
21  }
22  const runnerRow = 3 - Math.min(3, Math.ceil(game.y))
23  track[runnerRow]![Math.floor(PLAYER_X * scale)] = '@'
24  const phase = { ready: 'SPACE to start', running: 'RUNNING', paused: 'PAUSED', over: 'GAME OVER - R to retry' }[game.phase]
25  return { playable: true, lines: [
26    `TOKEN RUN | ${phase}`, `SCORE ${score(game)}`,
27    ...track.map(row => row.join('')), '-'.repeat(trackWidth), 'SPACE/UP R P Q ESC',
28  ].map(line => line.slice(0, width)) }
29}
30