diff --git a/Cargo.lock b/Cargo.lock index 13e9f32..ba4b2eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4321,6 +4321,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -5280,6 +5286,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -7721,6 +7737,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tower", + "tower-http", "tower_governor", "tracing", "tracing-subscriber", @@ -8783,14 +8800,24 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.11.1", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", "iri-string", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -9207,6 +9234,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" diff --git a/solitaire_server/Cargo.toml b/solitaire_server/Cargo.toml index 3f99ceb..a39908a 100644 --- a/solitaire_server/Cargo.toml +++ b/solitaire_server/Cargo.toml @@ -25,6 +25,7 @@ sqlx = { workspace = true } jsonwebtoken = { workspace = true } bcrypt = { workspace = true } tower_governor = { workspace = true } +tower-http = { version = "0.6", features = ["fs"] } tracing = { workspace = true } tracing-subscriber = { workspace = true } dotenvy = { workspace = true } diff --git a/solitaire_server/src/lib.rs b/solitaire_server/src/lib.rs index b789a16..57f0b68 100644 --- a/solitaire_server/src/lib.rs +++ b/solitaire_server/src/lib.rs @@ -15,6 +15,7 @@ pub mod sync; use axum::{ extract::DefaultBodyLimit, middleware as axum_middleware, + response::Html, routing::{delete, get, post}, Router, }; @@ -25,6 +26,7 @@ use tower_governor::{ key_extractor::SmartIpKeyExtractor, GovernorLayer, }; +use tower_http::services::ServeDir; /// Shared application state injected into every Axum handler via [`axum::extract::State`]. /// @@ -104,10 +106,23 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router { .route("/api/replays/{id}", get(replays::get_by_id)) .route("/health", get(health)); + // Replay web UI: a single HTML page served at `/replays/:id` plus a + // ServeDir for the static assets (`web/index.html`, `web/replay.css`, + // and the wasm-bindgen-generated `web/pkg/`). The HTML page is the + // same regardless of `:id` — it reads the path from `location` in JS + // and fetches the replay JSON from `/api/replays/:id`. + let web = Router::new() + .route( + "/replays/{id}", + get(|| async { Html(include_str!("../web/index.html")) }), + ) + .nest_service("/web", ServeDir::new("solitaire_server/web")); + Router::new() .merge(protected) .merge(auth_routes) .merge(public) + .merge(web) // Reject request bodies larger than 1 MB. .layer(DefaultBodyLimit::max(1024 * 1024)) .with_state(state) diff --git a/solitaire_server/web/index.html b/solitaire_server/web/index.html new file mode 100644 index 0000000..efbfd51 --- /dev/null +++ b/solitaire_server/web/index.html @@ -0,0 +1,34 @@ + + + + + + Solitaire Quest — Replay + + + +
+

Solitaire Quest — Replay

