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…

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.)
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).
| Tier | Surface | When it's active | Frame rate |
|---|---|---|---|
| Tier 1 | Function hooks (ui.render replacing Spinner/TurnDuration/AbovePrompt) | CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 | ~10 fps, full animation |
| Tier 2 | statusLine + classic command hooks | Always available, the fallback | 1 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).
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).
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.
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
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.
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:
~/.claude/settings.json to <path>.claude-slots.bak-<ISO timestamp>,statusLine / spinnerVerbs / spinnerTipsEnabled into ${CLAUDE_PLUGIN_DATA}/config.json so they can be restored later,statusLine at node <plugin root>/bin/claude-slots-statusline.js with refreshInterval: 1 (the lowest the host allows),spinnerVerbs with ten slot-themed phrases ("Pulling the lever", "Spinning the reels", "Reels whirring", "Chasing sevens", "Feeling lucky", …), andspinnerTipsEnabled 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).
| Command | Does |
|---|---|
/claude-slots:setup [--apply] [--dry-run] [--no-statusline] [--no-verbs] | Install (or preview installing) the Tier 2 statusLine fallback |
/claude-slots:teardown | Restore your previous statusLine/spinnerVerbs/spinnerTipsEnabled |
/claude-slots:stats | Show 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) |
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'soptionsfield (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-textstring/numberfield with the allowed values documented in its description —claude plugin validaterejects anoptionsarray at this version.
| Option | Type | Default | Values | |||
|---|---|---|---|---|---|---|
size | string | auto | auto \ | compact \ | standard \ | grand — chassis size; auto picks from the terminal's column count (≥90 → grand if enabled, ≥62 → standard, else compact) |
idle | string | compact | off \ | compact \ | full — what the AbovePrompt band shows between turns (Tier 1 only) | |
theme | string | classic | classic \ | emoji — glyph set (docs/ART-SPEC.md §14 covers the emoji theme) | ||
sound | string | off | off \ | on — play the bundled fx/*.wav clips via $.audio.play (Tier 1 only) | ||
startCredits | number | 100 | starting credit balance for a new session (1 credit spent per spin, never blocks a spin — see docs/ART-SPEC.md §10) |
.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
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):
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.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin validate . passes (scanner + manifest).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./clear, concurrent sessions, budget limits (toasts, store size).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).${CLAUDE_PLUGIN_DATA}/config.json — your original statusLine command is saved there and /claude-slots:teardown will put it straight back.--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.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+).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)./claude-slots:teardown (or node bin/claude-slots-setup.js --restore) to put back your original statusLine/spinnerVerbs/spinnerTipsEnabled./plugin uninstall claude-slots@claude-slots-local (marketplace install) or simply stop passing --plugin-dir (inline install).${CLAUDE_PLUGIN_DATA} (~/.claude/plugins/data/claude-slots*/) and any ~/.claude/plugins/store/claude-slots*.json files to clear saved credits/stats.MIT — see LICENSE.
hooks/slots.js 485 lines1// 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}
485lib/machine.js 249 lines1// 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}
249lib/render-tree.js 47 lines1// 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}
47lib/rng.js 223 lines1// 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 };
223lib/economy.js 115 lines1// 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}
115lib/art.js 360 lines1// 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';
360lib/frame.js 220 lines1// 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}
220lib/symbols.js 322 lines1// 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}
322lib/width.js 294 lines1// 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}
294lib/art-storyboards.js 508 lines1// 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