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
+
+
+
+
+
+
+
+
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();