07b8ecd9b2
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>
204 lines
5.7 KiB
JavaScript
204 lines
5.7 KiB
JavaScript
// 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();
|