SLOPSHOPPER

Claude Slots

Replaces the spinner with an animated ASCII slot machine: pull the lever on every prompt, watch the reels spin while Claude works, cash in when the turn…

newbandspinnerprompt
v0.1.0MITupdated 2026-09-15WorldInnovationsDepartment/claude_slots
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claude-slots
› 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 ╔══════════════════════════════════════════════════════════╗ ║ ○●○●○ ✻ ○●○●○ ║ ╠══════════════════════════════════════════════════════════╣ ║ ┌─────┐ ┌─────┐ ┌─────┐ ● ║ ║ │ ♦ │ │ ♣ │ │ ☼ │ ║ ║ ▐ ★ │ │ BAR │ │ ♥ ▌ ║ ║ │ ♥ │ │ ♦ │ │ ♣ │ ║ ║ └─────┘ └─────┘ └─────┘ ║ ╠══════════════════════════════════════════════════════════╣ ║CREDITS: 100 LAST WIN: 000 esc to interrupt║ ╚══════════════════════════════════════════════════════════╝ ✻ ♦ BAR ✻ cr:100 win:000 esc to interrupt ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
✻ ♦ BAR ✻ cr:100 win:000 esc to interrupt
Spinner
╔══════════════════════════════════════════════════════════╗ ║ ○●○●○ ✻ ○●○●○ ║ ╠══════════════════════════════════════════════════════════╣ ║ ┌─────┐ ┌─────┐ ┌─────┐ ● ║ ║ │ ♦ │ │ ♣ │ │ ☼ │ ║ ║ ▐ ★ │ │ BAR │ │ ♥ ▌ ║ ║ │ ♥ │ │ ♦ │ │ ♣ │ ║ ║ └─────┘ └─────┘ └─────┘ ║ ╠══════════════════════════════════════════════════════════╣ ║CREDITS: 100 LAST WIN: 000 esc to interrupt║ ╚══════════════════════════════════════════════════════════╝
README

Claude Slots

A Claude Code plugin that replaces the spinner ("Working…", "Thinking…") with an animated ASCII slot machine. Pull the lever when you submit a prompt, watch the reels spin while Claude works, and cash in when the turn completes.

╔════════════════════ ✻ ════════════════════╗
║   ● ● ● ● ● ● ● ● ● ● ● ● ● ● ● ● ● ● ●  ║
║  ▐  ✻     BAR     ★  ▌  [ 1.2k tok ]     ║
║   ● ● ● ● ● ● ● ● ● ● ● ● ● ● ● ● ● ● ●  ║
║  CREDITS: 097   LAST WIN: 008            ║
╚═══════════════════════════════════════════╝

(a compact sketch of the idle machine — see docs/ART-SPEC.md for the literal, golden frames.)

Screenshots

Open docs/preview.html in any browser for a live, in-browser preview of the machine (the unmodified lib/ engine running client-side — no build step, no network requests, nothing installed). It has size/outcome/seed/FPS controls, a "play all outcomes" demo, and a scrolling transcript of past results — the fastest way to see every storyboard without a live Claude Code session.

TODO: capture and embed a GIF/asciicast of a live spin (hooks/slots.js and bin/claude-slots-statusline.js are both implemented now; only the recording itself is outstanding).

How it works — two tiers, one engine

TierSurfaceWhen it's activeFrame rate
Tier 1Function hooks (ui.render replacing Spinner/TurnDuration/AbovePrompt)CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1~10 fps, full animation
Tier 2statusLine + classic command hooksAlways available, the fallback1 fps, still animated

Both tiers share one pure engine (lib/: symbols, RNG, the state machine, chassis art, and the Frame → tree/ANSI renderers) — see docs/ARCHITECTURE.md for the full design and docs/ART-SPEC.md for every symbol, strip, probability, and golden frame. The outcome of a spin is rolled the instant the lever is pulled; every phase after that (spin-up, spinning, reels locking one by one, reveal, the outcome-specific animation) is a deterministic function of that outcome plus elapsed time, so a 10 fps renderer and a 1 fps renderer are just sampling the same state machine at different rates — they never disagree about what should be on screen.

The lever is pulled on prompt.submit / UserPromptSubmit. The reels spin for as long as the model is working. When the turn completes, the reels stop, a combo is revealed, and a combo-specific animation plays (coin rain for a win, a lever-jam "INTERRUPTED" state if you hit Esc, credits refunded either way for an aborted or errored turn).

Install

Option A — from GitHub (recommended)

This repo doubles as a one-plugin marketplace (.claude-plugin/marketplace.json, name claude-slots-local) at WorldInnovationsDepartment/claude_slots. From inside Claude Code:

/plugin marketplace add WorldInnovationsDepartment/claude_slots
/plugin install claude-slots@claude-slots-local

(the full URL form /plugin marketplace add https://github.com/WorldInnovationsDepartment/claude_slots works too). /claude-slots:setup, /claude-slots:teardown, /claude-slots:stats, /claude-slots:demo become available once the install finishes.

For a non-interactive install (e.g. rolling this out via a shared settings.json), add:

{
  "extraKnownMarketplaces": {
    "claude-slots-local": {
      "source": { "source": "github", "repo": "WorldInnovationsDepartment/claude_slots" }
    }
  },
  "enabledPlugins": {
    "claude-slots@claude-slots-local": true
  }
}

to ~/.claude/settings.json (user-wide) or .claude/settings.json (project-scoped).

Option B — point Claude Code straight at a local checkout

git clone https://github.com/WorldInnovationsDepartment/claude_slots.git
claude --plugin-dir /path/to/claude_slots

Everything works immediately: the plugin loads inline, hooks fire, and the same /claude-slots:* commands are all available.

Option C — as a local marketplace

Same as Option A, but pointed at a local clone instead of GitHub:

/plugin marketplace add /path/to/claude_slots
/plugin install claude-slots@claude-slots-local

Enabling Tier 1 (the real animated spinner)

Tier 1 is early-access and gated behind an environment variable:

export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1

Add that to your shell profile (~/.zshrc or ~/.bashrc) and restart Claude Code. With it set, hooks/hooks.json's "modules": ["./slots.js"] loads as a function-hooks module and the machine replaces the built-in Spinner/TurnDuration/AbovePrompt components directly in the terminal UI at up to ~10 fps. Without it, the module has zero effect (verified: gating a live run with the flag unset produces byte-identical output to the built-in spinner) and you're on Tier 2 only. node bin/claude-slots-setup.js also detects and reports which tier is active.

Setting up Tier 2 (the statusLine fallback)

Tier 2 needs ~/.claude/settings.json's statusLine pointed at this plugin's renderer. Run:

/claude-slots:setup

which shells out to bin/claude-slots-setup.js. By default it only prints a dry-run diff and changes nothing. Re-run with --apply (/claude-slots:setup --apply, or node bin/claude-slots-setup.js --apply) to actually install it. Setup:

  • backs up your current ~/.claude/settings.json to <path>.claude-slots.bak-<ISO timestamp>,
  • saves your previous statusLine / spinnerVerbs / spinnerTipsEnabled into ${CLAUDE_PLUGIN_DATA}/config.json so they can be restored later,
  • points statusLine at node <plugin root>/bin/claude-slots-statusline.js with refreshInterval: 1 (the lowest the host allows),
  • replaces spinnerVerbs with ten slot-themed phrases ("Pulling the lever", "Spinning the reels", "Reels whirring", "Chasing sevens", "Feeling lucky", …), and
  • turns off spinnerTipsEnabled so the tip line doesn't fight the machine for the same row.

If you had your own statusLine command already, Tier 2's renderer runs it first and prints its output as line 1, so your existing status line is preserved above the machine, not replaced by it.

Undo everything with:

/claude-slots:teardown

which restores the exact statusLine / spinnerVerbs / spinnerTipsEnabled you had before (or removes the keys entirely if you never had them) and deletes the saved config. Setup/teardown are idempotent: running --apply twice in a row does not overwrite the original saved values with the plugin's own, and running --restore with nothing to restore is a safe no-op.

Useful flags on bin/claude-slots-setup.js: --dry-run (default), --apply, --restore, --no-statusline, --no-verbs, --settings-path <path> (point at a different settings file, mainly for testing).

Commands

CommandDoes
/claude-slots:setup [--apply] [--dry-run] [--no-statusline] [--no-verbs]Install (or preview installing) the Tier 2 statusLine fallback
/claude-slots:teardownRestore your previous statusLine/spinnerVerbs/spinnerTipsEnabled
/claude-slots:statsShow credits, spins, wins, and jackpots from whichever tier has data
/claude-slots:demo [outcome] [size]Render a one-off spin animation in the transcript, no real turn or credits involved — good for previewing art (outcome ∈ jackpot/medallion/triple/double/near-miss/lose, size ∈ compact/standard/grand)

Options

Configured via Claude Code's plugin userConfig (/plugin UI, or pluginConfigs["claude-slots"].options in settings). register(on, options) in hooks/slots.js receives them as options.<key> — Tier 1 only. Tier 2's command hooks and bin/ scripts do not currently read userConfig at all (there is no CLAUDE_PLUGIN_OPTION_<KEY> mechanism — Tier 2 always auto-sizes from COLUMNS, starts new sessions at the default 100 credits, and uses the classic theme regardless of what's configured for Tier 1; idle and sound don't apply to Tier 2 in the first place, since it has no AbovePrompt band or $.audio.play). If you need Tier 2 to honor size/startCredits, that's open follow-up work, not something to expect today.

Note: userConfig's options field (a dropdown of preset values in the config UI) requires Claude Code v2.1.271+. This plugin targets the verified baseline, 2.1.263, so every option here is a free-text string/number field with the allowed values documented in its description — claude plugin validate rejects an options array at this version.

OptionTypeDefaultValues
sizestringautoauto \compact \standard \grand — chassis size; auto picks from the terminal's column count (≥90 → grand if enabled, ≥62 → standard, else compact)
idlestringcompactoff \compact \full — what the AbovePrompt band shows between turns (Tier 1 only)
themestringclassicclassic \emoji — glyph set (docs/ART-SPEC.md §14 covers the emoji theme)
soundstringoffoff \on — play the bundled fx/*.wav clips via $.audio.play (Tier 1 only)
startCreditsnumber100starting credit balance for a new session (1 credit spent per spin, never blocks a spin — see docs/ART-SPEC.md §10)

Repository layout

.claude-plugin/plugin.json        manifest: name, version, userConfig, etc.
.claude-plugin/marketplace.json   makes this repo a one-plugin local marketplace
hooks/hooks.json                  {"modules":["./slots.js"], "hooks":{...tier-2 command hooks...}}
hooks/slots.js                    Tier 1 module — the only file that touches `$`
lib/*.js                          the shared engine (pure ESM: runs in Node, a browser, or the
                                   hooks worker unchanged — no Node/DOM APIs)
bin/*.js                          Tier 2 scripts + setup/teardown/stats/demo (Node ≥18, ESM)
commands/*.md                     /claude-slots:setup, :teardown, :stats, :demo
fx/*.wav                          tiny generated sound effects (lever click, coin ding, jackpot chord)
tests/*.test.js                   node:test — rng, rectangularity, renderers, scanner-compat
scripts/dev/pty-smoke.py          live pseudo-terminal smoke test, both tiers, one CLI
scripts/dev/make-fx.js            regenerates fx/*.wav (pure Node PCM synthesis, no deps)
scripts/dev/build-preview.js      rebuilds docs/preview.html from the unmodified lib/ engine
docs/preview.html                 self-contained, in-browser preview (see Screenshots above)
docs/ARCHITECTURE.md              the design (read this first)
docs/ART-SPEC.md                  every symbol, strip, probability, chassis frame, storyboard (golden)
jsconfig.json                     lets editors type-check hooks/slots.js against .claude/types

Development / testing

Zero runtime dependencies, plain Node ESM (package.json has "type": "module").

node --test                            # unit tests: rng, frame rectangularity, renderers
# (equivalently: npm test, which runs `node --test tests/*.test.js`. On some Node builds,
#  passing the bare directory — `node --test tests/` — misresolves it as a CJS module path
#  instead of a test root; the glob form and the no-args form both avoid that.)
node scripts/dev/make-fx.js            # regenerate fx/*.wav
node bin/claude-slots-setup.js --dry-run --settings-path /tmp/some-settings.json

Live end-to-end smoke test (drives a real claude process through a PTY, both tiers):

python3 scripts/dev/pty-smoke.py --tier 1 --tui default \
  --prompt "Count to 10." --seconds 20 --out /tmp/run1

python3 scripts/dev/pty-smoke.py --tier 2 --tui fullscreen \
  --prompt "Count to 10." --seconds 20 --out /tmp/run2

Each run writes <out>.bin (raw capture), <out>.stripped.txt (ANSI stripped), and <out>.timeline.json (event timeline), and prints a head/tail excerpt plus a count of lines containing the ╔ chassis corner glyph as a quick tier-agnostic "did it actually render" signal. Always pass --model haiku for these (the script defaults to it) and make sure no stray claude process from a prior run is still alive:

pkill -f "claude --plugin-dir /path/to/claude_slot"

Verification gates this plugin is held to (docs/ARCHITECTURE.md §7):

  1. node --test green — probabilities sum to 1 (200k-roll tolerance check), every frame for every (size × phase × outcome × time sample) is a true rectangle within its width budget, the tree renderer only ever emits allow-listed props, the ANSI renderer round-trips to the same visible text.
  2. CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin validate . passes (scanner + manifest).
  3. Live PTY run, Tier 1: the machine replaces the spinner during a turn; a result line closes the turn; Esc mid-turn produces the JAM/"INTERRUPTED" state and a refund.
  4. Live PTY run, Tier 2 (flag off, statusLine pointed at the script): the machine renders in the status line at 1 fps; hooks drive lever/stop; a pre-existing user status line is preserved as line 1 above it.
  5. Adversarial review: API compliance, scanner compliance, 40-column terminals, reduced-motion, /clear, concurrent sessions, budget limits (toasts, store size).

Troubleshooting

  • Nothing changed, spinner looks the same. Check which tier you expect: Tier 1 needs CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 set and Claude Code restarted; Tier 2 needs /claude-slots:setup --apply to have actually run (dry-run alone changes nothing).
  • My old status line disappeared. It should be preserved as line 1 above the machine. If it isn't, check ${CLAUDE_PLUGIN_DATA}/config.json — your original statusLine command is saved there and /claude-slots:teardown will put it straight back.
  • I ran setup twice and I'm worried I lost my real settings. You didn't: --apply only saves the original values into config.json on the first run; a second --apply re-applies the plugin's own values on top without touching the saved originals. Every --apply/--restore also leaves a timestamped settings.json.claude-slots.bak-<ISO> backup next to your settings file regardless.
  • Setup/teardown exits with "contains invalid JSON". claude-slots-setup.js refuses to guess at a settings.json/config.json it can't parse (a stray hand-edit, or a half-written file left over from an earlier crash) — it leaves the file untouched and exits non-zero rather than crashing or overwriting it. Fix or remove the named file and re-run.
  • claude plugin validate fails. Run it with --json for a structured report; the most common cause during development is a userConfig field using an unsupported schema key for your Claude Code version (see the Options note above about options needing v2.1.271+).
  • A hook seems to hang or error. Every hook body is wrapped so a throw falls back to next(e) (Tier 1) or a silent exit 0 (Tier 2 command hooks) — a broken plugin should never block a prompt or crash a session. If you see otherwise, please file an issue with the debug log (~/.claude/debug/<session-id>.txt, read-only, never touched by this plugin).

Uninstall

  1. /claude-slots:teardown (or node bin/claude-slots-setup.js --restore) to put back your original statusLine/spinnerVerbs/spinnerTipsEnabled.
  2. Remove the plugin: /plugin uninstall claude-slots@claude-slots-local (marketplace install) or simply stop passing --plugin-dir (inline install).
  3. Optionally remove ${CLAUDE_PLUGIN_DATA} (~/.claude/plugins/data/claude-slots*/) and any ~/.claude/plugins/store/claude-slots*.json files to clear saved credits/stats.

License

MIT — see LICENSE.

