feat(server): web replay viewer (HTML/CSS + WASM bindings)

Wires the WASM module from the previous commit into a minimal web
viewer served at <server>/replays/<id>. Two new server routes:

- `GET /replays/:id`  — returns the same embedded HTML page for any
  id; the page itself reads the path from window.location in JS and
  fetches the replay JSON via /api/replays/:id.
- `/web/*` — ServeDir for the static assets (replay.css, replay.js,
  and the wasm-bindgen-generated pkg/).

Web layer:
- index.html — header, board, controls, status. Module script.
- replay.css — midnight-purple palette matching the desktop client,
  dark felt board, CSS-grid pile layout, tableau fan via per-card
  inline `top` offset.
- replay.js — fetches the replay, instantiates the wasm
  ReplayPlayer, drives state(), step(). Controls: Restart, Play/Pause
  toggle, Step. Auto-tick at 600 ms.
- pkg/ — generated by wasm-bindgen (committed so deployers don't
  have to install wasm-bindgen-cli + the wasm32 target).

`tower-http = "0.6"` added to solitaire_server with the `fs` feature
for ServeDir.

To regenerate pkg/ after a solitaire_wasm change:
    RUSTFLAGS='--cfg getrandom_backend="wasm_js"' \
      cargo build -p solitaire_wasm \
      --target wasm32-unknown-unknown --release
    wasm-bindgen --target web \
      --out-dir solitaire_server/web/pkg --no-typescript \
      target/wasm32-unknown-unknown/release/solitaire_wasm.wasm

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-05 18:54:01 +00:00
parent 5bed43ef32
commit 07b8ecd9b2
8 changed files with 822 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Solitaire Quest — Replay</title>
<link rel="stylesheet" href="/web/replay.css">
</head>
<body>
<header>
<h1>Solitaire Quest <span class="muted">— Replay</span></h1>
<div id="caption" class="muted">Loading…</div>
</header>
<main>
<section id="board"></section>
<section id="controls">
<button id="btn-prev" disabled>⏮ Restart</button>
<button id="btn-play">▶ Play</button>
<button id="btn-step">⏭ Step</button>
<span id="progress" class="muted">step 0 / 0</span>
</section>
<section id="status" class="muted">
<span id="score">Score 0</span>
<span id="moves">Moves 0</span>
<span id="result"></span>
</section>
</main>
<script type="module" src="/web/replay.js"></script>
</body>
</html>
+339
View File
@@ -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 };
Binary file not shown.
+197
View File
@@ -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;
}
+203
View File
@@ -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/<id> — 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();