+
Loading…
+
+ +
+
+ +
+ + + + step 0 / 0 +
+ +
+ Score 0 + Moves 0 + +
+
+ + + + diff --git a/solitaire_server/web/pkg/solitaire_wasm.js b/solitaire_server/web/pkg/solitaire_wasm.js new file mode 100644 index 0000000..7f477ce --- /dev/null +++ b/solitaire_server/web/pkg/solitaire_wasm.js @@ -0,0 +1,339 @@ +/** + * Browser-side replay state machine. Owns a live `GameState` and the + * replay's move list; each `step()` applies the next move. + */ +export class ReplayPlayer { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + ReplayPlayerFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_replayplayer_free(ptr, 0); + } + /** + * Returns `true` once every move has been applied. + * @returns {boolean} + */ + is_finished() { + const ret = wasm.replayplayer_is_finished(this.__wbg_ptr); + return ret !== 0; + } + /** + * Construct from a raw replay JSON string. + * @param {string} replay_json + */ + constructor(replay_json) { + const ptr0 = passStringToWasm0(replay_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.replayplayer_new(ptr0, len0); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + this.__wbg_ptr = ret[0]; + ReplayPlayerFinalization.register(this, this.__wbg_ptr, this); + return this; + } + /** + * Snapshot the current `GameState` as a JS object (see `StateSnapshot`). + * @returns {any} + */ + state() { + const ret = wasm.replayplayer_state(this.__wbg_ptr); + return ret; + } + /** + * Apply the next move; returns the post-step snapshot, or `null` + * once the move list is exhausted. + * @returns {any} + */ + step() { + const ret = wasm.replayplayer_step(this.__wbg_ptr); + return ret; + } + /** + * 0-indexed position of the next move to apply. + * @returns {number} + */ + step_idx() { + const ret = wasm.replayplayer_step_idx(this.__wbg_ptr); + return ret >>> 0; + } + /** + * Total number of moves the replay contains. + * @returns {number} + */ + total_steps() { + const ret = wasm.replayplayer_total_steps(this.__wbg_ptr); + return ret >>> 0; + } +} +if (Symbol.dispose) ReplayPlayer.prototype[Symbol.dispose] = ReplayPlayer.prototype.free; +function __wbg_get_imports() { + const import0 = { + __proto__: null, + __wbg_Error_3639a60ed15f87e7: function(arg0, arg1) { + const ret = Error(getStringFromWasm0(arg0, arg1)); + return ret; + }, + __wbg___wbindgen_throw_9c75d47bf9e7731e: function(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); + }, + __wbg_error_a6fa202b58aa1cd3: function(arg0, arg1) { + let deferred0_0; + let deferred0_1; + try { + deferred0_0 = arg0; + deferred0_1 = arg1; + console.error(getStringFromWasm0(arg0, arg1)); + } finally { + wasm.__wbindgen_free(deferred0_0, deferred0_1, 1); + } + }, + __wbg_new_227d7c05414eb861: function() { + const ret = new Error(); + return ret; + }, + __wbg_new_2fad8ca02fd00684: function() { + const ret = new Object(); + return ret; + }, + __wbg_new_3baa8d9866155c79: function() { + const ret = new Array(); + return ret; + }, + __wbg_set_6be42768c690e380: function(arg0, arg1, arg2) { + arg0[arg1] = arg2; + }, + __wbg_set_f614f6a0608d1d1d: function(arg0, arg1, arg2) { + arg0[arg1 >>> 0] = arg2; + }, + __wbg_stack_3b0d974bbf31e44f: function(arg0, arg1) { + const ret = arg1.stack; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbindgen_cast_0000000000000001: function(arg0) { + // Cast intrinsic for `F64 -> Externref`. + const ret = arg0; + return ret; + }, + __wbindgen_cast_0000000000000002: function(arg0, arg1) { + // Cast intrinsic for `Ref(String) -> Externref`. + const ret = getStringFromWasm0(arg0, arg1); + return ret; + }, + __wbindgen_cast_0000000000000003: function(arg0) { + // Cast intrinsic for `U64 -> Externref`. + const ret = BigInt.asUintN(64, arg0); + return ret; + }, + __wbindgen_init_externref_table: function() { + const table = wasm.__wbindgen_externrefs; + const offset = table.grow(4); + table.set(0, undefined); + table.set(offset + 0, undefined); + table.set(offset + 1, null); + table.set(offset + 2, true); + table.set(offset + 3, false); + }, + }; + return { + __proto__: null, + "./solitaire_wasm_bg.js": import0, + }; +} + +const ReplayPlayerFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_replayplayer_free(ptr, 1)); + +let cachedDataViewMemory0 = null; +function getDataViewMemory0() { + if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) { + cachedDataViewMemory0 = new DataView(wasm.memory.buffer); + } + return cachedDataViewMemory0; +} + +function getStringFromWasm0(ptr, len) { + return decodeText(ptr >>> 0, len); +} + +let cachedUint8ArrayMemory0 = null; +function getUint8ArrayMemory0() { + if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8ArrayMemory0; +} + +function passStringToWasm0(arg, malloc, realloc) { + if (realloc === undefined) { + const buf = cachedTextEncoder.encode(arg); + const ptr = malloc(buf.length, 1) >>> 0; + getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf); + WASM_VECTOR_LEN = buf.length; + return ptr; + } + + let len = arg.length; + let ptr = malloc(len, 1) >>> 0; + + const mem = getUint8ArrayMemory0(); + + let offset = 0; + + for (; offset < len; offset++) { + const code = arg.charCodeAt(offset); + if (code > 0x7F) break; + mem[ptr + offset] = code; + } + if (offset !== len) { + if (offset !== 0) { + arg = arg.slice(offset); + } + ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; + const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len); + const ret = cachedTextEncoder.encodeInto(arg, view); + + offset += ret.written; + ptr = realloc(ptr, len, offset, 1) >>> 0; + } + + WASM_VECTOR_LEN = offset; + return ptr; +} + +function takeFromExternrefTable0(idx) { + const value = wasm.__wbindgen_externrefs.get(idx); + wasm.__externref_table_dealloc(idx); + return value; +} + +let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); +cachedTextDecoder.decode(); +const MAX_SAFARI_DECODE_BYTES = 2146435072; +let numBytesDecoded = 0; +function decodeText(ptr, len) { + numBytesDecoded += len; + if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { + cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); + cachedTextDecoder.decode(); + numBytesDecoded = len; + } + return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); +} + +const cachedTextEncoder = new TextEncoder(); + +if (!('encodeInto' in cachedTextEncoder)) { + cachedTextEncoder.encodeInto = function (arg, view) { + const buf = cachedTextEncoder.encode(arg); + view.set(buf); + return { + read: arg.length, + written: buf.length + }; + }; +} + +let WASM_VECTOR_LEN = 0; + +let wasmModule, wasmInstance, wasm; +function __wbg_finalize_init(instance, module) { + wasmInstance = instance; + wasm = instance.exports; + wasmModule = module; + cachedDataViewMemory0 = null; + cachedUint8ArrayMemory0 = null; + wasm.__wbindgen_start(); + return wasm; +} + +async function __wbg_load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); + } catch (e) { + const validResponse = module.ok && expectedResponseType(module.type); + + if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') { + console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); + + } else { throw e; } + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + } else { + const instance = await WebAssembly.instantiate(module, imports); + + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + } else { + return instance; + } + } + + function expectedResponseType(type) { + switch (type) { + case 'basic': case 'cors': case 'default': return true; + } + return false; + } +} + +function initSync(module) { + if (wasm !== undefined) return wasm; + + + if (module !== undefined) { + if (Object.getPrototypeOf(module) === Object.prototype) { + ({module} = module) + } else { + console.warn('using deprecated parameters for `initSync()`; pass a single object instead') + } + } + + const imports = __wbg_get_imports(); + if (!(module instanceof WebAssembly.Module)) { + module = new WebAssembly.Module(module); + } + const instance = new WebAssembly.Instance(module, imports); + return __wbg_finalize_init(instance, module); +} + +async function __wbg_init(module_or_path) { + if (wasm !== undefined) return wasm; + + + if (module_or_path !== undefined) { + if (Object.getPrototypeOf(module_or_path) === Object.prototype) { + ({module_or_path} = module_or_path) + } else { + console.warn('using deprecated parameters for the initialization function; pass a single object instead') + } + } + + if (module_or_path === undefined) { + module_or_path = new URL('solitaire_wasm_bg.wasm', import.meta.url); + } + const imports = __wbg_get_imports(); + + if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) { + module_or_path = fetch(module_or_path); + } + + const { instance, module } = await __wbg_load(await module_or_path, imports); + + return __wbg_finalize_init(instance, module); +} + +export { initSync, __wbg_init as default }; diff --git a/solitaire_server/web/pkg/solitaire_wasm_bg.wasm b/solitaire_server/web/pkg/solitaire_wasm_bg.wasm new file mode 100644 index 0000000..a03c60d Binary files /dev/null and b/solitaire_server/web/pkg/solitaire_wasm_bg.wasm differ diff --git a/solitaire_server/web/replay.css b/solitaire_server/web/replay.css new file mode 100644 index 0000000..528e264 --- /dev/null +++ b/solitaire_server/web/replay.css @@ -0,0 +1,197 @@ +/* Solitaire Quest replay viewer — palette mirrors the desktop client's + midnight-purple Balatro tone (BG_BASE = #1A0F2E) and the dark felt + from the engine's TABLE_COLOUR. */ + +:root { + --bg: #0f0a1f; + --felt: #0f4c30; + --panel: #1a0f2e; + --panel-hi: #2d1b69; + --text: #f5f0ff; + --text-muted: #b5a8d5; + --accent: #ffd23f; + --red: #cc3344; + --black: #1a0f2e; + --card-bg: #ffffff; + --card-border: #ccc; + --card-w: 80px; + --card-h: 112px; + --gap: 12px; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + display: flex; + flex-direction: column; +} + +header { + padding: 16px 24px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + display: flex; + flex-direction: column; + gap: 4px; +} + +h1 { + margin: 0; + font-size: 20px; + font-weight: 600; +} + +.muted { color: var(--text-muted); } + +main { + flex: 1; + display: flex; + flex-direction: column; + gap: 16px; + padding: 24px; + align-items: center; +} + +#board { + background: var(--felt); + border-radius: 12px; + padding: 24px; + width: min(100%, calc(7 * var(--card-w) + 8 * var(--gap))); + display: grid; + grid-template-columns: repeat(7, var(--card-w)); + grid-template-rows: var(--card-h) auto; + gap: var(--gap); + column-gap: var(--gap); + row-gap: 32px; +} + +/* Top row: stock, waste, [skip], 4 foundations. */ +.pile-stock { grid-column: 1; grid-row: 1; } +.pile-waste { grid-column: 2; grid-row: 1; } +.pile-foundation-0 { grid-column: 4; grid-row: 1; } +.pile-foundation-1 { grid-column: 5; grid-row: 1; } +.pile-foundation-2 { grid-column: 6; grid-row: 1; } +.pile-foundation-3 { grid-column: 7; grid-row: 1; } +.pile-tableau-0 { grid-column: 1; grid-row: 2; } +.pile-tableau-1 { grid-column: 2; grid-row: 2; } +.pile-tableau-2 { grid-column: 3; grid-row: 2; } +.pile-tableau-3 { grid-column: 4; grid-row: 2; } +.pile-tableau-4 { grid-column: 5; grid-row: 2; } +.pile-tableau-5 { grid-column: 6; grid-row: 2; } +.pile-tableau-6 { grid-column: 7; grid-row: 2; } + +.pile { + position: relative; + width: var(--card-w); + /* Tableau columns let cards stack downward. */ +} + +.pile-empty { + width: var(--card-w); + height: var(--card-h); + border: 2px dashed rgba(255, 255, 255, 0.15); + border-radius: 8px; +} + +.card { + position: absolute; + width: var(--card-w); + height: var(--card-h); + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 6px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + padding: 4px 6px; + font-family: "Helvetica Neue", Arial, sans-serif; + font-weight: 600; + line-height: 1; + user-select: none; + transition: top 180ms ease, opacity 180ms ease; +} + +/* Tableau fan: cards beneath the top one peek through ~28 px down. */ +.pile-tableau-0 .card, +.pile-tableau-1 .card, +.pile-tableau-2 .card, +.pile-tableau-3 .card, +.pile-tableau-4 .card, +.pile-tableau-5 .card, +.pile-tableau-6 .card { + /* Per-card top set inline by JS (offset = idx * 28 px). */ +} + +.card.face-down { + background: + repeating-linear-gradient( + 45deg, + #482f97 0, + #482f97 6px, + #2d1b69 6px, + #2d1b69 12px + ); + color: transparent; + border-color: #4a3a8a; +} + +.card .corner { + position: absolute; + font-size: 14px; + line-height: 1.1; + text-align: center; +} + +.card .corner.top { top: 4px; left: 6px; } +.card .corner.bottom { bottom: 4px; right: 6px; transform: rotate(180deg); } + +.card.red { color: var(--red); } +.card.black { color: var(--black); } + +.card .center { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 28px; +} + +#controls { + display: flex; + gap: 12px; + align-items: center; +} + +#controls button { + background: var(--panel-hi); + color: var(--text); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + padding: 8px 16px; + cursor: pointer; + font-size: 14px; + font-family: inherit; +} + +#controls button:hover:not(:disabled) { + background: var(--accent); + color: var(--black); +} + +#controls button:disabled { + opacity: 0.5; + cursor: default; +} + +#status { + display: flex; + gap: 24px; + font-size: 14px; +} + +#status #result.win { + color: var(--accent); + font-weight: 600; +} diff --git a/solitaire_server/web/replay.js b/solitaire_server/web/replay.js new file mode 100644 index 0000000..e899a63 --- /dev/null +++ b/solitaire_server/web/replay.js @@ -0,0 +1,203 @@ +// Solitaire Quest replay viewer. +// +// Pulls the replay JSON from `/api/replays/:id`, hands it to the +// `solitaire_wasm` ReplayPlayer (which owns a real solitaire_core +// `GameState` compiled to WebAssembly), and renders each step's pile +// snapshot as plain HTML cards. The WASM module is the single source +// of truth for the rules engine — we don't re-implement Klondike in JS. + +import init, { ReplayPlayer } from "/web/pkg/solitaire_wasm.js"; + +const STEP_INTERVAL_MS = 600; +const FAN_OFFSET_PX = 28; + +const SUIT_GLYPHS = { + clubs: "♣", + diamonds: "♦", + hearts: "♥", + spades: "♠", +}; + +const RED_SUITS = new Set(["diamonds", "hearts"]); + +const RANK_LABELS = [ + "", "A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", +]; + +const board = document.getElementById("board"); +const captionEl = document.getElementById("caption"); +const progressEl = document.getElementById("progress"); +const scoreEl = document.getElementById("score"); +const movesEl = document.getElementById("moves"); +const resultEl = document.getElementById("result"); +const btnPlay = document.getElementById("btn-play"); +const btnStep = document.getElementById("btn-step"); +const btnPrev = document.getElementById("btn-prev"); + +let player = null; +let replayJson = null; +let playInterval = null; + +async function bootstrap() { + // /replays/ — pull the id off the path so we can fetch the JSON. + const id = window.location.pathname.split("/").pop(); + if (!id) { + captionEl.textContent = "No replay id in URL."; + return; + } + + let response; + try { + response = await fetch(`/api/replays/${id}`); + } catch (e) { + captionEl.textContent = `Network error: ${e}`; + return; + } + if (!response.ok) { + captionEl.textContent = `Server returned ${response.status}.`; + return; + } + const replay = await response.json(); + replayJson = JSON.stringify(replay); + + captionEl.textContent = + `Seed ${replay.seed} · ${replay.draw_mode} · ${replay.mode} ` + + `· ${formatDuration(replay.time_seconds)} win on ${replay.recorded_at} ` + + `· final score ${replay.final_score}`; + + await init(); + resetPlayer(); +} + +function resetPlayer() { + if (playInterval) { + clearInterval(playInterval); + playInterval = null; + btnPlay.textContent = "▶ Play"; + } + player = new ReplayPlayer(replayJson); + btnPrev.disabled = true; + btnStep.disabled = false; + btnPlay.disabled = false; + render(player.state()); +} + +function step() { + const snap = player.step(); + if (snap === null) { + finish(); + return null; + } + btnPrev.disabled = false; + render(snap); + return snap; +} + +function finish() { + if (playInterval) { + clearInterval(playInterval); + playInterval = null; + } + btnPlay.textContent = "▶ Play"; + btnPlay.disabled = true; + btnStep.disabled = true; +} + +function render(snap) { + if (!snap) return; + board.replaceChildren(); + renderPile("stock", snap.stock, false); + renderPile("waste", snap.waste, false); + snap.foundations.forEach((cards, idx) => + renderPile(`foundation-${idx}`, cards, false)); + snap.tableaus.forEach((cards, idx) => + renderPile(`tableau-${idx}`, cards, true)); + + progressEl.textContent = `step ${snap.step_idx} / ${snap.total_steps}`; + scoreEl.textContent = `Score ${snap.score}`; + movesEl.textContent = `Moves ${snap.move_count}`; + if (snap.is_won) { + resultEl.textContent = "✨ Won"; + resultEl.classList.add("win"); + } else { + resultEl.textContent = ""; + resultEl.classList.remove("win"); + } +} + +function renderPile(name, cards, fan) { + const pile = document.createElement("div"); + pile.className = `pile pile-${name}`; + if (cards.length === 0) { + const empty = document.createElement("div"); + empty.className = "pile-empty"; + pile.appendChild(empty); + board.appendChild(pile); + return; + } + cards.forEach((card, idx) => { + const top = fan ? idx * FAN_OFFSET_PX : 0; + pile.appendChild(buildCard(card, top)); + }); + board.appendChild(pile); +} + +function buildCard(card, top) { + const el = document.createElement("div"); + el.className = "card"; + el.style.top = `${top}px`; + if (!card.face_up) { + el.classList.add("face-down"); + return el; + } + el.classList.add(RED_SUITS.has(card.suit) ? "red" : "black"); + const label = RANK_LABELS[card.rank] || "?"; + const glyph = SUIT_GLYPHS[card.suit] || "?"; + + const top_corner = document.createElement("span"); + top_corner.className = "corner top"; + top_corner.textContent = `${label}\n${glyph}`; + el.appendChild(top_corner); + + const center = document.createElement("span"); + center.className = "center"; + center.textContent = glyph; + el.appendChild(center); + + const bottom_corner = document.createElement("span"); + bottom_corner.className = "corner bottom"; + bottom_corner.textContent = `${label}\n${glyph}`; + el.appendChild(bottom_corner); + return el; +} + +function formatDuration(seconds) { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${String(s).padStart(2, "0")}`; +} + +btnStep.addEventListener("click", () => { + if (player) step(); +}); + +btnPlay.addEventListener("click", () => { + if (!player) return; + if (playInterval) { + clearInterval(playInterval); + playInterval = null; + btnPlay.textContent = "▶ Play"; + return; + } + btnPlay.textContent = "⏸ Pause"; + playInterval = setInterval(() => { + const snap = step(); + if (snap === null) finish(); + }, STEP_INTERVAL_MS); +}); + +btnPrev.addEventListener("click", () => { + if (replayJson) resetPlayer(); +}); + +bootstrap();