Source 10 files
hooks/slots.js 485 lines
1// Claude Slots — Tier 1 function-hooks module (hooks/slots.js)
2//
3// Replaces the built-in Spinner / TurnDuration / AbovePrompt components with
4// the animated slot machine from lib/ (docs/ARCHITECTURE.md §4). This is the
5// ONLY file in the plugin allowed to touch `$` / `on` / `next` — the static
6// scanner enforces (per `claude plugin validate` and the fh2 spike evidence
7// in docs/ART-SPEC.md's sibling research) that:
8//
9//   - `register` is a same-file exported function.
10//   - Every `$` use is either `$.noun.method(...)` written directly inside a
11//     hook body / a top-level helper, or `$` passed *whole* as an argument
12//     to a top-level function declared in this same file (never stored in a
13//     variable, spread, exported, destructured, or shadowed).
14//   - `next` is only ever called directly as `next(e)`, inline inside the
15//     `on(...)` callback that received it (never forwarded to a helper).
16//   - `on` is only ever called directly inside `register(...)`.
17//   - Everything else (rng, machine state, art, rendering) lives in lib/ and
18//     is imported as plain, `$`-free functions/data.
19//
20// Every hook body is wrapped in try/catch and falls back to `next(e)` (or a
21// silent no-op for observe-only events) — a hook that throws is skipped by
22// the engine, so we never want that to be the *first* time something here
23// fails; better to degrade to the native spinner/footer than to vanish.
24
25import * as machine from '../lib/machine.js';
26import { renderTree } from '../lib/render-tree.js';
27import { hashSeed } from '../lib/rng.js';
28import { DEFAULT_START_CREDITS } from '../lib/economy.js';
29
30// ---------------------------------------------------------------------------
31// Module-level state. Kept at true module scope (not inside `register`) per
32// docs/ARCHITECTURE.md §4's robustness note ("keep per-plugin state
33// module-level"); `session.start` is treated as the reload/reset signal and
34// clears the transient (non-persisted) parts on every fire, including a
35// later reload — only the economy (credits/stats) survives via `$.store`.
36// ---------------------------------------------------------------------------
37
38const SIZE_VALUES = new Set(['auto', 'compact', 'standard', 'grand']);
39const IDLE_VALUES = new Set(['off', 'compact', 'full']);
40const THEME_VALUES = new Set(['classic', 'emoji']);
41const SOUND_VALUES = new Set(['off', 'on']);
42
43const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000;
44const PENDING_CAP = 50;
45const SOUND_BUDGET_PER_SESSION = 500;
46const ABORTED_STATUS_MS = 3500;
47
48/** Resolved plugin options for this activation (set once, at the top of `register`). */
49let cfg = { size: 'auto', idle: 'compact', theme: 'classic', sound: 'off', startCredits: DEFAULT_START_CREDITS };
50
51/** The session's one running machine (lib/machine.js State — plain JSON data). */
52let liveState = null;
53
54/** The in-flight turn's id, remembered for observability only. */
55let currentTurnId = null;
56
57/** Last turn.step-with-tool-calls timestamp — a harmless, optional flourish flag (§ turn.step). */
58let toolPulseAt = 0;
59
60/** The shared ~10fps redraw ticker (docs/ARCHITECTURE.md §1: floor 100ms). */
61let tickerHandle = null;
62let tickerRunning = false;
63
64/** Bumped on every pullLever; lets a stale scheduled ticker-stop recognize a newer spin started. */
65let spinGeneration = 0;
66
67/** Spins whose turn.complete has fired but which no TurnDuration instance has claimed yet (FIFO). */
68let pendingSpins = [];
69
70/** Once a TurnDuration instance's requestId is matched to a spin, it stays bound here (capped). */
71let boundSpins = new Map();
72
73/** `$.audio.play` calls made this session (defensive cap; §sound option). */
74let soundPlaysThisSession = 0;
75
76// ---------------------------------------------------------------------------
77// Small pure helpers — ordinary data in, ordinary data out, no `$`/`on`/`next`.
78// ---------------------------------------------------------------------------
79
80function pickOneOf(value, allowed, fallback) {
81  return allowed.has(value) ? value : fallback;
82}
83
84function resolveOptions(options) {
85  const o = options || {};
86  const startCreditsNum = Number(o.startCredits);
87  return {
88    size: pickOneOf(o.size, SIZE_VALUES, 'auto'),
89    idle: pickOneOf(o.idle, IDLE_VALUES, 'compact'),
90    theme: pickOneOf(o.theme, THEME_VALUES, 'classic'),
91    sound: pickOneOf(o.sound, SOUND_VALUES, 'off'),
92    startCredits: Number.isFinite(startCreditsNum) && startCreditsNum >= 0 ? startCreditsNum : DEFAULT_START_CREDITS,
93  };
94}
95
96/** Deep-clone via JSON round-trip — machine State is plain JSON-serializable data (lib/machine.js's own guarantee). */
97function cloneJson(value) {
98  return JSON.parse(JSON.stringify(value));
99}
100
101function mergeStoredEconomy(state, stored) {
102  if (!stored || typeof stored !== 'object') return;
103  if (Number.isFinite(stored.credits)) state.economy.credits = Math.max(0, stored.credits);
104  if (Number.isFinite(stored.lastWin)) state.economy.lastWin = Math.max(0, stored.lastWin);
105  const storedStats = stored.stats;
106  if (storedStats && typeof storedStats === 'object') {
107    const s = state.economy.stats;
108    if (Number.isFinite(storedStats.spins)) s.spins = storedStats.spins;
109    if (storedStats.winsByClass && typeof storedStats.winsByClass === 'object') {
110      Object.assign(s.winsByClass, storedStats.winsByClass);
111    }
112    if (Number.isFinite(storedStats.jackpots)) s.jackpots = storedStats.jackpots;
113    if (Number.isFinite(storedStats.streak)) s.streak = storedStats.streak;
114    if (Number.isFinite(storedStats.bestStreak)) s.bestStreak = storedStats.bestStreak;
115    if (Array.isArray(storedStats.recent)) s.recent = storedStats.recent.slice(0, 3);
116  }
117}
118
119/** AbovePrompt's chassis size: compact 2-line form when cramped or requested, else standard (never grand — §7.3/§7.2 budget for this band). */
120function resolveAboveSize(idleOption, columns, maxRows) {
121  if (idleOption === 'compact') return 'compact';
122  if (Number.isFinite(maxRows) && maxRows < 12) return 'compact';
123  if (Number.isFinite(columns) && columns < 62) return 'compact';
124  return 'standard';
125}
126
127function outcomeSoundKind(outcomeClass) {
128  if (outcomeClass === 'JACKPOT' || outcomeClass === 'MEDALLION') return 'jackpot';
129  if (outcomeClass === 'TRIPLE' || outcomeClass === 'DOUBLE') return 'coin';
130  return null;
131}
132
133function soundAssetFor(kind) {
134  if (kind === 'lever') return 'fx/lever-click.wav';
135  if (kind === 'jackpot') return 'fx/jackpot-chord.wav';
136  return 'fx/coin-ding.wav';
137}
138
139/** Binds a fresh requestId to `spin`, evicting the oldest bound entry once over the cap (§ TurnDuration binding). */
140function bindSpin(requestId, spin) {
141  boundSpins.set(requestId, spin);
142  if (boundSpins.size > PENDING_CAP) {
143    const oldestKey = boundSpins.keys().next().value;
144    boundSpins.delete(oldestKey);
145  }
146}
147
148/** Resolves a TurnDuration instance's requestId to the spin it belongs to: exact durationMs match, else oldest pending (FIFO). */
149function resolveSpinForRequest(requestId, durationMs) {
150  if (boundSpins.has(requestId)) return boundSpins.get(requestId);
151  if (pendingSpins.length === 0) return null;
152  let idx = pendingSpins.findIndex((s) => s.durationMs === durationMs);
153  if (idx === -1) idx = 0;
154  const spin = pendingSpins.splice(idx, 1)[0];
155  bindSpin(requestId, spin);
156  return spin;
157}
158
159function pushPendingSpin(spin) {
160  pendingSpins.push(spin);
161  if (pendingSpins.length > PENDING_CAP) pendingSpins.splice(0, pendingSpins.length - PENDING_CAP);
162}
163
164function resetTransientState() {
165  stopTickerNow();
166  if (statusClearHandle) {
167    statusClearHandle.cancel();
168    statusClearHandle = null;
169  }
170  pendingSpins = [];
171  boundSpins = new Map();
172  currentTurnId = null;
173  toolPulseAt = 0;
174  soundPlaysThisSession = 0;
175  spinGeneration += 1;
176}
177
178// ---------------------------------------------------------------------------
179// Ticker — the shared ~10fps redraw heartbeat (docs/ARCHITECTURE.md §1/§4).
180// Named top-level functions forwarding `$` explicitly, mirroring the
181// validated fh2 spike pattern exactly (`$.clock.every`/`$.clock.after`
182// attributed by the scanner through these same helpers).
183// ---------------------------------------------------------------------------
184
185function tick($) {
186  $.ui.invalidate('ui.render');
187}
188
189function startTicker($) {
190  if (tickerRunning) return;
191  tickerRunning = true;
192  tickerHandle = $.clock.every(100, () => tick($));
193}
194
195function stopTickerNow() {
196  if (tickerHandle) {
197    tickerHandle.cancel();
198    tickerHandle = null;
199  }
200  tickerRunning = false;
201}
202
203/** Schedules a ticker-stop `ms` from now, but only actually stops it if no newer spin has started meanwhile. */
204function scheduleTickerStop($, ms) {
205  const gen = spinGeneration;
206  $.clock.after(ms, () => {
207    if (gen === spinGeneration) stopTickerNow();
208  });
209}
210
211/** The pending timer that will clear the status line set by the most recent setTimedStatus() call. */
212let statusClearHandle = null;
213
214function clearStatus($) {
215  $.ui.status(undefined);
216}
217
218/**
219 * Pins a status line for `ms`, then clears it — `$.ui.status` itself never
220 * auto-expires. Tracks (and cancels) any previously-scheduled clear so two
221 * calls within `ms` of each other can't race: without this, an earlier
222 * call's deferred clear could fire *after* a later call set new text,
223 * wiping it out early (same "untracked timer" class of bug `tickerHandle`
224 * is already careful about — see scheduleTickerStop above).
225 */
226function setTimedStatus($, text, ms) {
227  if (statusClearHandle) {
228    statusClearHandle.cancel();
229    statusClearHandle = null;
230  }
231  $.ui.status(text);
232  statusClearHandle = $.clock.after(ms, () => {
233    statusClearHandle = null;
234    clearStatus($);
235  });
236}
237
238function playSoundAsset($, kind) {
239  $.audio.play({ asset: soundAssetFor(kind) }).catch(() => {});
240}
241
242/** Fire-and-forget: never awaited (so a slow/failed clip never delays the hook that triggered it). */
243function maybePlaySound($, kind) {
244  if (cfg.sound !== 'on' || !kind) return;
245  if (soundPlaysThisSession >= SOUND_BUDGET_PER_SESSION) return;
246  soundPlaysThisSession += 1;
247  playSoundAsset($, kind);
248}
249
250function notifyJackpot($, amount) {
251  $.ui.toast(`JACKPOT! +${amount} credits`);
252}
253
254/** aborted turns never get a TurnDuration render (measured fact, docs/ARCHITECTURE.md §4) — AbovePrompt is the only place JAM can show; when the idle band itself is off, fall back to a timed status line. */
255function notifyAborted($) {
256  if (cfg.idle === 'off') setTimedStatus($, '… INTERRUPTED …', ABORTED_STATUS_MS);
257}
258
259// ---------------------------------------------------------------------------
260// $-touching async helpers (store/session reads) — each receives `$` as its
261// first argument, used only as `$.noun.method(...)`.
262// ---------------------------------------------------------------------------
263
264async function loadStoredEconomy($) {
265  try {
266    return await $.store.get('economy');
267  } catch (err) {
268    return undefined;
269  }
270}
271
272async function saveEconomy($, economy) {
273  try {
274    await $.store.set('economy', cloneJson(economy));
275  } catch (err) {
276    // persistence is best-effort; losing one write never blocks the turn
277  }
278}
279
280async function recordTier1Marker($) {
281  const sessionId = await $.session.id();
282  const now = $.clock.now();
283  let prevSessions = {};
284  try {
285    const prev = await $.store.get('tier1');
286    if (prev && typeof prev === 'object' && prev.sessions && typeof prev.sessions === 'object') {
287      prevSessions = prev.sessions;
288    }
289  } catch (err) {
290    prevSessions = {};
291  }
292  const cutoff = now - TWO_DAYS_MS;
293  const sessions = {};
294  for (const key of Object.keys(prevSessions)) {
295    const ts = prevSessions[key];
296    if (Number.isFinite(ts) && ts >= cutoff) sessions[key] = ts;
297  }
298  sessions[sessionId] = now;
299  await $.store.set('tier1', { sessions });
300}
301
302async function initSession($) {
303  const stored = await loadStoredEconomy($);
304  const state = machine.createState({
305    startCredits: cfg.startCredits,
306    theme: cfg.theme,
307    grandEnabled: cfg.size === 'grand',
308  });
309  mergeStoredEconomy(state, stored);
310  await recordTier1Marker($);
311  return state;
312}
313
314async function maybePullLever($, e) {
315  if (!liveState) liveState = machine.createState({ startCredits: cfg.startCredits, theme: cfg.theme, grandEnabled: cfg.size === 'grand' });
316  const now = $.clock.now();
317  if (machine.isAnimating(liveState, now)) return; // mid-spin: a queued prompt must not restart the animation
318  const sessionId = await $.session.id();
319  const turnCount = await $.session.turnCount();
320  const seed = hashSeed(sessionId + ':' + turnCount + ':' + now);
321  machine.pullLever(liveState, now, seed);
322  spinGeneration += 1;
323  startTicker($);
324  maybePlaySound($, 'lever');
325}
326
327async function handleTurnComplete($, e) {
328  if (!liveState) return;
329  if (liveState.leverAt == null || liveState.stopAt != null) return; // no lever pulled for this turn, or already resolved
330  const now = $.clock.now();
331  machine.stop(liveState, now, e.reason);
332  currentTurnId = null;
333
334  const resultSnapshot = cloneJson(liveState);
335  pushPendingSpin({ turnId: e.turnId, durationMs: e.durationMs, resultSnapshot });
336
337  await saveEconomy($, liveState.economy);
338
339  const outcomeClass = e.reason === 'aborted' ? 'ABORTED' : e.reason === 'error' ? 'ERROR' : liveState.outcome.outcomeClass;
340  const tier = liveState.outcome.tier;
341  const animMs = e.reason === 'aborted'
342    ? machine.TIMINGS.jamMs
343    : e.reason === 'error'
344      ? machine.TIMINGS.malfunctionMs
345      : machine.TIMINGS.outcomeMs(outcomeClass, tier);
346
347  if (e.reason === 'aborted') {
348    notifyAborted($);
349  } else if (outcomeClass === 'JACKPOT') {
350    notifyJackpot($, liveState.economy.lastWin);
351  }
352  maybePlaySound($, outcomeSoundKind(outcomeClass));
353
354  scheduleTickerStop($, animMs + 500);
355}
356
357// ---------------------------------------------------------------------------
358// register(on, options) — the module's only export.
359// ---------------------------------------------------------------------------
360
361/** @type {import('claude-code').Register} */
362export function register(on, options) {
363  cfg = resolveOptions(options);
364
365  on('session.start', async ($, e, next) => {
366    try {
367      resetTransientState();
368      liveState = await initSession($);
369      return next(e);
370    } catch (err) {
371      return next(e);
372    }
373  });
374
375  on('prompt.submit', async ($, e, next) => {
376    // Run the rest of the chain FIRST and inspect what it resolved to before
377    // committing to a spin: a downstream plugin/policy hook can legitimately
378    // stop the turn with `{ drop: reason }` (claude-code.d.ts), in which case
379    // turn.start/turn.complete never fire for this prompt. Pulling the lever
380    // unconditionally beforehand — the old order — left the machine wedged
381    // in SPINNING forever (no time cap on that phase) and blocked every
382    // future lever pull, since the ticker is only ever stopped from
383    // turn.complete. next(e) is intentionally NOT wrapped in the try below:
384    // it must be called exactly once, so a failure in it propagates and lets
385    // the engine skip this whole hook rather than risk a double-invoke.
386    const result = await next(e);
387    try {
388      if (!(result && result.drop)) {
389        await maybePullLever($, e);
390      }
391    } catch (err) {
392      // pulling the lever is our own visual bookkeeping — never let a
393      // failure here affect the prompt.submit result the chain already produced
394    }
395    return result;
396  });
397
398  on('turn.start', async ($, e, next) => {
399    try {
400      currentTurnId = e.turnId;
401      return next(e);
402    } catch (err) {
403      return next(e);
404    }
405  });
406
407  on('turn.step', async ($, e, next) => {
408    try {
409      if (e.toolUses && e.toolUses.length > 0) toolPulseAt = $.clock.now();
410      return next(e);
411    } catch (err) {
412      return next(e);
413    }
414  });
415
416  on('turn.complete', async ($, e, next) => {
417    try {
418      await handleTurnComplete($, e);
419    } catch (err) {
420      // never let our own bookkeeping alter or block the turn's answer
421    }
422    return next(e);
423  });
424
425  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
426    try {
427      if (e.surface !== 'terminal') return next(e);
428      if (!liveState) return next(e);
429      // Ticker lifecycle is owned solely by prompt.submit/turn.complete (see
430      // those hooks below) — a render hook must never restart it itself: an
431      // unrelated external ui.render invalidate arriving after our own
432      // scheduled ticker-stop would otherwise resurrect a ticker with no
433      // future stop ever scheduled for it again. (Caught live in testing: an
434      // unrelated invalidate ~30s after a turn.complete reignited a ticker
435      // that then ran forever, because startTicker($) used to live here too.)
436      const now = $.clock.now();
437      const columns = e.viewport ? e.viewport.columns : undefined;
438      const elapsedMs = liveState.leverAt != null ? Math.max(0, now - liveState.leverAt) : 0;
439      const info = { elapsedMs, mode: e.props.mode, tokens: null, hint: 'esc to interrupt', toolPulseAt };
440      const built = machine.frame(liveState, now, { size: cfg.size, columns, info });
441      return renderTree(built);
442    } catch (err) {
443      return next(e);
444    }
445  });
446
447  on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
448    try {
449      if (e.surface !== 'terminal') return next(e);
450      // See the Spinner hook above: the ticker's lifecycle is never
451      // restarted from a render hook.
452      const spin = resolveSpinForRequest(e.requestId, e.props.durationMs);
453      if (!spin) return next(e);
454      const now = $.clock.now();
455      const columns = e.viewport ? e.viewport.columns : undefined;
456      if (machine.isAnimating(spin.resultSnapshot, now)) {
457        const built = machine.frame(spin.resultSnapshot, now, { size: cfg.size, columns, info: {} });
458        return renderTree(built);
459      }
460      const built = machine.resultLine(spin.resultSnapshot, { columns });
461      return renderTree(built);
462    } catch (err) {
463      return next(e);
464    }
465  });
466
467  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
468    try {
469      if (e.surface !== 'terminal') return next(e);
470      if (cfg.idle === 'off' || e.props.hasSurvey) return next(e);
471      if (!liveState) return next(e);
472      const now = $.clock.now();
473      const columns = e.viewport ? e.viewport.columns : undefined;
474      const maxRows = e.props.maxRows;
475      const size = resolveAboveSize(cfg.idle, columns, maxRows);
476      const elapsedMs = liveState.leverAt != null ? Math.max(0, now - liveState.leverAt) : 0;
477      const info = { elapsedMs, tokens: null, hint: 'esc to interrupt' };
478      const built = machine.frame(liveState, now, { size, columns, maxRows, info });
479      return renderTree(built);
480    } catch (err) {
481      return next(e);
482    }
483  });
484}
485
lib/machine.js 249 lines
1// Claude Slots — the pure, time-driven state machine (lib/machine.js)
2//
3// Pure ESM, zero dependencies. Implements docs/ARCHITECTURE.md §3: given a
4// `state` (plain, JSON-serializable data) and a wall-clock `now` in
5// milliseconds, `frame(state, now, opts)` always returns the identical
6// Frame — no hidden clocks, no randomness beyond the outcome rolled once at
7// `pullLever`. Both Tier 1 (polling ~every 100ms) and Tier 2 (polling every
8// 1000ms) call the exact same functions; they just sample at different
9// rates.
10//
11//   IDLE --pullLever(now)--> LEVER -> SPINUP -> SPINNING (until stop())
12//   SPINNING --stop(now,'answer'|'refusal')--> STOPPING -> REVEAL -> OUTCOME -> RESULT
13//   SPINNING --stop(now,'aborted')--> JAM -> RESULT
14//   SPINNING --stop(now,'error')--> MALFUNCTION -> RESULT
15//
16// All storyboard content (what each phase/outcome looks like) lives in
17// art.js; this file only knows *when* phases start and end, and the handful
18// of numbers (reel-lock booleans, the near-miss overshoot flag, the
19// pre-payout credit snapshot) that are genuinely timing/economy concerns.
20
21import { rollOutcome } from './rng.js';
22import { createEconomy, applySpinStart, applyOutcome, DEFAULT_START_CREDITS } from './economy.js';
23import {
24  renderFrame,
25  resultLine as artResultLine,
26  LEVER_DURATION_MS,
27  SPINUP_DURATION_MS,
28  STOPPING_STAGGER_MS,
29  OVERSHOOT_MS,
30  REVEAL_DURATION_MS,
31  JAM_DURATION_MS,
32  MALFUNCTION_DURATION_MS,
33  outcomeDurationMs,
34} from './art.js';
35
36/** Timing constants shared by both tiers — see art.js for the numbers themselves (this file just adds them up). */
37export const TIMINGS = Object.freeze({
38  leverMs: LEVER_DURATION_MS,
39  spinupMs: SPINUP_DURATION_MS,
40  stoppingStaggerMs: STOPPING_STAGGER_MS,
41  overshootMs: OVERSHOOT_MS,
42  revealMs: REVEAL_DURATION_MS,
43  jamMs: JAM_DURATION_MS,
44  malfunctionMs: MALFUNCTION_DURATION_MS,
45  outcomeMs: outcomeDurationMs,
46});
47
48const STOPPING_TOTAL = (isNearMiss) => 2 * TIMINGS.stoppingStaggerMs + (isNearMiss ? TIMINGS.overshootMs : 0);
49
50/**
51 * @typedef {{
52 *   version: 1,
53 *   options: {startCredits:number, theme:'classic'|'emoji', grandEnabled:boolean},
54 *   economy: import('./economy.js').Economy,
55 *   seed: number|string|null,
56 *   outcome: ReturnType<typeof rollOutcome>|null,
57 *   leverAt: number|null,
58 *   stopAt: number|null,
59 *   stopReason: 'answer'|'aborted'|'error'|'refusal'|null,
60 *   creditsBeforeOutcome: number,
61 * }} State
62 */
63
64/**
65 * Create a fresh, idle machine state. Accepts either `createState(opts)` or
66 * `createState(now, opts)` (some early integration sketches called it with a
67 * leading timestamp) — both forms work.
68 * @param {number|object} [nowOrOpts]
69 * @param {object} [maybeOpts]
70 * @returns {State}
71 */
72export function createState(nowOrOpts, maybeOpts) {
73  const opts = typeof nowOrOpts === 'number' ? maybeOpts || {} : nowOrOpts || {};
74  return {
75    version: 1,
76    options: {
77      startCredits: Number.isFinite(opts.startCredits) ? opts.startCredits : DEFAULT_START_CREDITS,
78      theme: opts.theme === 'emoji' ? 'emoji' : 'classic',
79      grandEnabled: opts.grandEnabled !== false,
80    },
81    economy: createEconomy({ startCredits: opts.startCredits }),
82    seed: null,
83    outcome: null,
84    leverAt: null,
85    stopAt: null,
86    stopReason: null,
87    creditsBeforeOutcome: 0,
88  };
89}
90
91/**
92 * Pull the lever: charges the spin cost, rolls this spin's outcome
93 * (deterministically, from `seed`), and starts the LEVER phase at `now`.
94 * Mutates and returns `state`.
95 * @param {State} state
96 * @param {number} now
97 * @param {number|string} seed
98 * @returns {State}
99 */
100export function pullLever(state, now, seed) {
101  applySpinStart(state.economy);
102  state.seed = seed;
103  state.outcome = rollOutcome(seed, { credits: state.economy.credits });
104  state.leverAt = now;
105  state.stopAt = null;
106  state.stopReason = null;
107  return state;
108}
109
110/**
111 * Stop the spin: records when/why, snapshots pre-payout credits (so
112 * frame()'s progressive payout counter has a start value to interpolate
113 * from), and immediately applies the economic consequence (payout or
114 * refund) — the OUTCOME animation's "counter tick" is purely a display
115 * interpolation over this already-final credits value, computed in art.js.
116 * @param {State} state
117 * @param {number} now
118 * @param {'answer'|'aborted'|'error'|'refusal'} reason
119 * @returns {State}
120 */
121export function stop(state, now, reason) {
122  if (!state.outcome) {
123    throw new Error('machine.stop: called before pullLever (no outcome to resolve)');
124  }
125  state.stopAt = now;
126  state.stopReason = reason;
127  state.creditsBeforeOutcome = state.economy.credits;
128  if (reason === 'aborted' || reason === 'error') {
129    applyOutcome(state.economy, { outcomeClass: reason === 'aborted' ? 'ABORTED' : 'ERROR' });
130  } else {
131    applyOutcome(state.economy, state.outcome);
132  }
133  return state;
134}
135
136/**
137 * Compute which phase `state` is in at `now`, and how far into that phase.
138 * Pure function of (state, now) — no side effects, no mutation.
139 * @param {State} state
140 * @param {number} now
141 * @returns {{phase:string, t:number, reelLocks:[boolean,boolean,boolean], overshoot:boolean}}
142 */
143export function phaseAt(state, now) {
144  if (state.leverAt == null) {
145    return { phase: state.outcome ? 'RESULT' : 'IDLE', t: now, reelLocks: [true, true, true], overshoot: false };
146  }
147
148  // stop() can land at any point — even mid LEVER/SPINUP, if the turn ends
149  // (or is interrupted) very fast — so it takes priority over the
150  // lever/spin-up/spinning timeline the instant it has happened.
151  if (state.stopAt == null) {
152    const sinceLever = now - state.leverAt;
153    if (sinceLever < TIMINGS.leverMs) {
154      return { phase: 'LEVER', t: sinceLever, reelLocks: [false, false, false], overshoot: false };
155    }
156    if (sinceLever < TIMINGS.leverMs + TIMINGS.spinupMs) {
157      return { phase: 'SPINUP', t: sinceLever - TIMINGS.leverMs, reelLocks: [false, false, false], overshoot: false };
158    }
159    return { phase: 'SPINNING', t: sinceLever - TIMINGS.leverMs - TIMINGS.spinupMs, reelLocks: [false, false, false], overshoot: false };
160  }
161
162  const sinceStop = now - state.stopAt;
163  const reason = state.stopReason;
164
165  if (reason === 'aborted') {
166    if (sinceStop < TIMINGS.jamMs) return { phase: 'JAM', t: sinceStop, reelLocks: [false, false, false], overshoot: false };
167    return { phase: 'RESULT', t: sinceStop - TIMINGS.jamMs, reelLocks: [true, true, true], overshoot: false };
168  }
169  if (reason === 'error') {
170    if (sinceStop < TIMINGS.malfunctionMs) return { phase: 'MALFUNCTION', t: sinceStop, reelLocks: [false, false, false], overshoot: false };
171    return { phase: 'RESULT', t: sinceStop - TIMINGS.malfunctionMs, reelLocks: [true, true, true], overshoot: false };
172  }
173
174  // 'answer' / 'refusal' — the normal STOPPING -> REVEAL -> OUTCOME -> RESULT path
175  const isNearMiss = state.outcome.outcomeClass === 'NEAR_MISS';
176  const stoppingTotal = STOPPING_TOTAL(isNearMiss);
177  if (sinceStop < stoppingTotal) {
178    const stagger = TIMINGS.stoppingStaggerMs;
179    const reelLocks = [true, sinceStop >= stagger, sinceStop >= 2 * stagger];
180    const overshoot = isNearMiss && sinceStop >= 2 * stagger && sinceStop < stoppingTotal;
181    return { phase: 'STOPPING', t: sinceStop, reelLocks, overshoot };
182  }
183  const sinceReveal = sinceStop - stoppingTotal;
184  if (sinceReveal < TIMINGS.revealMs) {
185    return { phase: 'REVEAL', t: sinceReveal, reelLocks: [true, true, true], overshoot: false };
186  }
187  const sinceOutcome = sinceReveal - TIMINGS.revealMs;
188  const outcomeTotal = outcomeDurationMs(state.outcome.outcomeClass, state.outcome.tier);
189  if (sinceOutcome < outcomeTotal) {
190    return { phase: 'OUTCOME', t: sinceOutcome, reelLocks: [true, true, true], overshoot: false };
191  }
192  return { phase: 'RESULT', t: sinceOutcome - outcomeTotal, reelLocks: [true, true, true], overshoot: false };
193}
194
195/** True while a spin's animation is still playing — false once RESULT is reached (or before any spin). */
196export function isAnimating(state, now) {
197  const { phase } = phaseAt(state, now);
198  return !(phase === 'IDLE' || phase === 'RESULT');
199}
200
201function resolveSize(size, columns, grandEnabled) {
202  if (size && size !== 'auto') return size;
203  if (columns >= 90 && grandEnabled) return 'grand';
204  if (columns >= 62) return 'standard';
205  return 'compact';
206}
207
208/**
209 * Render the current visual state as a Frame.
210 * @param {State} state
211 * @param {number} now
212 * @param {{size?:'auto'|'compact'|'standard'|'grand', columns?:number, maxRows?:number,
213 *          info?:{elapsedMs?:number, tokens?:number, mode?:string, hint?:string, credits?:number, lastWin?:number}}} [opts]
214 * @returns {import('./frame.js').Frame}
215 */
216export function frame(state, now, opts = {}) {
217  const columns = opts.columns ?? 60;
218  const size = resolveSize(opts.size, columns, state.options.grandEnabled);
219  const { phase, t, reelLocks, overshoot } = phaseAt(state, now);
220  const info = opts.info || {};
221  const economy = info.credits != null || info.lastWin != null ? { ...state.economy, credits: info.credits ?? state.economy.credits, lastWin: info.lastWin ?? state.economy.lastWin } : state.economy;
222  return renderFrame({
223    size, columns, maxRows: opts.maxRows,
224    phase, t, seed: state.seed,
225    outcome: state.outcome, reelLocks, overshoot,
226    economy, creditsBeforeOutcome: state.creditsBeforeOutcome,
227    info, theme: state.options.theme,
228  });
229}
230
231/**
232 * The permanent 1-line transcript record (§12) for the spin that just
233 * resolved. Only meaningful once RESULT has been reached; safe to call any
234 * time after `stop()`.
235 * @param {State} state
236 * @param {{columns?:number}} [opts]
237 * @returns {import('./frame.js').Frame}
238 */
239export function resultLine(state, opts = {}) {
240  const outcome = state.stopReason === 'aborted' ? { outcomeClass: 'ABORTED' } : state.stopReason === 'error' ? { outcomeClass: 'ERROR' } : state.outcome;
241  const elapsedSec = state.stopAt != null && state.leverAt != null ? Math.round((state.stopAt - state.leverAt) / 1000) : 0;
242  return artResultLine(outcome || { outcomeClass: 'LOSE', paylineSymbols: null }, {
243    credits: state.economy.credits,
244    elapsedSec,
245    columns: opts.columns,
246    theme: state.options.theme,
247  });
248}
249
lib/render-tree.js 47 lines
1// Claude Slots — RenderElement tree renderer (lib/render-tree.js), Tier 1
2//
3// Pure ESM, zero dependencies. Frame -> the plain-data element tree a
4// function-hooks `ui.render` handler returns (docs/ARCHITECTURE.md §2, and
5// claude-code.d.ts's RenderElement/TextProps): a column Box containing one
6// Text wrapper per line, each wrapping one Text child per span. Only the
7// allow-listed Text props are ever emitted — `color`, `backgroundColor`,
8// `bold`, `dimColor`, `inverse` — and only when actually set, since the
9// scanner/validator rejects a tree carrying any other prop. A line with no
10// spans still emits one Text child (a single space) so it can never
11// collapse and shift the rows below it.
12
13function textElement(text, style = {}) {
14  const props = {};
15  if (style.color) props.color = style.color;
16  if (style.bg) props.backgroundColor = style.bg;
17  if (style.bold) props.bold = true;
18  if (style.dim) props.dimColor = true;
19  if (style.inverse) props.inverse = true;
20  const el = { type: 'Text', children: [text] };
21  if (Object.keys(props).length) el.props = props;
22  return el;
23}
24
25/**
26 * @param {import('./frame.js').Frame} frame
27 * @returns {{type:'Box', props:{flexDirection:'column'}, children: object[]}}
28 */
29export function renderTree(frame) {
30  const children = frame.lines.map((lineSpans) => {
31    const spanEls = lineSpans.length ? lineSpans.map((s) => textElement(s.text, s)) : [textElement(' ')];
32    return { type: 'Text', children: spanEls };
33  });
34  return { type: 'Box', props: { flexDirection: 'column' }, children };
35}
36
37/**
38 * Convenience alias for rendering a single-line result Frame (§12) — a
39 * transcript result line is structurally just a 1-line Frame, so this is
40 * `renderTree` under another name for call sites that want to be explicit
41 * about rendering a result rather than a live animation frame.
42 * @param {import('./frame.js').Frame} frame
43 */
44export function renderResultTree(frame) {
45  return renderTree(frame);
46}
47
lib/rng.js 223 lines
1// Claude Slots — outcome-first RNG (lib/rng.js)
2//
3// Pure ESM, zero dependencies. Implements docs/ART-SPEC.md §4 ("do not spin
4// three independent reels and hope the probabilities fall out right") and
5// §5 (the near-miss rule): pick the outcome class first, resolve the
6// payline to match it, then derive the two dressing rows purely from
7// symbols.js's static neighbor table — no further randomness.
8//
9// NOTE on the §4 worked examples: ART-SPEC's six seed->outcome rows are
10// attributed to "a reference mulberry32 implementation... included in
11// build_spec.py" — that file only embeds the pseudocode as documentation
12// text, it contains no runnable PRNG. mulberry32 below is the well-known
13// public-domain algorithm (Tommy Ettinger); it was checked against two
14// independent, commonly-cited mulberry32 encodings and against those six
15// worked seeds — neither reproduces them bit-for-bit, so they could not be
16// used as byte-exact golden vectors here (see this package's test notes).
17// What *is* verified (tests/rng.test.js): the six-class probability table
18// sums to 1 and matches within tolerance over a large sample, the near-miss
19// shape is always exactly [S, S, NEXT(S)], dressing rows always equal
20// PREV/NEXT of the payline per symbols.js, and a given seed always
21// reproduces an identical result.
22
23import { ALL_SYMBOL_IDS, NEIGHBORS, OUTCOME_ORDER, OUTCOME_PROBABILITIES, TRIPLE_TIERS, DOUBLE_TIERS, NEARMISS_WEIGHTS, REEL_STRIPS, getPayout } from './symbols.js';
24
25/**
26 * mulberry32: a fast, small, seedable 32-bit PRNG. Returns a function that
27 * yields floats in [0, 1) on each call.
28 * @param {number} seed a uint32 (values are coerced with `>>> 0`)
29 * @returns {() => number}
30 */
31export function mulberry32(seed) {
32  let a = seed >>> 0;
33  return function mulberry32Next() {
34    a |= 0;
35    a = (a + 0x6d2b79f5) | 0;
36    let t = Math.imul(a ^ (a >>> 15), 1 | a);
37    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
38    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
39  };
40}
41
42/**
43 * Deterministically hash an arbitrary string to a uint32, for callers that
44 * want to seed mulberry32 from a session id / turn id / free-form string
45 * rather than a raw number. (cyrb-style FNV-1a variant — small, dependency
46 * free, well distributed for this use, not cryptographic.)
47 * @param {string} str
48 * @returns {number}
49 */
50export function hashSeed(str) {
51  const s = String(str);
52  let h = 0x811c9dc5;
53  for (let i = 0; i < s.length; i++) {
54    h ^= s.charCodeAt(i);
55    h = Math.imul(h, 0x01000193);
56  }
57  // final avalanche so short/similar strings don't cluster in low bits
58  h ^= h >>> 16;
59  h = Math.imul(h, 0x85ebca6b);
60  h ^= h >>> 13;
61  return h >>> 0;
62}
63
64function toUint32Seed(seed) {
65  if (typeof seed === 'number') return seed >>> 0;
66  return hashSeed(seed);
67}
68
69/**
70 * Weighted pick over an ordered {id: weight} map. Weights need not sum to
71 * 1 — they are normalized against their own total, matching §4's
72 * `weightedPick(rng, TABLE)` calls against tables whose weights are
73 * fractions of the *outer* class probability (e.g. TRIPLE_TIERS sums to
74 * 0.08, not 1).
75 * @param {() => number} rng
76 * @param {Record<string, number>} weights
77 */
78function weightedPick(rng, weights) {
79  const entries = Object.entries(weights);
80  const total = entries.reduce((sum, [, w]) => sum + w, 0);
81  const r = rng() * total;
82  let acc = 0;
83  for (const [id, w] of entries) {
84    acc += w;
85    if (r < acc) return id;
86  }
87  return entries[entries.length - 1][0];
88}
89
90function uniformPick(rng, ids) {
91  const idx = Math.min(ids.length - 1, Math.floor(rng() * ids.length));
92  return ids[idx];
93}
94
95function rollOutcomeClass(rng) {
96  const r = rng();
97  let acc = 0;
98  for (const cls of OUTCOME_ORDER) {
99    acc += OUTCOME_PROBABILITIES[cls];
100    if (r < acc) return cls;
101  }
102  return 'LOSE'; // floating-point fallback, never reached if the table sums to 1.0
103}
104
105/**
106 * True if `[a,b,c]` has the canonical near-miss shape (two matching reels,
107 * the third the matched symbol's NEXT) in ANY reel arrangement. A guard used
108 * only by the LOSE reroll loop; given the loop's own distinctness check this
109 * can never actually trigger (a genuine near-miss requires exactly two equal
110 * entries) — kept as the belt-and-suspenders check §4's pseudocode specifies.
111 */
112function looksLikeNearMiss(a, b, c) {
113  const trip = [a, b, c];
114  for (let i = 0; i < 3; i++) {
115    for (let j = 0; j < 3; j++) {
116      if (i === j) continue;
117      const k = 3 - i - j;
118      if (trip[i] === trip[j] && NEIGHBORS.NEXT[trip[i]] === trip[k]) return true;
119    }
120  }
121  return false;
122}
123
124function resolvePayline(cls, rng) {
125  switch (cls) {
126    case 'JACKPOT':
127      return ['CLAUDE', 'CLAUDE', 'CLAUDE'];
128    case 'MEDALLION':
129      return ['MEDALLION', 'MEDALLION', 'MEDALLION'];
130    case 'TRIPLE': {
131      const tier = weightedPick(rng, TRIPLE_TIERS);
132      return [tier, tier, tier];
133    }
134    case 'DOUBLE': {
135      const tier = weightedPick(rng, DOUBLE_TIERS);
136      const oddPos = Math.floor(rng() * 3);
137      const pool = ALL_SYMBOL_IDS.filter((id) => id !== tier && id !== 'MEDALLION');
138      const filler = uniformPick(rng, pool);
139      const paylineLine = [tier, tier, tier];
140      paylineLine[oddPos] = filler;
141      return paylineLine;
142    }
143    case 'NEAR_MISS': {
144      const sym = weightedPick(rng, NEARMISS_WEIGHTS);
145      return [sym, sym, NEIGHBORS.NEXT[sym]];
146    }
147    case 'LOSE':
148    default: {
149      const GUARD = 10000; // pathological-fallback guard; a correctly-weighted 8-symbol alphabet resolves in a handful of tries
150      for (let i = 0; i < GUARD; i++) {
151        const a = uniformPick(rng, ALL_SYMBOL_IDS);
152        const b = uniformPick(rng, ALL_SYMBOL_IDS);
153        const c = uniformPick(rng, ALL_SYMBOL_IDS);
154        if (new Set([a, b, c]).size === 3 && !looksLikeNearMiss(a, b, c)) return [a, b, c];
155      }
156      return ['FILE', 'TEST', 'DIFF']; // never reached with 8 distinct symbols; a safe, definitely-3-distinct fallback
157    }
158  }
159}
160
161function fillNonPaylineRows(paylineSymbols) {
162  return {
163    above: paylineSymbols.map((s) => NEIGHBORS.PREV[s]),
164    below: paylineSymbols.map((s) => NEIGHBORS.NEXT[s]),
165  };
166}
167
168/**
169 * Roll a full spin outcome for `seed` — deterministic: the same seed always
170 * returns an identical result (verified in tests). Implements ART-SPEC §4-5
171 * exactly: outcome class first, then payline, then pure (no further
172 * randomness) dressing rows from the static neighbor table.
173 *
174 * @param {number|string} seed a uint32, or any string (hashed via hashSeed)
175 * @param {{credits?: number}} [opts] accepted for forward-compatibility with
176 *   callers that want to pass current-state context through; the paytable
177 *   is a flat lookup per §2 and `credits` does not currently affect the roll.
178 * @returns {{
179 *   outcomeClass: 'JACKPOT'|'MEDALLION'|'TRIPLE'|'DOUBLE'|'NEAR_MISS'|'LOSE',
180 *   tier: string|null,
181 *   paylineSymbols: [string,string,string],
182 *   grid: [string,string,string][],
183 *   targetStops: [number,number,number],
184 *   payout: number,
185 *   overshootReel?: number,
186 * }}
187 */
188export function rollOutcome(seed, opts = {}) {
189  void opts; // see jsdoc: reserved, currently unused
190  const rng = mulberry32(toUint32Seed(seed));
191  const outcomeClass = rollOutcomeClass(rng);
192  const paylineSymbols = resolvePayline(outcomeClass, rng);
193  const { above, below } = fillNonPaylineRows(paylineSymbols);
194
195  // grid[reel][row], row 0 = above the payline, row 1 = the payline itself, row 2 = below
196  const grid = [0, 1, 2].map((reelIdx) => [above[reelIdx], paylineSymbols[reelIdx], below[reelIdx]]);
197
198  // targetStops: the first-occurrence strip index each reel settles on. Using
199  // first occurrence (the same convention symbols.js's NEIGHBORS table uses)
200  // keeps grid's dressing rows consistent with "one stop above/below" on the
201  // actual reel-strip window a renderer might display while settling.
202  const targetStops = [0, 1, 2].map((reelIdx) => REEL_STRIPS[reelIdx].indexOf(paylineSymbols[reelIdx]));
203
204  let tier = null;
205  if (outcomeClass === 'TRIPLE') {
206    tier = paylineSymbols[0];
207  } else if (outcomeClass === 'DOUBLE') {
208    const counts = {};
209    for (const s of paylineSymbols) counts[s] = (counts[s] || 0) + 1;
210    tier = Object.keys(counts).find((k) => counts[k] === 2) ?? null;
211  }
212
213  const payout = getPayout(outcomeClass, tier);
214
215  const result = { outcomeClass, tier, paylineSymbols, grid, targetStops, payout };
216  if (outcomeClass === 'NEAR_MISS') result.overshootReel = 2; // always reel 3 (index 2), per §5
217  return result;
218}
219
220// Compatibility alias: some early integration sketches referred to this
221// function as `roll`.
222export { rollOutcome as roll };
223
lib/economy.js 115 lines
1// Claude Slots — credits economy (lib/economy.js)
2//
3// Pure ESM, zero dependencies. Implements docs/ART-SPEC.md §10: starting
4// balance, spin cost charged at the lever (never on reveal), payouts,
5// refunds for interrupted/errored spins, and the "ON THE HOUSE" floor —
6// balance never goes negative and a spin is never blocked by lack of
7// credits.
8
9/** Starting balance for a brand-new session (§10; also plugin.json's userConfig default). */
10export const DEFAULT_START_CREDITS = 100;
11
12/** Cost of a single spin, in credits (§10). */
13export const SPIN_COST = 1;
14
15/**
16 * @typedef {{
17 *   credits: number,
18 *   lastWin: number,
19 *   stats: {
20 *     spins: number,
21 *     winsByClass: Record<string, number>,
22 *     jackpots: number,
23 *     streak: number,
24 *     bestStreak: number,
25 *     recent: string[],
26 *   },
27 * }} Economy
28 */
29
30/**
31 * @param {{startCredits?: number}} [opts]
32 * @returns {Economy}
33 */
34export function createEconomy(opts = {}) {
35  const startCredits = Number.isFinite(opts.startCredits) ? Math.max(0, opts.startCredits) : DEFAULT_START_CREDITS;
36  return {
37    credits: startCredits,
38    lastWin: 0,
39    stats: {
40      spins: 0,
41      winsByClass: { JACKPOT: 0, MEDALLION: 0, TRIPLE: 0, DOUBLE: 0, NEAR_MISS: 0, LOSE: 0, ABORTED: 0, ERROR: 0 },
42      jackpots: 0,
43      streak: 0,
44      bestStreak: 0,
45      recent: [],
46    },
47  };
48}
49
50/**
51 * Charge the spin cost the instant the lever is pulled (§8.2/§10 — never on
52 * reveal). Floors at 0 rather than going negative or blocking the spin
53 * ("ON THE HOUSE", §8.1/§10). Mutates and returns `economy`.
54 * @param {Economy} economy
55 * @returns {Economy}
56 */
57export function applySpinStart(economy) {
58  economy.credits = Math.max(0, economy.credits - SPIN_COST);
59  economy.stats.spins += 1;
60  return economy;
61}
62
63function pushRecent(economy, cls, symbols) {
64  economy.stats.recent.unshift({ outcomeClass: cls, symbols: symbols || null });
65  if (economy.stats.recent.length > 3) economy.stats.recent.length = 3;
66}
67
68/**
69 * Apply a resolved spin's consequence to `economy`. `outcome` is either an
70 * rng.js rollOutcome() result ({outcomeClass, payout, paylineSymbols, ...})
71 * for a completed turn ('answer'/'refusal'), or a synthetic
72 * {outcomeClass: 'ABORTED'|'ERROR'} for an interrupted/errored turn — those
73 * two refund the spin cost, since no payline was ever actually resolved
74 * (§8.13-8.14, §10). Mutates and returns `economy`.
75 *
76 * `economy.stats.recent` holds, most-recent-first, up to 3
77 * `{outcomeClass, symbols}` entries (`symbols` is the 3-wide payline, or
78 * `null` for ABORTED/ERROR) — enough for art.js's GRAND-size RECENT field
79 * (§7.3) to render each past spin's payline.
80 *
81 * @param {Economy} economy
82 * @param {{outcomeClass: string, payout?: number, paylineSymbols?: string[]}} outcome
83 * @returns {Economy}
84 */
85export function applyOutcome(economy, outcome) {
86  const cls = outcome.outcomeClass;
87
88  if (cls === 'ABORTED' || cls === 'ERROR') {
89    economy.credits += SPIN_COST; // refund: this spin resolved no payline
90    economy.lastWin = 0;
91    economy.stats.winsByClass[cls] = (economy.stats.winsByClass[cls] || 0) + 1;
92    economy.stats.streak = 0;
93    pushRecent(economy, cls, null);
94    return economy;
95  }
96
97  const payout = outcome.payout || 0;
98  economy.credits += payout;
99  economy.lastWin = payout;
100  economy.stats.winsByClass[cls] = (economy.stats.winsByClass[cls] || 0) + 1;
101  if (cls === 'JACKPOT') economy.stats.jackpots += 1;
102
103  const isWin = payout > 0;
104  economy.stats.streak = isWin ? economy.stats.streak + 1 : 0;
105  economy.stats.bestStreak = Math.max(economy.stats.bestStreak, economy.stats.streak);
106
107  pushRecent(economy, cls, outcome.paylineSymbols || null);
108  return economy;
109}
110
111/** True at 0 credits — the "ON THE HOUSE" info-strip substitution applies (§8.1/§10). */
112export function isOnTheHouse(economy) {
113  return economy.credits <= 0;
114}
115
lib/art.js 360 lines
1// Claude Slots — chassis art & storyboards (lib/art.js)
2//
3// Pure ESM, zero dependencies. Owns everything docs/ART-SPEC.md §7-§14
4// describe: the STANDARD/COMPACT/GRAND chassis geometry, the marquee/reel
5// bay/lever primitives, every phase's and outcome class's storyboard
6// content and sub-beat timing, the info strip, the transcript result line
7// (§12), and RETURN TO IDLE (§8.15). machine.js supplies only *when*
8// (phase, elapsed-ms-in-phase, the pre-rolled outcome, reel-lock booleans);
9// this file decides everything about what that looks like. Every builder
10// returns a Frame and is assertRect-checked before it is handed back.
11
12// This file holds the chassis geometry/primitives, the STANDARD/GRAND
13// builder, and the §12 transcript result line. The per-phase/per-outcome
14// storyboard content (what LEVER/SPINNING/OUTCOME/etc. actually look like,
15// and the COMPACT/ULTRA-COMPACT layouts) lives in ./art-storyboards.js,
16// re-exported below — split out purely to keep each file under a readable
17// line budget; machine.js and tests both just `import ... from './art.js'`.
18import { makeFrame, span, line, compose, assertRect, frameToPlainText } from './frame.js';
19import { displayWidth } from './width.js';
20import { SYMBOL_IDS, getSymbol, getThemedGlyph, COLORS } from './symbols.js';
21
22// ---------------------------------------------------------------------------
23// Storyboard timing constants — the single source of truth for how long
24// each phase/sub-beat lasts. machine.js imports these for its phase-boundary
25// arithmetic; this file uses them to pick which sub-frame is on screen.
26// ---------------------------------------------------------------------------
27
28export const LEVER_DURATION_MS = 800; // §8.2, 5 knob positions ~160ms apart
29export const SPINUP_DURATION_MS = 1200; // §8.3, 3 stagger beats of 400ms
30export const STOPPING_STAGGER_MS = 300; // §8.5: reel2 +300, reel3 +600
31export const OVERSHOOT_MS = 80; // §5: the illusory near-miss triple beat
32export const REVEAL_DURATION_MS = 300; // §8.6
33export const JAM_DURATION_MS = 600; // §8.13: 300 brake + 300 settled
34export const MALFUNCTION_DURATION_MS = 600; // §8.14: mirrors JAM's timing
35
36/** OUTCOME phase total duration, ms, by class (TRIPLE is further keyed by tier). */
37export const OUTCOME_DURATIONS_MS = Object.freeze({
38  JACKPOT: 2800,
39  MEDALLION: 2100,
40  TRIPLE: Object.freeze({ TOOL: 2200, TEST: 1900, TOKEN: 1700, FILE: 1600, DIFF: 1500, PROSE: 1500 }),
41  DOUBLE: 1500,
42  NEAR_MISS: 1800,
43  LOSE: 1600,
44});
45
46export function outcomeDurationMs(outcomeClass, tier) {
47  const d = OUTCOME_DURATIONS_MS[outcomeClass];
48  if (d == null) return 1600;
49  return typeof d === 'number' ? d : d[tier] ?? 1600;
50}
51
52// ---------------------------------------------------------------------------
53// Chassis geometry (§7)
54// ---------------------------------------------------------------------------
55
56const STANDARD_GEO = Object.freeze({ width: 60, interior: 58, leftMargin: 10, cellW: 5, gap: 1, leverGap: 4 });
57const GRAND_GEO = Object.freeze({ width: 80, interior: 78, leftMargin: 14, cellW: 7, gap: 1, leverGap: 4 });
58export const COMPACT_WIDTH = 40;
59
60export const TITLE = '✻';
61
62export function roleStyle(role) {
63  const s = { color: role.hex };
64  if (role.bold) s.bold = true;
65  if (role.dim) s.dim = true;
66  return s;
67}
68
69function centerText(text, w) {
70  const tw = displayWidth(text);
71  const pad = w - tw;
72  if (pad < 0) throw new Error(`centerText: ${JSON.stringify(text)} (width ${tw}) doesn't fit in ${w}`);
73  const left = Math.floor(pad / 2);
74  return ' '.repeat(left) + text + ' '.repeat(pad - left);
75}
76
77// --- reel-cell content resolution -------------------------------------------
78
79/** True if `text` is one of the eight canonical symbol ids. */
80export function isSymbolId(text) {
81  return SYMBOL_IDS.includes(text);
82}
83
84function blurStyle(ch) {
85  if (ch === '▓') return { color: COLORS.motionBlur.from };
86  if (ch === '░') return { color: COLORS.motionBlur.to };
87  if (ch === '▒') return { color: '#73757D' }; // midpoint of motionBlur.from/to
88  if (ch === '—') return { color: COLORS.hintText.hex, dim: true };
89  return { color: COLORS.infoText.hex };
90}
91
92/**
93 * Resolve one reel cell's glyph + style. `entry` is either a symbol id
94 * (§1's eight glyphs) or a literal decoration string ('▓','▒','░','—', a
95 * single space for a still-blank slot). `tone: 'dim'` desaturates a real
96 * symbol to the LOSE-state neutral gray (§9's "LOSE state... dim" rule).
97 */
98function cellContent(entry, { hot = false, tone = 'normal', theme = 'classic' } = {}) {
99  if (isSymbolId(entry)) {
100    const sym = getSymbol(entry);
101    const glyph = getThemedGlyph(entry, theme);
102    if (tone === 'dim') return { glyph, style: { color: COLORS.loseNeutral.hex, dim: true } };
103    return { glyph, style: { color: sym.color.hex, bold: !!sym.color.bold || hot } };
104  }
105  return { glyph: entry, style: blurStyle(entry) };
106}
107
108// --- box-drawing primitives --------------------------------------------------
109
110function boxEdge(kind, hot, w) {
111  const left = hot ? (kind === 'top' ? '╔' : '╚') : kind === 'top' ? '┌' : '└';
112  const right = hot ? (kind === 'top' ? '╗' : '╝') : kind === 'top' ? '┐' : '┘';
113  const fill = hot ? '═' : '─';
114  return left + fill.repeat(w) + right;
115}
116
117function wallStyle(hot) {
118  return roleStyle(hot ? COLORS.reelFrameHot : COLORS.reelFrameIdle);
119}
120
121/**
122 * Push the placements for one reel-bay row (top/mid/bot) across all 3 cells
123 * into `placements`, starting at `startCol`. `entries` is a length-3 array
124 * (ignored for top/bot). `payline` swaps the row's two OUTERMOST wall
125 * characters for the block-bar markers (§6/§7 anatomy note) — internal
126 * cell walls are untouched.
127 */
128function pushReelRow(placements, startCol, geo, kind, entries, hots, payline, opts) {
129  const { cellW: w, gap } = geo;
130  const cellSpan = w + 2 + gap;
131  for (let i = 0; i < 3; i++) {
132    const col = startCol + i * cellSpan;
133    const hot = hots[i];
134    if (kind === 'top' || kind === 'bot') {
135      placements.push({ col, text: boxEdge(kind, hot, w), style: wallStyle(hot) });
136    } else {
137      const wallCh = hot ? '║' : '│';
138      const ws = wallStyle(hot);
139      const { glyph, style } = cellContent(entries[i], { hot, ...opts });
140      placements.push({ col, text: wallCh, style: ws });
141      placements.push({ col: col + 1, text: centerText(glyph, w), style });
142      placements.push({ col: col + 1 + w, text: wallCh, style: ws });
143    }
144  }
145  if (payline) {
146    const rowWidth = 3 * (w + 2) + 2 * gap;
147    const barStyle = roleStyle(hots.some(Boolean) ? COLORS.paylineBarHot : COLORS.paylineBarIdle);
148    placements.push({ col: startCol, text: '▐', style: barStyle });
149    placements.push({ col: startCol + rowWidth - 1, text: '▌', style: barStyle });
150  }
151}
152
153const BULB_STYLES = {
154  bulbs: (ch) => (ch === '●' ? roleStyle(COLORS.bulbLit) : ch === '○' ? roleStyle(COLORS.bulbUnlit) : ch === '★' ? roleStyle(COLORS.chassisBorderHot) : roleStyle(COLORS.bulbLit)),
155  blur: (ch) => blurStyle(ch),
156  decor: (ch) => {
157    if (ch === '●') return roleStyle(COLORS.coins);
158    if (ch === '○') return roleStyle(COLORS.bulbUnlit);
159    if (ch === '★') return roleStyle(COLORS.chassisBorderHot);
160    if (ch === '☆') return roleStyle(COLORS.sunburst);
161    // Was hardcoded to '#FF8A3D' — byte-identical to MEDALLION's signature
162    // orange (symbols.js), so the JAM 'INTERRUPTED' banner's flanking '◆ ◆'
163    // glyphs collided visually with the game's second-rarest win. Use the
164    // ABORTED role color instead so it reads as "something stopped", not
165    // "you won something special".
166    if (ch === '◆') return { color: COLORS.abortedText.hex };
167    if (ch === '♦') return { color: getSymbol('DIFF').color.hex };
168    if (ch === '♣') return { color: getSymbol('FILE').color.hex };
169    if (ch === '☼') return { color: getSymbol('TOKEN').color.hex };
170    if (ch === '♥') return { color: getSymbol('PROSE').color.hex };
171    if (ch === ' ') return {};
172    return { color: COLORS.infoText.hex };
173  },
174  void: () => roleStyle(COLORS.errorText),
175  brake: () => roleStyle(COLORS.abortedText),
176};
177
178function pushMarquee(placements, geo, marqueeText, bulbsLeft, bulbsRight, bulbMode, marqueeStyle) {
179  const bulbStyler = BULB_STYLES[bulbMode] || BULB_STYLES.decor;
180  const parts = [];
181  for (const ch of bulbsLeft) parts.push({ text: ch, style: bulbStyler(ch) });
182  parts.push({ text: '  ', style: {} });
183  parts.push({ text: marqueeText, style: marqueeStyle || roleStyle(COLORS.wordmark) });
184  parts.push({ text: '  ', style: {} });
185  for (const ch of bulbsRight) parts.push({ text: ch, style: bulbStyler(ch) });
186  const total = parts.reduce((w, p) => w + displayWidth(p.text), 0);
187  const pad = geo.interior - total;
188  if (pad < 0) throw new Error(`pushMarquee: marquee too wide for interior ${geo.interior}`);
189  let col = Math.floor(pad / 2);
190  for (const p of parts) {
191    if (p.text.length) placements.push({ col, text: p.text, style: p.style });
192    col += displayWidth(p.text);
193  }
194}
195
196function pushInfoStrip(placements, geo, left, right) {
197  const w = geo.interior;
198  const rw = displayWidth(right);
199  const lw = displayWidth(left);
200  if (lw + rw > w) {
201    // defensive: never throw on a too-long info line — truncate the left field
202    const budget = Math.max(0, w - rw - 1);
203    left = left.slice(0, budget);
204  }
205  placements.push({ col: 0, text: left, style: roleStyle(COLORS.infoText) });
206  if (right) placements.push({ col: w - displayWidth(right), text: right, style: roleStyle(COLORS.hintText) });
207}
208
209/**
210 * @typedef {{
211 *   marqueeText: string, bulbsLeft: string, bulbsRight: string, bulbMode?: 'bulbs'|'blur'|'decor'|'void'|'brake',
212 *   topSyms: [string,string,string], paySyms: [string,string,string], botSyms: [string,string,string],
213 *   hotCols: [boolean,boolean,boolean], tone?: 'normal'|'dim', theme?: 'classic'|'emoji',
214 *   knobRow: number|null, infoLeft: string, infoRight: string, outerHot?: boolean,
215 *   marqueeStyle?: {color?:string, bold?:boolean, dim?:boolean},
216 * }} BoxParams
217 */
218
219// Every interior cell of the chassis (between the two `║` borders) is
220// composed onto a canvas whose UNPLACED cells get this fill: an explicit
221// `bg` matching symbols.js's COLORS.background (§9's "dark ground every
222// foreground color sits on"), never a styleless space. This isn't just
223// cosmetic: Claude Code's Ink-based renderer treats a plain, unstyled
224// whitespace run as safe to skip over with a cursor-forward/CHA jump
225// instead of actually writing space bytes (verified live — see the "stale
226// digits inside the chassis" note in docs/ARCHITECTURE.md §4). That's only
227// safe when the terminal cells under the jump are already correctly blank.
228// They aren't always: the Spinner/AbovePrompt chassis renders *inline* in
229// the same scrolling transcript region as the model's streamed text
230// (ARCHITECTURE.md's scroll-region evidence), so a row the chassis is about
231// to occupy can still hold leftover transcript characters from a moment
232// earlier. Giving every interior cell an explicit background forces the
233// renderer to actually paint it — it can no longer treat the run as a
234// no-op — which overwrites any such stale content instead of skipping past
235// it. Every compose() call below that builds a chassis interior line must
236// carry this fillStyle. Exported so art-storyboards.js's COMPACT single-line
237// layout (the same class of multi-span, gapped line) can reuse it too.
238export const CHASSIS_FILL_STYLE = Object.freeze({ bg: COLORS.background.hex });
239
240/** @param {typeof STANDARD_GEO} geo @param {BoxParams} p */
241function buildBoxFrame(geo, p) {
242  const { width, interior, leftMargin } = geo;
243  const fr = makeFrame(width);
244  const borderStyle = roleStyle(p.outerHot ? COLORS.chassisBorderHot : COLORS.chassisBorderIdle);
245  const wrap = (interiorSpans) => line(span('║', borderStyle), ...interiorSpans, span('║', borderStyle));
246  const composeInterior = (placements) => compose(interior, placements, { fillStyle: CHASSIS_FILL_STYLE });
247
248  fr.lines.push(line(span('╔' + '═'.repeat(interior) + '╗', borderStyle)));
249
250  const marqueePlacements = [];
251  pushMarquee(marqueePlacements, geo, p.marqueeText, p.bulbsLeft, p.bulbsRight, p.bulbMode || 'bulbs', p.marqueeStyle);
252  fr.lines.push(wrap(composeInterior(marqueePlacements)));
253
254  fr.lines.push(line(span('╠' + '═'.repeat(interior) + '╣', borderStyle)));
255
256  const leverCol = leftMargin + 3 * (geo.cellW + 2) + 2 * geo.gap + geo.leverGap;
257  const knobStyle = roleStyle(COLORS.bulbLit);
258  const rodStyle = roleStyle(COLORS.reelFrameIdle);
259  const leverGlyphFor = (row) => {
260    if (p.knobRow == null) return null;
261    if (row < p.knobRow) return { ch: '│', style: rodStyle };
262    if (row === p.knobRow) return { ch: '●', style: knobStyle };
263    return null;
264  };
265
266  const bodyOpts = { tone: p.tone || 'normal', theme: p.theme || 'classic' };
267  const rowKinds = ['top', 'mid', 'mid', 'mid', 'bot'];
268  const rowEntries = [null, p.topSyms, p.paySyms, p.botSyms, null];
269  for (let row = 0; row < 5; row++) {
270    const placements = [];
271    pushReelRow(placements, leftMargin, geo, rowKinds[row], rowEntries[row], p.hotCols, row === 2, bodyOpts);
272    const lg = leverGlyphFor(row);
273    if (lg) placements.push({ col: leverCol, text: lg.ch, style: lg.style });
274    fr.lines.push(wrap(composeInterior(placements)));
275  }
276
277  fr.lines.push(line(span('╠' + '═'.repeat(interior) + '╣', borderStyle)));
278
279  const infoPlacements = [];
280  pushInfoStrip(infoPlacements, geo, p.infoLeft, p.infoRight);
281  fr.lines.push(wrap(composeInterior(infoPlacements)));
282
283  fr.lines.push(line(span('╚' + '═'.repeat(interior) + '╝', borderStyle)));
284
285  return assertRect(fr);
286}
287
288export function buildStandardFrame(p) {
289  return buildBoxFrame(STANDARD_GEO, p);
290}
291export function buildGrandFrame(p) {
292  return buildBoxFrame(GRAND_GEO, p);
293}
294
295function placeParts(placements, col, parts) {
296  let c = col;
297  for (const p of parts) {
298    if (p.text.length) placements.push({ col: c, text: p.text, style: p.style });
299    c += displayWidth(p.text);
300  }
301  return c;
302}
303
304// ---------------------------------------------------------------------------
305// §12 — Transcript result line (the permanent static record)
306// ---------------------------------------------------------------------------
307
308const RESULT_LABELS = {
309  JACKPOT: (o) => `JACKPOT +${o.payout}`,
310  MEDALLION: (o) => `SIGNATURE +${o.payout}`,
311  TRIPLE: (o) => `TRIPLE +${o.payout}`,
312  DOUBLE: (o) => `DOUBLE +${o.payout}`,
313  NEAR_MISS: () => `so close +0`,
314  LOSE: () => `no win`,
315  ABORTED: () => `INTERRUPTED`,
316  ERROR: () => `ERROR`,
317};
318
319/**
320 * Build the permanent 1-line transcript record (§12): `▐ a │ b │ c ▌ LABEL
321 * payout ✦ credits N · Ts`. `outcome` is a rollOutcome() result, or a
322 * synthetic `{outcomeClass:'ABORTED'|'ERROR'}`.
323 * @param {{outcomeClass:string, paylineSymbols?:string[], payout?:number}} outcome
324 * @param {{credits:number, elapsedSec:number, theme?:string}} info
325 */
326export function resultLine(outcome, info) {
327  const cls = outcome.outcomeClass;
328  const theme = info.theme || 'classic';
329  const symbols = outcome.paylineSymbols || ['—', '—', '—'];
330  const placements = [];
331  let col = 0;
332  const barStyle = roleStyle(cls === 'ABORTED' || cls === 'ERROR' ? COLORS.abortedText : COLORS.paylineBarIdle);
333  col = placeParts(placements, col, [{ text: '▐ ', style: barStyle }]);
334  symbols.forEach((s, i) => {
335    if (i > 0) col = placeParts(placements, col, [{ text: ' │ ', style: roleStyle(COLORS.infoText) }]);
336    const { glyph, style } = isSymbolId(s) ? cellContent(s, { theme }) : { glyph: s, style: blurStyle(s) };
337    col = placeParts(placements, col, [{ text: glyph, style }]);
338  });
339  col = placeParts(placements, col, [{ text: ' ▌ ', style: barStyle }]);
340  const label = (RESULT_LABELS[cls] || (() => cls))(outcome);
341  const labelStyle = cls === 'ERROR' ? roleStyle(COLORS.errorText) : cls === 'ABORTED' ? roleStyle(COLORS.abortedText) : roleStyle(COLORS.wordmark);
342  col = placeParts(placements, col, [{ text: label, style: labelStyle }]);
343  col = placeParts(placements, col, [{ text: ` ✦ credits ${Math.max(0, Math.trunc(info.credits))} · ${Math.max(0, Math.trunc(info.elapsedSec))}s`, style: roleStyle(COLORS.infoText) }]);
344
345  const width = Math.max(col, info.columns || col);
346  const fr = makeFrame(width);
347  const pad = width - col;
348  const spans = placements.map((p) => span(p.text, p.style));
349  // Same rationale as CHASSIS_FILL_STYLE above: an explicit bg on the
350  // trailing pad keeps the renderer from CHA-jumping over it and leaving
351  // whatever a wider previous line (e.g. the "Baked for Ns" TurnDuration
352  // text this result line replaces) left behind at those columns.
353  if (pad > 0) spans.push(span(' '.repeat(pad), CHASSIS_FILL_STYLE));
354  fr.lines.push(spans);
355  return assertRect(fr);
356}
357
358export { frameToPlainText };
359export { resolveParams, renderFrame } from './art-storyboards.js';
360
lib/frame.js 220 lines
1// Claude Slots — the Frame model (lib/frame.js)
2//
3// Pure ESM, zero dependencies, no Node/DOM/browser APIs (see lib/width.js).
4//
5// Frame is the contract between the engine (machine.js/art.js) and the two
6// renderers (render-ansi.js, render-tree.js) — docs/ARCHITECTURE.md §2:
7//
8//   Frame = { width: number, lines: Span[][] }
9//   Span  = { text: string, color?: '#rrggbb', bold?: boolean, dim?: boolean,
10//             inverse?: boolean, bg?: '#rrggbb' }
11//
12// Every line in a Frame must sum to exactly `frame.width` display columns
13// (assertRect below enforces this). Builders in art.js compose each line
14// from fixed-width primitives via `compose`/`canvas`/`place`, mirroring the
15// approach docs/ART-SPEC.md's own generator (frames_lib.py/gen.py) uses to
16// guarantee every golden frame is a true rectangle before it ships.
17
18import { displayWidth, unitWidth, graphemeUnits } from './width.js';
19
20/**
21 * @typedef {{text: string, color?: string, bg?: string, bold?: boolean, dim?: boolean, inverse?: boolean}} Span
22 * @typedef {{width: number, lines: Span[][]}} Frame
23 */
24
25/**
26 * Create an empty Frame shell of a fixed display width.
27 * @param {number} width
28 * @returns {Frame}
29 */
30export function makeFrame(width) {
31  if (!Number.isInteger(width) || width <= 0) {
32    throw new Error(`makeFrame: width must be a positive integer, got ${JSON.stringify(width)}`);
33  }
34  return { width, lines: [] };
35}
36
37/**
38 * Build a Span. Only style keys that are actually set are copied onto the
39 * result, so spans serialize compactly and render-tree.js's "omit undefined
40 * props" rule is trivial to satisfy downstream.
41 * @param {string} text
42 * @param {{color?:string, bg?:string, bold?:boolean, dim?:boolean, inverse?:boolean}} [style]
43 * @returns {Span}
44 */
45export function span(text, style = {}) {
46  if (typeof text !== 'string') throw new Error(`span: text must be a string, got ${JSON.stringify(text)}`);
47  if (text.includes('\n')) throw new Error('span: text must not contain newlines');
48  const s = { text };
49  if (style.color) s.color = style.color;
50  if (style.bg) s.bg = style.bg;
51  if (style.bold) s.bold = true;
52  if (style.dim) s.dim = true;
53  if (style.inverse) s.inverse = true;
54  return s;
55}
56
57/** Display width of a single span's text. */
58export function spanWidth(s) {
59  return displayWidth(s.text);
60}
61
62/** Display width of a whole line (array of spans). */
63export function lineWidth(lineSpans) {
64  return lineSpans.reduce((w, s) => w + spanWidth(s), 0);
65}
66
67/**
68 * Build one Frame line out of spans. Flattens nested arrays (so callers can
69 * splice in a sub-row built elsewhere) and drops falsy entries, so optional
70 * spans can be expressed as `cond && span(...)`.
71 * @param {...(Span|Span[]|false|null|undefined)} spans
72 * @returns {Span[]}
73 */
74export function line(...spans) {
75  return spans.flat(Infinity).filter(Boolean);
76}
77
78/**
79 * A line made of a single repeated single-width fill character — the common
80 * case for chassis borders/separators (`═`.repeat(interiorWidth) etc).
81 * @param {number} width
82 * @param {string} [ch]
83 * @param {object} [style]
84 */
85export function hline(width, ch = '─', style = {}) {
86  if (unitWidth(ch) !== 1) throw new Error(`hline: fill char must be single-width, got ${JSON.stringify(ch)}`);
87  return line(span(ch.repeat(width), style));
88}
89
90// ---------------------------------------------------------------------------
91// compose: a small column-addressable canvas for building a line out of
92// independently-styled pieces placed at fixed columns (reel cells, the lever
93// track, left/right-justified info-strip text, …) — the "place text at
94// column" primitive ARCHITECTURE.md §2 calls for.
95// ---------------------------------------------------------------------------
96
97/**
98 * @typedef {{ch: string, style: object, _cont?: boolean}} Cell
99 */
100
101/**
102 * A blank canvas of `width` single-width cells, each initialized to `fillCh`.
103 * @param {number} width
104 * @param {string} [fillCh]
105 * @param {object} [fillStyle]
106 * @returns {Cell[]}
107 */
108export function canvas(width, fillCh = ' ', fillStyle = {}) {
109  if (!Number.isInteger(width) || width < 0) throw new Error(`canvas: width must be a non-negative integer, got ${JSON.stringify(width)}`);
110  if (unitWidth(fillCh) !== 1) throw new Error(`canvas: fill char must be single-width, got ${JSON.stringify(fillCh)}`);
111  const cells = new Array(width);
112  for (let i = 0; i < width; i++) cells[i] = { ch: fillCh, style: fillStyle };
113  return cells;
114}
115
116/**
117 * Return a NEW canvas (does not mutate `cv`) with `text` written starting at
118 * display column `col`, styled with `style`. A zero-width unit (a combining
119 * mark) merges into the previous cell's text rather than occupying its own
120 * column. Throws if `text` would run past either edge of the canvas.
121 * @param {Cell[]} cv
122 * @param {number} col
123 * @param {string} text
124 * @param {object} [style]
125 * @returns {Cell[]}
126 */
127export function place(cv, col, text, style = {}) {
128  const out = cv.slice();
129  let i = col;
130  for (const unit of graphemeUnits(text)) {
131    const w = unitWidth(unit);
132    if (w === 0) {
133      const prev = i - 1;
134      if (prev >= 0 && prev < out.length) {
135        out[prev] = { ch: out[prev].ch + unit, style: out[prev].style };
136      }
137      continue;
138    }
139    if (i < 0 || i + w > out.length) {
140      throw new Error(`place: ${JSON.stringify(text)} at col ${col} overflows canvas width ${out.length}`);
141    }
142    out[i] = { ch: unit, style };
143    for (let k = 1; k < w; k++) out[i + k] = { ch: '', style, _cont: true };
144    i += w;
145  }
146  return out;
147}
148
149function stylesEqual(a, b) {
150  return (
151    (a.color || null) === (b.color || null) &&
152    (a.bg || null) === (b.bg || null) &&
153    !!a.bold === !!b.bold &&
154    !!a.dim === !!b.dim &&
155    !!a.inverse === !!b.inverse
156  );
157}
158
159/** Collapse a canvas into a Frame line, merging adjacent same-style cells into one span. */
160export function canvasToLine(cv) {
161  const spans = [];
162  let cur = null;
163  for (const cell of cv) {
164    if (cell._cont) continue; // second+ column of a wide glyph carries no span of its own
165    if (cur && stylesEqual(cur.style, cell.style)) {
166      cur.text += cell.ch;
167    } else {
168      if (cur) spans.push(span(cur.text, cur.style));
169      cur = { text: cell.ch, style: cell.style };
170    }
171  }
172  if (cur) spans.push(span(cur.text, cur.style));
173  return spans;
174}
175
176/**
177 * Build one Frame line of `width` columns by laying `placements` onto a
178 * blank canvas in order (later placements may overwrite earlier ones).
179 * @param {number} width
180 * @param {{col:number, text:string, style?:object}[]} placements
181 * @param {{fillCh?:string, fillStyle?:object}} [opts]
182 * @returns {Span[]}
183 */
184export function compose(width, placements, opts = {}) {
185  const { fillCh = ' ', fillStyle = {} } = opts;
186  let cv = canvas(width, fillCh, fillStyle);
187  for (const p of placements) cv = place(cv, p.col, p.text, p.style || {});
188  return canvasToLine(cv);
189}
190
191/**
192 * Assert every line in `frame` sums to exactly `frame.width` display
193 * columns. Throws an Error listing every offending line (index, actual vs
194 * expected width, and its plain text) so a mismatch is easy to diff against
195 * an ART-SPEC golden frame.
196 * @param {Frame} frame
197 * @returns {Frame} the same frame, for chaining
198 */
199export function assertRect(frame) {
200  const diffs = [];
201  frame.lines.forEach((l, i) => {
202    const w = lineWidth(l);
203    if (w !== frame.width) {
204      diffs.push({ index: i, width: w, expected: frame.width, text: l.map((s) => s.text).join('') });
205    }
206  });
207  if (diffs.length) {
208    const msg = diffs
209      .map((d) => `  line ${d.index}: width ${d.width} !== ${d.expected} -- ${JSON.stringify(d.text)}`)
210      .join('\n');
211    throw new Error(`assertRect: frame is not rectangular (expected width ${frame.width}):\n${msg}`);
212  }
213  return frame;
214}
215
216/** Render a Frame to plain text (styling stripped), lines joined with '\n'. */
217export function frameToPlainText(frame) {
218  return frame.lines.map((l) => l.map((s) => s.text).join('')).join('\n');
219}
220
lib/symbols.js 322 lines
1// Claude Slots — symbols, strips, paytable, colors (lib/symbols.js)
2//
3// Pure ESM, zero dependencies. Transcribed literally from docs/ART-SPEC.md
4// §1 (symbol set), §2 (paytable/probabilities), §3 (reel strips + neighbor
5// table), §9 (color spec), §14 (optional emoji theme). Every table here is
6// treated as golden — if a number changes, it changes here first, and every
7// consumer (rng.js, economy.js, art.js) reads it from this module rather
8// than re-declaring it.
9
10// ---------------------------------------------------------------------------
11// §1 — Symbol set & Claude Code semantics
12// ---------------------------------------------------------------------------
13
14/** Canonical symbol ids, in the order §1's table lists them. */
15export const SYMBOL_IDS = Object.freeze(['CLAUDE', 'MEDALLION', 'TOOL', 'TEST', 'DIFF', 'FILE', 'TOKEN', 'PROSE']);
16
17/** Every symbol id except the two "shaped" combos (never a plain DOUBLE/TRIPLE filler in the LOSE/DOUBLE-filler pools where excluded explicitly by the caller). */
18export const ALL_SYMBOL_IDS = SYMBOL_IDS;
19
20const SYMBOLS_RAW = {
21  CLAUDE: {
22    glyph: '✻',
23    name: 'CLAUDE',
24    concept:
25      'The assistant itself — the top symbol, U+273B, sanctioned single-width exception (matches Claude Code’s own spinner)',
26    tier: 'Top',
27    dressingWeight: 1.2,
28    color: { hex: '#F5A623', ansi256: 214, bold: true },
29  },
30  MEDALLION: {
31    glyph: '◆C◆',
32    name: 'MEDALLION',
33    concept: "Claude's signature — a completed, clean turn. Special rarity tier, sits between JACKPOT and TRIPLE",
34    tier: 'Special',
35    dressingWeight: 0.3,
36    color: { hex: '#FF8A3D', ansi256: 208, bold: true },
37  },
38  TOOL: {
39    glyph: 'BAR',
40    name: 'TOOL',
41    concept: 'An active/loaded tool call',
42    tier: 'High',
43    dressingWeight: 7.0,
44    color: { hex: '#C77DFF', ansi256: 141 },
45  },
46  TEST: {
47    glyph: '★',
48    name: 'TEST',
49    concept: 'A passing test / green check',
50    tier: 'High',
51    dressingWeight: 3.5,
52    color: { hex: '#FFD166', ansi256: 221 },
53  },
54  DIFF: {
55    glyph: '♦',
56    name: 'DIFF',
57    concept: 'A code diff / edit',
58    tier: 'Mid-high',
59    dressingWeight: 24.0,
60    color: { hex: '#4ADE80', ansi256: 114 },
61  },
62  FILE: {
63    glyph: '♣',
64    name: 'FILE',
65    concept: 'A file read/write',
66    tier: 'Mid',
67    dressingWeight: 20.0,
68    color: { hex: '#9CA3AF', ansi256: 249 },
69  },
70  TOKEN: {
71    glyph: '☼',
72    name: 'TOKEN',
73    concept: 'A token — also literally the credit/coin glyph used in coin-rain',
74    tier: 'Mid-low',
75    dressingWeight: 14.0,
76    color: { hex: '#F5A623', ansi256: 214 },
77  },
78  PROSE: {
79    glyph: '♥',
80    name: 'PROSE',
81    concept: 'Creative/explanatory prose generation',
82    tier: 'Low',
83    dressingWeight: 30.0,
84    color: { hex: '#F472B6', ansi256: 211 },
85  },
86};
87
88/** id -> {glyph, name, concept, tier, dressingWeight, color}. */
89export const SYMBOLS = Object.freeze(
90  Object.fromEntries(Object.entries(SYMBOLS_RAW).map(([id, v]) => [id, Object.freeze({ id, ...v, color: Object.freeze({ ...v.color }) })]))
91);
92
93/** @param {string} id @returns {typeof SYMBOLS[keyof typeof SYMBOLS]} */
94export function getSymbol(id) {
95  const s = SYMBOLS[id];
96  if (!s) throw new Error(`getSymbol: unknown symbol id ${JSON.stringify(id)}`);
97  return s;
98}
99
100/** @param {string} id @returns {string} the display glyph, e.g. 'BAR', '✻', '◆C◆' */
101export function getGlyph(id) {
102  return getSymbol(id).glyph;
103}
104
105// ---------------------------------------------------------------------------
106// §3 — Canonical reel strips & neighbor table
107// ---------------------------------------------------------------------------
108
109// Three different-length circular strips, hard-coded verbatim from §3.
110const REEL_1 = [
111  'CLAUDE', 'TOOL', 'TEST', 'TOKEN', 'FILE', 'DIFF', 'PROSE', 'MEDALLION',
112  'TOOL', 'TOKEN', 'FILE', 'DIFF', 'PROSE', 'TOKEN', 'FILE', 'DIFF', 'PROSE',
113  'FILE', 'DIFF', 'PROSE', 'PROSE', 'PROSE',
114];
115const REEL_2 = [
116  'CLAUDE', 'TOOL', 'TEST', 'TOKEN', 'FILE', 'DIFF', 'PROSE', 'MEDALLION',
117  'TOOL', 'TOKEN', 'FILE', 'DIFF', 'PROSE', 'TOKEN', 'FILE', 'DIFF', 'PROSE',
118  'FILE', 'DIFF', 'PROSE', 'FILE', 'DIFF', 'PROSE', 'PROSE',
119];
120const REEL_3 = [
121  'CLAUDE', 'TOOL', 'TEST', 'TOKEN', 'FILE', 'DIFF', 'PROSE', 'MEDALLION',
122  'TOOL', 'TOKEN', 'FILE', 'DIFF', 'PROSE', 'TOKEN', 'FILE', 'DIFF', 'PROSE',
123  'DIFF', 'PROSE', 'PROSE',
124];
125
126/** REEL_STRIPS[0..2] — 22, 24, and 20 stops respectively (ART-SPEC §3). */
127export const REEL_STRIPS = Object.freeze([Object.freeze(REEL_1), Object.freeze(REEL_2), Object.freeze(REEL_3)]);
128
129/** @param {0|1|2} reelIndex @returns {readonly string[]} */
130export function getStrip(reelIndex) {
131  const strip = REEL_STRIPS[reelIndex];
132  if (!strip) throw new Error(`getStrip: reelIndex must be 0, 1, or 2, got ${JSON.stringify(reelIndex)}`);
133  return strip;
134}
135
136function computeNeighbors(strip) {
137  const next = {};
138  const prev = {};
139  for (const id of SYMBOL_IDS) {
140    const first = strip.indexOf(id);
141    if (first === -1) throw new Error(`computeNeighbors: symbol ${id} missing from strip`);
142    next[id] = strip[(first + 1) % strip.length];
143    prev[id] = strip[(first - 1 + strip.length) % strip.length];
144  }
145  return { NEXT: Object.freeze(next), PREV: Object.freeze(prev) };
146}
147
148// Derived from reel 1 per §3's stated rule (first-occurrence +1/-1 mod
149// length). §3 guarantees this table comes out identical for all three
150// reels since the four rare symbols share the same head-of-strip layout —
151// asserted below rather than assumed.
152const NEIGHBORS_FROM_REEL1 = computeNeighbors(REEL_1);
153for (const strip of [REEL_2, REEL_3]) {
154  const n = computeNeighbors(strip);
155  for (const id of SYMBOL_IDS) {
156    if (n.NEXT[id] !== NEIGHBORS_FROM_REEL1.NEXT[id] || n.PREV[id] !== NEIGHBORS_FROM_REEL1.PREV[id]) {
157      throw new Error(`symbols.js: neighbor table diverges between reels for ${id} — REEL_STRIPS transcription bug`);
158    }
159  }
160}
161
162/** { NEXT: {id -> id}, PREV: {id -> id} } — one static table shared by all three reels (§3). */
163export const NEIGHBORS = NEIGHBORS_FROM_REEL1;
164
165/** @param {'NEXT'|'PREV'} dir @param {string} id */
166export function getNeighbor(dir, id) {
167  const table = NEIGHBORS[dir];
168  if (!table) throw new Error(`getNeighbor: dir must be 'NEXT' or 'PREV', got ${JSON.stringify(dir)}`);
169  const v = table[id];
170  if (!v) throw new Error(`getNeighbor: unknown symbol id ${JSON.stringify(id)}`);
171  return v;
172}
173
174// ---------------------------------------------------------------------------
175// §2 — Paytable & probability table
176// ---------------------------------------------------------------------------
177
178/** Outcome classes, in the exact cumulative-probability order §4's rollOutcomeClass iterates. */
179export const OUTCOME_ORDER = Object.freeze(['JACKPOT', 'MEDALLION', 'TRIPLE', 'DOUBLE', 'NEAR_MISS', 'LOSE']);
180
181/** class -> P(class); sums to 1.0 (verified in tests/probabilities.test.js). */
182export const OUTCOME_PROBABILITIES = Object.freeze({
183  JACKPOT: 0.015,
184  MEDALLION: 0.025,
185  TRIPLE: 0.08,
186  DOUBLE: 0.26,
187  NEAR_MISS: 0.12,
188  LOSE: 0.5,
189});
190
191/** Sub-tier weights for a rolled TRIPLE, of the class's own 0.08 (§4's resolvePayline). */
192export const TRIPLE_TIERS = Object.freeze({
193  TOOL: 0.01,
194  TEST: 0.01,
195  TOKEN: 0.012,
196  FILE: 0.015,
197  DIFF: 0.016,
198  PROSE: 0.017,
199});
200
201/** Sub-tier weights for a rolled DOUBLE, of the class's own 0.26. */
202export const DOUBLE_TIERS = Object.freeze({
203  CLAUDE: 0.01,
204  TOOL: 0.01,
205  TEST: 0.02,
206  TOKEN: 0.03,
207  FILE: 0.045,
208  DIFF: 0.065,
209  PROSE: 0.08,
210});
211
212/** Weights for which symbol a NEAR_MISS teases with — deliberately biased toward rarer/flashier symbols, NOT the §1 dressing weights. Sums to 1.0. */
213export const NEARMISS_WEIGHTS = Object.freeze({
214  CLAUDE: 0.3,
215  MEDALLION: 0.2,
216  TOOL: 0.15,
217  TEST: 0.12,
218  TOKEN: 0.1,
219  FILE: 0.06,
220  DIFF: 0.04,
221  PROSE: 0.03,
222});
223
224/** Paytable (§2) — spin cost 1 credit. JACKPOT/MEDALLION have no tier; TRIPLE/DOUBLE are keyed by the matched symbol id; NEAR_MISS/LOSE always pay 0. */
225export const PAYTABLE = Object.freeze({
226  JACKPOT: 250,
227  MEDALLION: 77,
228  TRIPLE: Object.freeze({ TOOL: 40, TEST: 35, TOKEN: 25, FILE: 18, DIFF: 12, PROSE: 8 }),
229  DOUBLE: Object.freeze({ CLAUDE: 10, TOOL: 6, TEST: 5, TOKEN: 4, FILE: 3, DIFF: 2, PROSE: 1 }),
230  NEAR_MISS: 0,
231  LOSE: 0,
232});
233
234/**
235 * @param {'JACKPOT'|'MEDALLION'|'TRIPLE'|'DOUBLE'|'NEAR_MISS'|'LOSE'} outcomeClass
236 * @param {string|null} [tier] required (and used) only for TRIPLE/DOUBLE
237 * @returns {number}
238 */
239export function getPayout(outcomeClass, tier = null) {
240  const entry = PAYTABLE[outcomeClass];
241  if (entry === undefined) throw new Error(`getPayout: unknown outcome class ${JSON.stringify(outcomeClass)}`);
242  if (typeof entry === 'number') return entry;
243  if (tier == null || !(tier in entry)) throw new Error(`getPayout: ${outcomeClass} requires a valid tier, got ${JSON.stringify(tier)}`);
244  return entry[tier];
245}
246
247// ---------------------------------------------------------------------------
248// §9 — Color spec (truecolor + 256-color fallback)
249// ---------------------------------------------------------------------------
250
251/** Background: dark ground every foreground color in this palette sits on. */
252export const BACKGROUND = Object.freeze({ hex: '#1B1912', ansi256: 234 });
253
254/** Non-symbol chrome/role colors, transcribed verbatim from §9's table. */
255export const COLORS = Object.freeze({
256  background: BACKGROUND,
257  chassisBorderIdle: Object.freeze({ hex: '#8C7A54', ansi256: 101 }),
258  chassisBorderHot: Object.freeze({ hex: '#FFCB77', ansi256: 222, bold: true }),
259  bulbLit: Object.freeze({ hex: '#F6C67D', ansi256: 222, bold: true }),
260  bulbUnlit: Object.freeze({ hex: '#6B4F26', ansi256: 58, dim: true }),
261  wordmark: Object.freeze({ hex: '#E9BD7F', ansi256: 180, bold: true }),
262  reelFrameIdle: Object.freeze({ hex: '#A08F66', ansi256: 137 }),
263  reelFrameHot: Object.freeze({ hex: '#FFB454', ansi256: 215, bold: true }),
264  paylineBarIdle: Object.freeze({ hex: '#E9BD7F', ansi256: 180, bold: true }),
265  paylineBarHot: Object.freeze({ hex: '#FF7A45', ansi256: 209, bold: true }),
266  motionBlur: Object.freeze({ from: '#4A4436', to: '#8C7A54', ansi256From: 238, ansi256To: 101 }),
267  glintSweep: Object.freeze({ from: '#5C5138', to: '#E9D3A8', ansi256From: 239, ansi256To: 187 }),
268  coins: Object.freeze({ hex: '#F6C67D', ansi256: 222, bold: true }),
269  sunburst: Object.freeze({ hex: '#FFFFFF', ansi256: 231, bold: true }),
270  infoText: Object.freeze({ hex: '#BDBEA9', ansi256: 249 }),
271  hintText: Object.freeze({ hex: '#8A7C5E', ansi256: 101, dim: true }),
272  loseNeutral: Object.freeze({ hex: '#7A7360', ansi256: 242, dim: true }),
273  nearMissWhisper: Object.freeze({ hex: '#E9BD7F', ansi256: 180, dim: true }),
274  // §9's table marks these two roles `dim`, but ARCHITECTURE.md's verified
275  // platform fact is that both renderers turn ANY `dim` span into one flat
276  // #999999 gray (render-ansi.js's DIM_GRAY; Tier 1's dimColor measured the
277  // same way) — so a literal transcription would make ABORTED and ERROR
278  // render as the *same* indistinguishable gray as each other (and as every
279  // other dim role), which directly defeats §9's own "red = error, always
280  // unambiguous" rule. Deliberate, documented deviation: keep the exact
281  // hues, drop `dim` only for these two roles so they stay visually
282  // distinct instead of collapsing together.
283  abortedText: Object.freeze({ hex: '#9C9686', ansi256: 246 }),
284  errorText: Object.freeze({ hex: '#E4342F', ansi256: 160 }),
285  onTheHouseText: Object.freeze({ hex: '#E9BD7F', ansi256: 180, dim: true }),
286});
287
288/**
289 * @param {keyof typeof COLORS} role
290 */
291export function getColor(role) {
292  const c = COLORS[role];
293  if (!c) throw new Error(`getColor: unknown color role ${JSON.stringify(role)}`);
294  return c;
295}
296
297// ---------------------------------------------------------------------------
298// §14 — Optional emoji theme (opt-in, never default)
299// ---------------------------------------------------------------------------
300
301/** id -> emoji glyph. Opt-in only (theme:'emoji'); default theme never uses these (§14 width caveats apply). */
302export const EMOJI_THEME = Object.freeze({
303  CLAUDE: '✨',
304  MEDALLION: '🏅',
305  TOOL: '🔧',
306  TEST: '✅',
307  DIFF: '📝',
308  FILE: '📄',
309  TOKEN: '🪙',
310  PROSE: '💬',
311});
312
313/** @param {string} id @param {'classic'|'emoji'} [theme] */
314export function getThemedGlyph(id, theme = 'classic') {
315  if (theme === 'emoji') return EMOJI_THEME[id] ?? getGlyph(id);
316  return getGlyph(id);
317}
318
319export function listSymbolIds() {
320  return SYMBOL_IDS.slice();
321}
322
lib/width.js 294 lines
1// Claude Slots — display-width primitives (lib/width.js)
2//
3// Pure ESM, zero dependencies, no Node/DOM/browser APIs — this file must run
4// unchanged in Node, in a browser, and inside Claude Code's hooks worker.
5//
6// The whole cabinet (docs/ART-SPEC.md §1, §13) is built from glyphs that are
7// verified single-width (box drawing, geometric shapes, a handful of dingbat
8// symbols, ASCII). This module gives every other lib/ file one place to ask
9// "how many terminal columns does this string occupy", so frame.js/art.js
10// never hand-count characters.
11//
12// The algorithm below is a compact, dependency-free wcwidth-style table: it
13// is not an exhaustive Unicode database, but it correctly classifies every
14// glyph this project's default theme uses (ASCII, box drawing U+2500-257F,
15// block elements U+2580-259F, geometric shapes U+25A0-25FF, the dingbats
16// used here including ✻ U+273B), plus the general shape of "wide" (CJK,
17// fullwidth, most emoji) and "zero-width" (combining marks, joiners,
18// variation selectors) codepoints for the optional emoji theme (§14) and for
19// any prose/tool-output text a future integration might need to measure.
20
21// --- zero-width ranges ------------------------------------------------------
22// Combining marks, joiners, variation selectors, and other codepoints that
23// attach to the previous character without occupying a column of their own.
24const ZERO_WIDTH_RANGES = [
25  [0x0300, 0x036f], // Combining Diacritical Marks
26  [0x0483, 0x0489],
27  [0x0591, 0x05bd],
28  [0x05bf, 0x05bf],
29  [0x05c1, 0x05c2],
30  [0x05c4, 0x05c5],
31  [0x05c7, 0x05c7],
32  [0x0610, 0x061a],
33  [0x064b, 0x065f],
34  [0x0670, 0x0670],
35  [0x06d6, 0x06dc],
36  [0x06df, 0x06e4],
37  [0x06e7, 0x06e8],
38  [0x06ea, 0x06ed],
39  [0x0711, 0x0711],
40  [0x0730, 0x074a],
41  [0x07a6, 0x07b0],
42  [0x0816, 0x0819],
43  [0x081b, 0x0823],
44  [0x0825, 0x0827],
45  [0x0829, 0x082d],
46  [0x0e31, 0x0e31],
47  [0x0e34, 0x0e3a],
48  [0x0e47, 0x0e4e],
49  [0x200b, 0x200f], // ZWSP, ZWNJ, ZWJ, LRM, RLM
50  [0x202a, 0x202e], // directional formatting
51  [0x2060, 0x2064], // word joiner etc.
52  [0x20d0, 0x20ff], // Combining Diacritical Marks for Symbols
53  [0xfe00, 0xfe0f], // variation selectors (VS1-16; VS-16 handled specially below)
54  [0xfe20, 0xfe2f], // combining half marks
55  [0xfeff, 0xfeff], // BOM / zero width no-break space
56  [0x1ab0, 0x1aff],
57  [0x1dc0, 0x1dff],
58  [0xe0100, 0xe01ef], // variation selectors supplement
59];
60
61// --- wide (2-column) ranges --------------------------------------------------
62// East Asian Wide/Fullwidth blocks, plus the emoji-presentation ranges. Box
63// drawing (U+2500-257F), block elements (U+2580-259F), geometric shapes
64// (U+25A0-25FF) and the general dingbats range (U+2700-27BF) are all
65// deliberately excluded — every glyph this cabinet uses in its default theme
66// lives in one of those "narrow" ranges (verified against ART-SPEC §1/§13).
67const WIDE_RANGES = [
68  [0x1100, 0x115f], // Hangul Jamo
69  [0x231a, 0x231b], // watch, hourglass (emoji presentation by default)
70  [0x2329, 0x232a],
71  [0x23e9, 0x23ec],
72  [0x23f0, 0x23f0],
73  [0x23f3, 0x23f3],
74  [0x25fd, 0x25fe],
75  [0x2614, 0x2615],
76  [0x2648, 0x2653],
77  [0x267f, 0x267f],
78  [0x2693, 0x2693],
79  [0x26a1, 0x26a1],
80  [0x26aa, 0x26ab],
81  [0x26bd, 0x26be],
82  [0x26c4, 0x26c5],
83  [0x26ce, 0x26ce],
84  [0x26d4, 0x26d4],
85  [0x26ea, 0x26ea],
86  [0x26f2, 0x26f3],
87  [0x26f5, 0x26f5],
88  [0x26fa, 0x26fa],
89  [0x26fd, 0x26fd],
90  [0x2705, 0x2705],
91  [0x270a, 0x270b],
92  [0x2728, 0x2728],
93  [0x274c, 0x274c],
94  [0x274e, 0x274e],
95  [0x2753, 0x2755],
96  [0x2757, 0x2757],
97  [0x2795, 0x2797],
98  [0x27b0, 0x27b0],
99  [0x27bf, 0x27bf],
100  [0x2b1b, 0x2b1c],
101  [0x2b50, 0x2b50],
102  [0x2b55, 0x2b55],
103  [0x2e80, 0x303e], // CJK radicals, punctuation, Hiragana/Katakana start
104  [0x3041, 0x33ff], // Hiragana..CJK compat
105  [0x3400, 0x4dbf], // CJK extension A
106  [0x4e00, 0x9fff], // CJK unified ideographs
107  [0xa000, 0xa4cf], // Yi
108  [0xac00, 0xd7a3], // Hangul syllables
109  [0xf900, 0xfaff], // CJK compatibility ideographs
110  [0xfe30, 0xfe4f], // CJK compatibility forms
111  [0xff00, 0xff60], // fullwidth forms
112  [0xffe0, 0xffe6],
113  [0x16fe0, 0x16fe4],
114  [0x17000, 0x18d08], // Tangut
115  [0x1b000, 0x1b2fb], // Kana supplement
116  [0x1f004, 0x1f004],
117  [0x1f0cf, 0x1f0cf],
118  [0x1f18e, 0x1f18e],
119  [0x1f191, 0x1f19a],
120  [0x1f200, 0x1f320],
121  [0x1f32d, 0x1f335],
122  [0x1f337, 0x1f37c],
123  [0x1f37e, 0x1f393],
124  [0x1f3a0, 0x1f3ca],
125  [0x1f3cf, 0x1f3d3],
126  [0x1f3e0, 0x1f3f0],
127  [0x1f3f4, 0x1f3f4],
128  [0x1f3f8, 0x1f43e],
129  [0x1f440, 0x1f440],
130  [0x1f442, 0x1f4fc],
131  [0x1f4ff, 0x1f53d],
132  [0x1f54b, 0x1f54e],
133  [0x1f550, 0x1f567],
134  [0x1f57a, 0x1f57a],
135  [0x1f595, 0x1f596],
136  [0x1f5a4, 0x1f5a4],
137  [0x1f5fb, 0x1f64f], // ... incl. emoticons block
138  [0x1f680, 0x1f6c5],
139  [0x1f6cc, 0x1f6cc],
140  [0x1f6d0, 0x1f6d2],
141  [0x1f6d5, 0x1f6d7],
142  [0x1f6dc, 0x1f6df],
143  [0x1f6eb, 0x1f6ec],
144  [0x1f6f4, 0x1f6fc],
145  [0x1f7e0, 0x1f7eb],
146  [0x1f7f0, 0x1f7f0],
147  [0x1f900, 0x1f9ff], // supplemental symbols and pictographs
148  [0x1fa70, 0x1faff],
149  [0x20000, 0x2fffd],
150  [0x30000, 0x3fffd],
151];
152
153function inRanges(cp, ranges) {
154  // Binary search over sorted, non-overlapping ranges.
155  let lo = 0;
156  let hi = ranges.length - 1;
157  while (lo <= hi) {
158    const mid = (lo + hi) >> 1;
159    const [start, end] = ranges[mid];
160    if (cp < start) hi = mid - 1;
161    else if (cp > end) lo = mid + 1;
162    else return true;
163  }
164  return false;
165}
166
167const VS16 = 0xfe0f; // forces emoji (wide) presentation on the preceding codepoint
168const VS15 = 0xfe0e; // forces text (narrow) presentation
169
170/**
171 * Split a string into an array of "grapheme-ish" units for width purposes:
172 * a base codepoint plus any immediately-following combining marks / a single
173 * trailing variation selector are kept together as one unit. Good enough for
174 * this project's glyph set without pulling in Intl.Segmenter (unavailable in
175 * some hook-worker runtimes) or a full grapheme-break table.
176 * @param {string} str
177 * @returns {string[]}
178 */
179export function graphemeUnits(str) {
180  const units = [];
181  const chars = Array.from(str); // splits on UTF-16 surrogate pairs correctly
182  let i = 0;
183  while (i < chars.length) {
184    let unit = chars[i];
185    let cp = unit.codePointAt(0);
186    i += 1;
187    // absorb a following variation selector into this unit
188    if (i < chars.length) {
189      const nextCp = chars[i].codePointAt(0);
190      if (nextCp === VS16 || nextCp === VS15) {
191        unit += chars[i];
192        i += 1;
193      }
194    }
195    // absorb any run of zero-width combining marks that follow
196    while (i < chars.length && inRanges(chars[i].codePointAt(0), ZERO_WIDTH_RANGES) && chars[i].codePointAt(0) !== VS16 && chars[i].codePointAt(0) !== VS15) {
197      unit += chars[i];
198      i += 1;
199    }
200    units.push(unit);
201  }
202  return units;
203}
204
205/**
206 * Display width, in terminal columns, of a single grapheme unit (as produced
207 * by graphemeUnits) — i.e. at most one base codepoint plus trailing
208 * combining marks / one variation selector.
209 * @param {string} unit
210 * @returns {0|1|2}
211 */
212export function unitWidth(unit) {
213  const first = unit.codePointAt(0);
214  // an explicit trailing variation selector overrides default presentation
215  if (unit.length > String.fromCodePoint(first).length) {
216    const rest = unit.slice(String.fromCodePoint(first).length);
217    const vs = rest.codePointAt(0);
218    if (vs === VS16) return 2;
219    if (vs === VS15) return 1;
220  }
221  if (inRanges(first, ZERO_WIDTH_RANGES)) return 0;
222  if (first === 0) return 0;
223  // C0/C1 controls (excluding tab/newline, which callers should not be
224  // measuring anyway) contribute no visible column.
225  if ((first >= 0 && first < 0x20) || (first >= 0x7f && first < 0xa0)) return 0;
226  if (inRanges(first, WIDE_RANGES)) return 2;
227  return 1;
228}
229
230/**
231 * Total display width, in terminal columns, of a string.
232 * @param {string} str
233 * @returns {number}
234 */
235export function displayWidth(str) {
236  if (str === '' || str == null) return 0;
237  let width = 0;
238  for (const unit of graphemeUnits(String(str))) width += unitWidth(unit);
239  return width;
240}
241
242/**
243 * Pad `str` on the right with `ch` (a single-width fill character, default
244 * space) until it reaches `width` display columns. No-op (returns `str`
245 * unchanged) if `str` is already at or over budget — callers that need a
246 * hard cap should truncate first.
247 * @param {string} str
248 * @param {number} width
249 * @param {string} [ch]
250 */
251export function padEnd(str, width, ch = ' ') {
252  if (unitWidth(ch) !== 1) throw new Error(`padEnd: fill char must be single-width, got ${JSON.stringify(ch)}`);
253  const w = displayWidth(str);
254  if (w >= width) return str;
255  return str + ch.repeat(width - w);
256}
257
258/**
259 * Pad `str` on the left with `ch` until it reaches `width` display columns.
260 * @param {string} str
261 * @param {number} width
262 * @param {string} [ch]
263 */
264export function padStart(str, width, ch = ' ') {
265  if (unitWidth(ch) !== 1) throw new Error(`padStart: fill char must be single-width, got ${JSON.stringify(ch)}`);
266  const w = displayWidth(str);
267  if (w >= width) return str;
268  return ch.repeat(width - w) + str;
269}
270
271/**
272 * Truncate `str` to at most `width` display columns, appending `ellipsis`
273 * (counted against the same budget) when truncation actually happens.
274 * Never splits a wide glyph in half — a unit that would only partially fit
275 * is dropped rather than corrupting the column count.
276 * @param {string} str
277 * @param {number} width
278 * @param {string} [ellipsis]
279 */
280export function truncate(str, width, ellipsis = '') {
281  if (displayWidth(str) <= width) return str;
282  const ellW = displayWidth(ellipsis);
283  const budget = Math.max(0, width - ellW);
284  let out = '';
285  let w = 0;
286  for (const unit of graphemeUnits(String(str))) {
287    const uw = unitWidth(unit);
288    if (w + uw > budget) break;
289    out += unit;
290    w += uw;
291  }
292  return out + ellipsis;
293}
294
lib/art-storyboards.js 508 lines
1// Claude Slots — per-phase/per-outcome storyboard content (lib/art-storyboards.js)
2//
3// Pure ESM, zero dependencies. Split out of lib/art.js purely to keep each
4// file under a readable line budget — see that file's header for the
5// chassis geometry/primitives this one builds on. This file owns "what does
6// phase X (or outcome class Y) actually look like": marquee text/bulbs, the
7// reel-cell content and hot flags, the info-strip text, the progressive
8// payout counter, and the COMPACT/ULTRA-COMPACT layouts (§7.2/§8.17, which
9// are hand-set fixed-format templates rather than a shrunk STANDARD, per
10// gen.py's own literal source strings).
11import { makeFrame, assertRect, span, compose } from './frame.js';
12import { displayWidth, padStart as wPadStart, truncate as wTruncate } from './width.js';
13import { COLORS, getSymbol, getThemedGlyph } from './symbols.js';
14import { buildStandardFrame, buildGrandFrame, outcomeDurationMs, roleStyle, isSymbolId, LEVER_DURATION_MS, COMPACT_WIDTH, TITLE, CHASSIS_FILL_STYLE } from './art.js';
15
16function compactLine(placements) {
17  // See art.js's CHASSIS_FILL_STYLE comment: this line is built from
18  // several independently-styled segments ("✻", "[...]",
19  // "cr:NNN", the trailing "[-]") separated by gaps that must stay
20  // explicitly painted rather than left as skippable plain whitespace.
21  return compose(COMPACT_WIDTH, placements, { fillStyle: CHASSIS_FILL_STYLE });
22}
23
24function creditsDigits(size) {
25  return size === 'grand' ? 4 : 3;
26}
27
28function fmtCredits(n, size) {
29  return wPadStart(String(Math.max(0, Math.trunc(n))), creditsDigits(size), '0');
30}
31
32// ---------------------------------------------------------------------------
33// Default (never-spun) dressing grid (§8.1's canonical idle combo)
34// ---------------------------------------------------------------------------
35
36const DEFAULT_TOP = ['DIFF', 'FILE', 'TOKEN'];
37const DEFAULT_PAY = ['TEST', 'TOOL', 'PROSE'];
38const DEFAULT_BOT = ['PROSE', 'DIFF', 'FILE'];
39const NO_HOT = [false, false, false];
40
41function bulbPair(t, periodMs = 600) {
42  return Math.floor(t / periodMs) % 2 === 0 ? ['●○●○●', '●○●○●'] : ['○●○●○', '○●○●○'];
43}
44
45function grandBulbPair(t, periodMs = 600) {
46  return Math.floor(t / periodMs) % 2 === 0 ? ['★●○●○●○★', '★●○●○●○★'] : ['★○●○●○●★', '★○●○●○●★'];
47}
48
49function tickCredits(creditsBefore, payout, tick) {
50  const credits = Math.round(creditsBefore + payout * tick);
51  const lastWin = Math.round(payout * tick);
52  return { credits, lastWin };
53}
54
55function fmtElapsed(ms) {
56  const sec = Math.max(0, Math.floor(ms / 1000));
57  const mm = Math.floor(sec / 60);
58  const ss = sec % 60;
59  return `${mm}:${String(ss).padStart(2, '0')}`;
60}
61
62function fmtTokens(n) {
63  const v = Math.max(0, Math.round(n || 0));
64  return v >= 1000 ? `${(v / 1000).toFixed(1)}k` : String(v);
65}
66
67function normalInfo(size, credits, lastWin, onTheHouse) {
68  if (onTheHouse) return `CREDITS: ${fmtCredits(0, size)} · ON THE HOUSE`;
69  return `CREDITS: ${fmtCredits(credits, size)}   LAST WIN: ${fmtCredits(lastWin, size)}`;
70}
71
72function recentText(recent) {
73  const slots = [];
74  for (let i = 0; i < 3; i++) {
75    const r = recent && recent[i];
76    if (!r || !r.symbols) {
77      slots.push('---');
78      continue;
79    }
80    const glyphs = r.symbols.map((id) => getSymbol(id).glyph);
81    slots.push(glyphs.every((g) => g === glyphs[0]) ? glyphs.join('') : glyphs.join('-'));
82  }
83  return slots.join(' ');
84}
85
86// ---------------------------------------------------------------------------
87// Phase -> BoxParams resolvers. Each returns a params object consumable by
88// buildStandardFrame/buildGrandFrame directly (size-independent content).
89// ---------------------------------------------------------------------------
90
91function idleParams(ctx) {
92  const outcome = ctx.outcome;
93  const [top, pay, bot] = outcome
94    ? [[0, 1, 2].map((i) => outcome.grid[i][0]), [0, 1, 2].map((i) => outcome.grid[i][1]), [0, 1, 2].map((i) => outcome.grid[i][2])]
95    : [DEFAULT_TOP, DEFAULT_PAY, DEFAULT_BOT];
96  const [bl, br] = ctx.size === 'grand' ? grandBulbPair(ctx.now ?? ctx.t) : bulbPair(ctx.now ?? ctx.t);
97  const onTheHouse = ctx.economy.credits <= 0;
98  let infoLeft = normalInfo(ctx.size, ctx.economy.credits, ctx.economy.lastWin, onTheHouse);
99  if (ctx.size === 'grand') {
100    infoLeft = onTheHouse
101      ? infoLeft
102      : `CREDITS: ${fmtCredits(ctx.economy.credits, 'grand')}   LAST WIN: ${fmtCredits(ctx.economy.lastWin, 'grand')}   RECENT: ${recentText(ctx.economy.stats && ctx.economy.stats.recent)}`;
103  }
104  return {
105    marqueeText: TITLE, bulbsLeft: bl, bulbsRight: br, bulbMode: 'bulbs',
106    topSyms: top, paySyms: pay, botSyms: bot, hotCols: NO_HOT, tone: 'normal', theme: ctx.theme,
107    knobRow: 0, infoLeft, infoRight: 'esc to interrupt',
108  };
109}
110
111function leverParams(t, economy, size) {
112  const knobRow = Math.min(4, Math.floor(t / (LEVER_DURATION_MS / 5)));
113  const onTheHouse = economy.credits <= 0;
114  return {
115    marqueeText: TITLE, bulbsLeft: '♦♣☼♠♥', bulbsRight: '♦♣☼♠♥', bulbMode: 'decor',
116    topSyms: DEFAULT_TOP, paySyms: DEFAULT_PAY, botSyms: DEFAULT_BOT, hotCols: NO_HOT,
117    knobRow, infoLeft: normalInfo(size, economy.credits, economy.lastWin, onTheHouse), infoRight: '...',
118  };
119}
120
121const SPINUP_FRAMES = [
122  { bulbs: '▓▒░▒▓', top: ['▒', '▒', '▒'], pay: ['▓', '▓', '▓'], bot: ['▒', '▒', '▒'], knobRow: 4 },
123  { bulbs: '▒░▓░▒', top: ['▓', '▒', '▒'], pay: ['▓', '▓', '▓'], bot: ['▓', '▒', '▒'], knobRow: 4 },
124  { bulbs: '░▓▒▓░', top: ['▓', '▓', '▓'], pay: ['▓', '▓', '▓'], bot: ['▓', '▓', '▓'], knobRow: 0 },
125];
126
127function spinupParams(t) {
128  const idx = Math.min(2, Math.floor(t / 400));
129  const f = SPINUP_FRAMES[idx];
130  return {
131    marqueeText: TITLE, bulbsLeft: f.bulbs, bulbsRight: f.bulbs, bulbMode: 'blur',
132    topSyms: f.top, paySyms: f.pay, botSyms: f.bot, hotCols: NO_HOT,
133    knobRow: f.knobRow, infoLeft: 'SPINNING...  0:00   0 tok', infoRight: 'esc to interrupt',
134  };
135}
136
137const GLINT_SWEEPS = [
138  { top: ['░', '▓', '▓'], pay: ['▒', '▓', '▓'], bot: ['░', '▓', '▓'] },
139  { top: ['▓', '▒', '▓'], pay: ['▓', '░', '▓'], bot: ['▓', '▒', '▓'] },
140  { top: ['▓', '▓', '░'], pay: ['▓', '▓', '▒'], bot: ['▓', '▓', '░'] },
141];
142const GLINT_PERIOD_MS = 7000;
143const GLINT_SUB_MS = 170;
144const EASE_PERIOD_MS = 6000;
145const EASE_TOTAL_MS = 400;
146
147function spinningParams(t, info) {
148  const elapsedMs = info.elapsedMs ?? t;
149  const tokens = info.tokens ?? 0;
150  const infoLeft = `SPINNING...  ${fmtElapsed(elapsedMs)}   ${fmtTokens(tokens)} tok`;
151  const infoRight = 'esc to interrupt';
152  const glintPos = t % GLINT_PERIOD_MS;
153  if (glintPos < GLINT_SUB_MS * 3) {
154    const f = GLINT_SWEEPS[Math.floor(glintPos / GLINT_SUB_MS)];
155    return { marqueeText: TITLE, bulbsLeft: '●○●○●', bulbsRight: '●○●○●', bulbMode: 'bulbs', topSyms: f.top, paySyms: f.pay, botSyms: f.bot, hotCols: NO_HOT, knobRow: 0, infoLeft, infoRight };
156  }
157  if (t % EASE_PERIOD_MS < EASE_TOTAL_MS) {
158    return { marqueeText: TITLE, bulbsLeft: '▒▓▒▓▒', bulbsRight: '▒▓▒▓▒', bulbMode: 'blur', topSyms: ['▒', '▓', '▒'], paySyms: ['▒', '▓', '▒'], botSyms: ['▒', '▓', '▒'], hotCols: NO_HOT, knobRow: 0, infoLeft, infoRight };
159  }
160  return { marqueeText: TITLE, bulbsLeft: '▓▓▓▓▓', bulbsRight: '▓▓▓▓▓', bulbMode: 'blur', topSyms: ['▓', '▓', '▓'], paySyms: ['▓', '▓', '▓'], botSyms: ['▓', '▓', '▓'], hotCols: NO_HOT, knobRow: 0, infoLeft, infoRight };
161}
162
163function stoppingParams(t, outcome, reelLocks, overshoot) {
164  const lockedN = reelLocks.filter(Boolean).length;
165  // The near-miss overshoot (§5/§8.5) only teases the illusory triple on the
166  // PAYLINE row — reel 3's dressing (top/bottom) cells are still mid-settle
167  // and show the transitional '▒' placeholder (build_spec.py's literal
168  // [DIFF,DIFF,"▒"]/[PROSE,PROSE,"▒"] source), never the true settled grid
169  // value (that only appears once REVEAL starts) and never plain spin-blur.
170  const isOvershootReel = (i) => overshoot && i === 2;
171  const top = [0, 1, 2].map((i) => (isOvershootReel(i) ? '▒' : reelLocks[i] ? outcome.grid[i][0] : '▓'));
172  const pay = [0, 1, 2].map((i) => (isOvershootReel(i) ? outcome.paylineSymbols[0] : reelLocks[i] ? outcome.grid[i][1] : '▓'));
173  const bot = [0, 1, 2].map((i) => (isOvershootReel(i) ? '▒' : reelLocks[i] ? outcome.grid[i][2] : '▓'));
174  return {
175    marqueeText: TITLE, bulbsLeft: '●●●●●', bulbsRight: '●●●●●', bulbMode: 'bulbs',
176    topSyms: top, paySyms: pay, botSyms: bot, hotCols: NO_HOT, knobRow: 0,
177    infoLeft: `WORKING...  DONE.  locking reel ${lockedN}`, infoRight: '',
178  };
179}
180
181function hotColsFor(outcome) {
182  if (['JACKPOT', 'MEDALLION', 'TRIPLE'].includes(outcome.outcomeClass)) return [true, true, true];
183  if (outcome.outcomeClass === 'DOUBLE') {
184    // exactly the two positions holding the matched (doubled) symbol are hot
185    const counts = {};
186    outcome.paylineSymbols.forEach((s) => (counts[s] = (counts[s] || 0) + 1));
187    return outcome.paylineSymbols.map((s) => counts[s] === 2);
188  }
189  return [false, false, false];
190}
191
192function revealParams(outcome, creditsBefore, size) {
193  const hot = hotColsFor(outcome);
194  const [top, pay, bot] = [0, 1, 2].map((r) => [0, 1, 2].map((i) => outcome.grid[i][r]));
195  return {
196    marqueeText: TITLE, bulbsLeft: hot.some(Boolean) ? '★●★●★' : '●●●●●', bulbsRight: hot.some(Boolean) ? '★●★●★' : '●●●●●', bulbMode: 'bulbs',
197    topSyms: top, paySyms: pay, botSyms: bot, hotCols: hot, knobRow: 0, outerHot: false,
198    infoLeft: normalInfo(size, creditsBefore, 0, false), infoRight: 'esc to interrupt',
199  };
200}
201
202// --- per-class OUTCOME sub-frame resolvers ----------------------------------
203
204function gridRows(outcome) {
205  return [0, 1, 2].map((r) => [0, 1, 2].map((i) => outcome.grid[i][r]));
206}
207
208function outcomeJackpot(t, o, creditsBefore, size) {
209  const [top, pay, bot] = gridRows(o);
210  const hot = [true, true, true];
211  if (t < 100) {
212    const { credits, lastWin } = tickCredits(creditsBefore, o.payout, 0);
213    return { marqueeText: '* * *  J A C K P O T  * * *', bulbsLeft: '★★★★★', bulbsRight: '★★★★★', bulbMode: 'decor', topSyms: top, paySyms: pay, botSyms: bot, hotCols: hot, knobRow: 0, outerHot: true, infoLeft: normalInfo(size, credits, lastWin), infoRight: 'esc to interrupt' };
214  }
215  if (t < 900) {
216    const { credits, lastWin } = tickCredits(creditsBefore, o.payout, 0);
217    return { marqueeText: 'J A C K P O T ! ! !', bulbsLeft: '  ●   ●', bulbsRight: '●   ●  ', bulbMode: 'decor', topSyms: top, paySyms: pay, botSyms: bot, hotCols: hot, knobRow: 0, outerHot: true, infoLeft: normalInfo(size, credits, lastWin), infoRight: 'esc to interrupt' };
218  }
219  if (t < 1900) {
220    const tick = (t - 900) / 1000;
221    const { credits, lastWin } = tickCredits(creditsBefore, o.payout, tick);
222    return { marqueeText: 'J A C K P O T ! ! !', bulbsLeft: ' ●  ● ', bulbsRight: ' ●  ●', bulbMode: 'decor', topSyms: top, paySyms: pay, botSyms: bot, hotCols: hot, knobRow: 0, outerHot: true, infoLeft: normalInfo(size, credits, lastWin), infoRight: '*** PAYOUT ***' };
223  }
224  const { credits, lastWin } = tickCredits(creditsBefore, o.payout, 1);
225  return { marqueeText: 'J A C K P O T ! ! !', bulbsLeft: '  ●  ', bulbsRight: '  ●  ', bulbMode: 'decor', topSyms: top, paySyms: pay, botSyms: bot, hotCols: hot, knobRow: 0, outerHot: true, infoLeft: normalInfo(size, credits, lastWin), infoRight: 'esc to interrupt' };
226}
227
228function outcomeMedallion(t, o, creditsBefore, size) {
229  const [top, pay, bot] = gridRows(o);
230  const hot = [true, true, true];
231  const tick = t < 200 ? 0 : 1;
232  const { credits, lastWin } = tickCredits(creditsBefore, o.payout, tick);
233  if (t < 200) {
234    return { marqueeText: 'S I G N A T U R E   C O M B O', bulbsLeft: '☆●☆●☆', bulbsRight: '☆●☆●☆', bulbMode: 'decor', topSyms: top, paySyms: pay, botSyms: bot, hotCols: hot, knobRow: 0, outerHot: true, infoLeft: normalInfo(size, credits, lastWin), infoRight: 'esc to interrupt' };
235  }
236  if (t < 1200) {
237    const sweepA = Math.floor(t / 200) % 2 === 0;
238    return { marqueeText: 'C L A U D E !', bulbsLeft: sweepA ? '  ☆    ' : '    ☆  ', bulbsRight: sweepA ? '    ☆  ' : '  ☆    ', bulbMode: 'decor', topSyms: top, paySyms: pay, botSyms: bot, hotCols: hot, knobRow: 0, outerHot: true, infoLeft: normalInfo(size, credits, lastWin), infoRight: 'esc to interrupt' };
239  }
240  return { marqueeText: 'S I G N A T U R E   C O M B O', bulbsLeft: '☆★☆★☆', bulbsRight: '☆★☆★☆', bulbMode: 'decor', topSyms: top, paySyms: pay, botSyms: bot, hotCols: hot, knobRow: 0, outerHot: true, infoLeft: normalInfo(size, credits, lastWin), infoRight: 'esc to interrupt' };
241}
242
243function outcomeTriple(t, o, creditsBefore, total, size) {
244  const [top, pay, bot] = gridRows(o);
245  const pulseEnd = total * 0.5;
246  const tickEnd = total * 0.8;
247  if (t < pulseEnd) {
248    const bright = Math.floor(t / 180) % 2 === 0;
249    const { credits, lastWin } = tickCredits(creditsBefore, o.payout, 0);
250    return { marqueeText: TITLE, bulbsLeft: '●○●○●', bulbsRight: '●○●○●', bulbMode: 'bulbs', topSyms: top, paySyms: pay, botSyms: bot, hotCols: bright ? [true, true, true] : [false, false, false], knobRow: 0, outerHot: bright, infoLeft: normalInfo(size, credits, lastWin), infoRight: 'esc to interrupt' };
251  }
252  const tick = t < tickEnd ? (t - pulseEnd) / (tickEnd - pulseEnd) : 1;
253  const { credits, lastWin } = tickCredits(creditsBefore, o.payout, tick);
254  return { marqueeText: TITLE, bulbsLeft: '●●●●●', bulbsRight: '●●●●●', bulbMode: 'bulbs', topSyms: top, paySyms: pay, botSyms: bot, hotCols: [true, true, true], knobRow: 0, outerHot: true, infoLeft: normalInfo(size, credits, lastWin), infoRight: 'esc to interrupt' };
255}
256
257function outcomeDouble(t, o, creditsBefore, size) {
258  const [top, pay, bot] = gridRows(o);
259  const hot = hotColsFor(o);
260  if (t < 300) {
261    const { credits, lastWin } = tickCredits(creditsBefore, o.payout, 0);
262    return { marqueeText: TITLE, bulbsLeft: '●○●○●', bulbsRight: '●○●○●', bulbMode: 'bulbs', topSyms: top, paySyms: pay, botSyms: bot, hotCols: hot, knobRow: 0, infoLeft: normalInfo(size, credits, lastWin), infoRight: 'esc to interrupt' };
263  }
264  const { credits, lastWin } = tickCredits(creditsBefore, o.payout, 1);
265  return { marqueeText: TITLE, bulbsLeft: '●○●○●', bulbsRight: '●○●○●', bulbMode: 'bulbs', topSyms: top, paySyms: pay, botSyms: bot, hotCols: NO_HOT, knobRow: 0, infoLeft: normalInfo(size, credits, lastWin), infoRight: 'esc to interrupt' };
266}
267
268function outcomeNearMiss(t, o, creditsBefore, size) {
269  const [top, pay, bot] = gridRows(o);
270  const base = { topSyms: top, paySyms: pay, botSyms: bot, hotCols: NO_HOT, knobRow: 0, tone: 'normal' };
271  const info = normalInfo(size, creditsBefore, 0);
272  if (t < 450) return { ...base, marqueeText: TITLE, bulbsLeft: '●●●●●', bulbsRight: '●●●●●', bulbMode: 'bulbs', infoLeft: info, infoRight: 'esc to interrupt' };
273  if (t < 1350) return { ...base, marqueeText: TITLE, bulbsLeft: '○○○○○', bulbsRight: '○○○○○', bulbMode: 'bulbs', infoLeft: 'SO CLOSE...', infoRight: 'esc to interrupt' };
274  return { ...base, marqueeText: TITLE, bulbsLeft: '●○●○●', bulbsRight: '●○●○●', bulbMode: 'bulbs', infoLeft: info, infoRight: 'esc to interrupt' };
275}
276
277function outcomeLose(t, o, creditsBefore, size) {
278  const [top, pay, bot] = gridRows(o);
279  const base = { topSyms: top, paySyms: pay, botSyms: bot, hotCols: NO_HOT, knobRow: 0, tone: 'dim' };
280  const info = normalInfo(size, creditsBefore, 0);
281  if (t < 500) return { ...base, marqueeText: TITLE, bulbsLeft: '○○○○○', bulbsRight: '○○○○○', bulbMode: 'bulbs', infoLeft: info, infoRight: 'esc to interrupt' };
282  if (t < 1100) return { ...base, marqueeText: TITLE, bulbsLeft: '○○○○○', bulbsRight: '○○○○○', bulbMode: 'bulbs', infoLeft: 'no dice, spin again?  o_o', infoRight: '' };
283  return { ...base, tone: 'normal', marqueeText: TITLE, bulbsLeft: '●○●○●', bulbsRight: '●○●○●', bulbMode: 'bulbs', infoLeft: info, infoRight: 'esc to interrupt' };
284}
285
286function jamParams(t, creditsAfter, size) {
287  if (t < 300) {
288    return { marqueeText: TITLE, bulbsLeft: '▓▒░', bulbsRight: '░▒▓', bulbMode: 'brake', topSyms: ['▓', '░', '▒'], paySyms: ['▒', '▓', '░'], botSyms: ['░', '▒', '▓'], hotCols: NO_HOT, knobRow: 3, infoLeft: 'esc pressed — brake engaged...', infoRight: '' };
289  }
290  return { marqueeText: 'I N T E R R U P T E D', marqueeStyle: roleStyle(COLORS.abortedText), bulbsLeft: '◆ ◆', bulbsRight: '◆ ◆', bulbMode: 'decor', topSyms: ['—', '—', '—'], paySyms: ['—', '—', '—'], botSyms: ['—', '—', '—'], hotCols: NO_HOT, knobRow: 4, infoLeft: `CREDITS: ${fmtCredits(creditsAfter, size)}   spin refunded`, infoRight: 'esc to interrupt' };
291}
292
293function malfunctionParams(t, creditsAfter, size) {
294  if (t < 300) {
295    return { marqueeText: TITLE, bulbsLeft: '▓▒░', bulbsRight: '░▒▓', bulbMode: 'void', topSyms: ['░', '░', '░'], paySyms: ['░', '░', '░'], botSyms: ['░', '░', '░'], hotCols: NO_HOT, knobRow: 3, infoLeft: 'hmm, something jammed...', infoRight: '' };
296  }
297  return { marqueeText: '* * *  E R R O R  * * *', marqueeStyle: roleStyle(COLORS.errorText), bulbsLeft: '░ ░', bulbsRight: '░ ░', bulbMode: 'void', topSyms: ['—', '—', '—'], paySyms: ['—', '—', '—'], botSyms: ['—', '—', '—'], hotCols: NO_HOT, knobRow: 4, infoLeft: `CREDITS: ${fmtCredits(creditsAfter, size)}   spin refunded`, infoRight: 'esc to interrupt' };
298}
299
300/** Resolve the full BoxParams for a given (phase, t, outcome, ...) — size-independent. */
301export function resolveParams(ctx) {
302  const { phase, t } = ctx;
303  const outcome = ctx.outcome;
304  switch (phase) {
305    case 'IDLE':
306    case 'RESULT':
307      return idleParams(ctx);
308    case 'LEVER':
309      return leverParams(t, ctx.economy, ctx.size);
310    case 'SPINUP':
311      return spinupParams(t);
312    case 'SPINNING':
313      return spinningParams(t, ctx.info || {});
314    case 'STOPPING':
315      return stoppingParams(t, outcome, ctx.reelLocks, ctx.overshoot);
316    case 'REVEAL':
317      return revealParams(outcome, ctx.creditsBeforeOutcome, ctx.size);
318    case 'OUTCOME': {
319      const cb = ctx.creditsBeforeOutcome;
320      switch (outcome.outcomeClass) {
321        case 'JACKPOT': return outcomeJackpot(t, outcome, cb, ctx.size);
322        case 'MEDALLION': return outcomeMedallion(t, outcome, cb, ctx.size);
323        case 'TRIPLE': return outcomeTriple(t, outcome, cb, outcomeDurationMs('TRIPLE', outcome.tier), ctx.size);
324        case 'DOUBLE': return outcomeDouble(t, outcome, cb, ctx.size);
325        case 'NEAR_MISS': return outcomeNearMiss(t, outcome, cb, ctx.size);
326        case 'LOSE': return outcomeLose(t, outcome, cb, ctx.size);
327        default: return outcomeLose(t, outcome, cb, ctx.size);
328      }
329    }
330    case 'JAM':
331      return jamParams(t, ctx.economy.credits, ctx.size);
332    case 'MALFUNCTION':
333      return malfunctionParams(t, ctx.economy.credits, ctx.size);
334    default:
335      return idleParams(ctx);
336  }
337}
338
339// ---------------------------------------------------------------------------
340// COMPACT-size phase rendering (§7.2/§8.17) — structurally distinct from
341// STANDARD/GRAND rather than a shrunk version of the same params shape.
342// ---------------------------------------------------------------------------
343
344// COMPACT/ULTRA-COMPACT lines are hand-set fixed-format templates in
345// ART-SPEC §7.2/§8.17 (verified against gen.py's literal source strings),
346// not an algorithmically-justified layout — so this file reproduces them as
347// literal templates (numbers substituted in) rather than re-deriving
348// column positions. `colorizeCompactText` then recovers per-glyph styling
349// by recognizing known tokens, so the plain text stays byte-identical to
350// the spec while still rendering in color.
351const GLYPH_COLOR = {
352  '◆C◆': () => ({ color: getSymbol('MEDALLION').color.hex, bold: true }),
353  BAR: () => ({ color: getSymbol('TOOL').color.hex }),
354  '✻': () => ({ color: getSymbol('CLAUDE').color.hex, bold: true }),
355  '★': () => ({ color: getSymbol('TEST').color.hex }),
356  '♦': () => ({ color: getSymbol('DIFF').color.hex }),
357  '♣': () => ({ color: getSymbol('FILE').color.hex }),
358  '☼': () => ({ color: getSymbol('TOKEN').color.hex }),
359  '♥': () => ({ color: getSymbol('PROSE').color.hex }),
360  '—': () => ({ color: COLORS.abortedText.hex, dim: true }),
361};
362const GLYPH_TOKENS_SORTED = Object.keys(GLYPH_COLOR).sort((a, b) => b.length - a.length);
363const HINT_TOKENS = ['esc to interrupt', 'esc:x'];
364
365function colorizeCompactText(text) {
366  const spans = [];
367  let i = 0;
368  let plainStart = 0;
369  const flushPlain = (end) => {
370    if (end > plainStart) spans.push(span(text.slice(plainStart, end), roleStyle(COLORS.infoText)));
371  };
372  outer: while (i < text.length) {
373    for (const hint of HINT_TOKENS) {
374      if (text.startsWith(hint, i)) {
375        flushPlain(i);
376        spans.push(span(hint, roleStyle(COLORS.hintText)));
377        i += hint.length;
378        plainStart = i;
379        continue outer;
380      }
381    }
382    for (const tok of GLYPH_TOKENS_SORTED) {
383      if (text.startsWith(tok, i)) {
384        flushPlain(i);
385        spans.push(span(tok, GLYPH_COLOR[tok]()));
386        i += tok.length;
387        plainStart = i;
388        continue outer;
389      }
390    }
391    i += 1;
392  }
393  flushPlain(text.length);
394  return spans;
395}
396
397/**
398 * COMPACT is a fixed ≤40-col budget (§7.2/§13.1), but the *actual* terminal
399 * requesting it can be narrower still (a split pane, a phone SSH client, or
400 * simply ctx.columns < COMPACT_WIDTH — resolveSize() picks 'compact' for
401 * anything under 62 cols, not just exactly 40). Clamp the working width to
402 * whatever's smaller, so COMPACT/ULTRA-COMPACT lines never assert/pad out
403 * to a width wider than the terminal actually offered.
404 */
405function resolveCompactWidth(columns) {
406  if (!Number.isFinite(columns) || columns <= 0) return COMPACT_WIDTH;
407  return Math.max(1, Math.min(COMPACT_WIDTH, Math.trunc(columns)));
408}
409
410function compactTextLine(text, width = COMPACT_WIDTH) {
411  // Defensive, like pushInfoStrip() in art.js: never throw on a too-long
412  // line (a narrow terminal, or credits/lastWin grown past the assumed
413  // digit budget) — truncate instead, so COMPACT never takes the whole
414  // renderer down with it.
415  const clipped = displayWidth(text) > width ? wTruncate(text, width) : text;
416  const padded = clipped + ' '.repeat(Math.max(0, width - displayWidth(clipped)));
417  return colorizeCompactText(padded);
418}
419
420function compactIdleLines(ctx, width = COMPACT_WIDTH) {
421  const onTheHouse = ctx.economy.credits <= 0;
422  const cr = fmtCredits(ctx.economy.credits, 'compact');
423  const win = fmtCredits(ctx.economy.lastWin, 'compact');
424  const line1 = `✻   ♦ BAR ✻   cr:${cr}`;
425  const line2 = onTheHouse ? `ON THE HOUSE         esc to interrupt` : `win:${win}              esc to interrupt`;
426  return [compactTextLine(line1, width), compactTextLine(line2, width)];
427}
428
429function compactSpinningLines(ctx, width = COMPACT_WIDTH) {
430  const elapsedMs = (ctx.info && ctx.info.elapsedMs) ?? ctx.t;
431  const tokens = (ctx.info && ctx.info.tokens) ?? 0;
432  const cr = fmtCredits(ctx.economy.credits, 'compact');
433  const line1 = `✻  [▓▓▓ spinning]  cr:${cr}`;
434  const line2 = `${fmtElapsed(elapsedMs)}  ${fmtTokens(tokens)} tok      esc to interrupt`;
435  return [compactTextLine(line1, width), compactTextLine(line2, width)];
436}
437
438function compactPayline(symbols, theme) {
439  return symbols.map((s) => (isSymbolId(s) ? getThemedGlyph(s, theme) : s)).join('|');
440}
441
442function compactOutcomeLines(ctx, width = COMPACT_WIDTH) {
443  const o = ctx.outcome;
444  const cls = o.outcomeClass;
445  const symbols = o.paylineSymbols || ['—', '—', '—'];
446  const bracket = `[${compactPayline(symbols, ctx.theme)}]`;
447  const cr = fmtCredits(ctx.economy.credits, 'compact');
448  if (cls === 'JACKPOT') {
449    return [compactTextLine(`✻  [✻ ✻ ✻]  JACKPOT +${o.payout}`, width), compactTextLine(`cr:${cr}  * * * CLAUDING * * *`, width)];
450  }
451  if (cls === 'MEDALLION') {
452    return [compactTextLine(`✻ [◆C◆|◆C◆|◆C◆] SIGNATURE`, width), compactTextLine(`cr:${cr}  +${o.payout}  so claude`, width)];
453  }
454  if (cls === 'TRIPLE') return [compactTextLine(`✻ ${bracket} TRIPLE +${o.payout}`, width)];
455  if (cls === 'DOUBLE') return [compactTextLine(`✻ ${bracket}  DOUBLE +${o.payout}`, width)];
456  if (cls === 'NEAR_MISS') return [compactTextLine(`✻ ${bracket}  so close...`, width)];
457  if (cls === 'LOSE') return [compactTextLine(`✻ ${bracket}  no win  cr:${cr}`, width)];
458  if (cls === 'ABORTED') return [compactTextLine(`✻ [—|—|—]  INTERRUPTED`, width)];
459  return [compactTextLine(`✻ [—|—|—]  ERROR  cr:${cr}`, width)]; // ERROR
460}
461
462function ultraCompactLines(ctx, width = COMPACT_WIDTH) {
463  if (ctx.phase === 'SPINNING' || ctx.phase === 'SPINUP') {
464    const elapsedMs = (ctx.info && ctx.info.elapsedMs) ?? ctx.t;
465    const tokens = (ctx.info && ctx.info.tokens) ?? 0;
466    const cr = fmtCredits(ctx.economy.credits, 'compact');
467    return [compactTextLine(`[spinning ${fmtElapsed(elapsedMs)} ${fmtTokens(tokens)} tok] cr:${cr} esc:x`, width)];
468  }
469  const onTheHouse = ctx.economy.credits <= 0;
470  const cr = fmtCredits(ctx.economy.credits, 'compact');
471  const win = fmtCredits(ctx.economy.lastWin, 'compact');
472  const status = onTheHouse ? 'ON THE HOUSE' : `cr:${cr} win:${win}`;
473  return [compactTextLine(`♦ BAR ✻ * ${status} * esc:x`, width)];
474}
475
476function buildCompactFrame(linesArr, width = COMPACT_WIDTH) {
477  const fr = makeFrame(width);
478  fr.lines.push(...linesArr);
479  return assertRect(fr);
480}
481
482// ---------------------------------------------------------------------------
483// Top-level dispatcher — machine.js's only entry point into this module.
484// ---------------------------------------------------------------------------
485
486/**
487 * @param {object} ctx see machine.js's frame() for the full shape:
488 *   {size, columns, maxRows, phase, t, outcome, reelLocks, overshoot,
489 *    economy, creditsBeforeOutcome, info, theme}
490 * @returns {import('./frame.js').Frame}
491 */
492export function renderFrame(ctx) {
493  if (ctx.size === 'compact') {
494    const width = resolveCompactWidth(ctx.columns);
495    const ultra = width <= 36 || ctx.maxRows === 1;
496    if (ultra) return buildCompactFrame(ultraCompactLines(ctx, width), width);
497    if (ctx.phase === 'SPINNING' || ctx.phase === 'SPINUP') return buildCompactFrame(compactSpinningLines(ctx, width), width);
498    if (ctx.phase === 'IDLE' || ctx.phase === 'RESULT') return buildCompactFrame(compactIdleLines(ctx, width), width);
499    if (['OUTCOME', 'JAM', 'MALFUNCTION'].includes(ctx.phase)) {
500      const synthetic = ctx.phase === 'JAM' ? { outcomeClass: 'ABORTED' } : ctx.phase === 'MALFUNCTION' ? { outcomeClass: 'ERROR' } : ctx.outcome;
501      return buildCompactFrame(compactOutcomeLines({ ...ctx, outcome: synthetic }, width), width);
502    }
503    return buildCompactFrame(compactIdleLines(ctx, width), width);
504  }
505  const params = resolveParams(ctx);
506  return ctx.size === 'grand' ? buildGrandFrame(params) : buildStandardFrame(params);
507}
508