4cb4212829
Test / test (pull_request) Successful in 36m34s
Replays previously persisted only seed + moves and re-dealt the board
from the seed at playback time, so any change to the seed->deal mapping
(RNG bumps, upstream upgrades) silently invalidated every existing
replay. Schema v4 instead embeds a SessionRecording - the upstream
card_game Session serde ({config, initial_state, instructions}) - so
playback rebuilds the exact recorded board; seed/draw_mode/mode remain
caption metadata only.
- core: SessionRecording newtype delegating to Session<Klondike> serde;
GameState::recording() / from_recording(); from_instructions_unchecked
fixture helper; serde_json added to dev-deps (tests only)
- data: Replay v4 (recording replaces moves); v1-v3 files rejected by
the existing version gate
- engine: win-recording and sync upload freeze game.recording();
playback rebuilds from the recording; Playing carries the extracted
move list (+ Box<Replay> for clippy large_enum_variant)
- wasm: replay_export() builds the full v4 upload payload so JS never
hand-assembles it (the old game.js path hardcoded schema_version: 2
and corrupted u64 seeds via Math.round); ReplayPlayer::from_json
enforces schema_version == 4 with a descriptive error
- web: game.js/play.html use replay_export; replay.js surfaces player
construction errors in the caption instead of dying silently
- server: mode validation accepts data-carrying GameMode variants
(Difficulty uploads previously 400'd against the String field)
Both replays on prod are May-era v1 rows with empty move lists - every
shared replay was already unplayable; the viewer now says why.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
219 lines
10 KiB
HTML
219 lines
10 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Ferrous Solitaire</title>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
html, body { height: 100%; background: #000; overflow: hidden; }
|
|
/* No size cap: the wgpu device now takes its max_texture_dimension from
|
|
the adapter (see solitaire_web/src/lib.rs), so the surface can match
|
|
the full viewport. fit_canvas_to_parent sizes the canvas to 100%. */
|
|
#bevy-canvas { display: block; width: 100%; height: 100%; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<canvas id="bevy-canvas"></canvas>
|
|
<script type="module">
|
|
import init from "/web/pkg/canvas.js";
|
|
// solitaire_wasm.js provides SolitaireGame with the full debug / automation
|
|
// API. It loads its own WASM module (solitaire_wasm_bg.wasm) independently
|
|
// of the Bevy canvas (canvas_bg.wasm). The two game instances run in
|
|
// parallel: Bevy renders the visual game while the debug instance drives
|
|
// automated tests through window.__FERROUS_DEBUG__.
|
|
import initWasm, { SolitaireGame } from "/web/pkg/solitaire_wasm.js";
|
|
|
|
// ── Debug / automation bridge ─────────────────────────────────────────
|
|
// Exposes the same window.__FERROUS_DEBUG__ surface as /play-classic so
|
|
// the shared Playwright e2e harness (cycle_metrics.js, smoke.spec.js,
|
|
// gameplay_review.spec.js) can target /play without changes.
|
|
//
|
|
// URL params:
|
|
// ?seed=<int> — fixed seed (random if absent)
|
|
// ?draw3= — Draw-Three mode (Draw-One if absent)
|
|
|
|
function randomSeed() {
|
|
return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
|
|
}
|
|
|
|
function parseSeed(param) {
|
|
const n = Number.parseInt(param, 10);
|
|
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
}
|
|
|
|
function debugStateKey(state) {
|
|
if (!state) return "";
|
|
const cards = (pile) =>
|
|
Array.isArray(pile)
|
|
? pile.map((c) => `${c.rank}${c.suit[0]}${c.face_up ? "u" : "d"}`).join(",")
|
|
: "";
|
|
return [
|
|
cards(state.stock),
|
|
cards(state.waste),
|
|
...(state.foundations || []).map(cards),
|
|
...(state.tableaus || []).map(cards),
|
|
].join("|");
|
|
}
|
|
|
|
function orderBaselineDebugMoves(moves) {
|
|
if (!Array.isArray(moves)) return [];
|
|
const indices = moves.map((_, i) => i);
|
|
const foundationSingles = [];
|
|
const moveKind = [];
|
|
const rest = [];
|
|
for (const i of indices) {
|
|
const m = moves[i];
|
|
if (m?.kind === "move" && typeof m.to === "string" && m.to.startsWith("foundation-") && m.count === 1) {
|
|
foundationSingles.push(i);
|
|
} else if (m?.kind === "move") {
|
|
moveKind.push(i);
|
|
} else {
|
|
rest.push(i);
|
|
}
|
|
}
|
|
return [...foundationSingles, ...moveKind, ...rest];
|
|
}
|
|
|
|
async function bootstrap() {
|
|
await initWasm();
|
|
|
|
const params = new URLSearchParams(location.search);
|
|
const seedParam = parseSeed(params.get("seed"));
|
|
const draw3 = params.has("draw3");
|
|
const seedValue = seedParam !== null ? seedParam : randomSeed();
|
|
|
|
let game = new SolitaireGame(seedValue, draw3);
|
|
|
|
function runDebugAutoplay(options = {}) {
|
|
if (!game) return { ok: false, reason: "game_not_ready", step: 0 };
|
|
|
|
const maxSteps = Number.isInteger(options.maxSteps) && options.maxSteps > 0
|
|
? options.maxSteps : 220;
|
|
const maxVisitsPerState = Number.isInteger(options.maxVisitsPerState) && options.maxVisitsPerState > 0
|
|
? options.maxVisitsPerState : 2;
|
|
const policy = options.policy === "baseline" ? "baseline" : "loop_aware";
|
|
const seen = new Map();
|
|
|
|
function simulatedVisitCount(legalMoveIndex) {
|
|
let saved = null;
|
|
try { saved = game.serialize(); } catch { return null; }
|
|
if (typeof saved !== "string" || saved.length === 0) return null;
|
|
const applied = game.debug_apply_legal_move(legalMoveIndex);
|
|
if (!applied?.ok) {
|
|
try { game = SolitaireGame.from_saved(saved); } catch {}
|
|
return null;
|
|
}
|
|
const nextKey = debugStateKey(applied.snapshot);
|
|
try { game = SolitaireGame.from_saved(saved); } catch { return null; }
|
|
return seen.get(nextKey) || 0;
|
|
}
|
|
|
|
for (let step = 0; step < maxSteps; step++) {
|
|
const snap = game.debug_snapshot();
|
|
if (!snap?.state || !snap?.invariants)
|
|
return { ok: false, reason: "missing_snapshot", step };
|
|
if (!snap.invariants.state_ok)
|
|
return { ok: false, reason: "invariant_failed", step, snapshot: snap };
|
|
if (snap.state.is_won)
|
|
return { ok: true, terminal: "won", step, snapshot: snap };
|
|
|
|
const key = debugStateKey(snap.state);
|
|
const visits = (seen.get(key) || 0) + 1;
|
|
seen.set(key, visits);
|
|
if (visits > maxVisitsPerState)
|
|
return { ok: true, terminal: "cycle", step, snapshot: snap };
|
|
|
|
const legalMoves = game.debug_legal_moves();
|
|
if (!Array.isArray(legalMoves) || legalMoves.length === 0)
|
|
return { ok: true, terminal: "no_moves", step, snapshot: snap };
|
|
|
|
const ordered = orderBaselineDebugMoves(legalMoves);
|
|
let idx = ordered[0];
|
|
if (policy === "loop_aware" && ordered.length > 1) {
|
|
let bestIdx = ordered[0];
|
|
let bestVisitCount = Number.MAX_SAFE_INTEGER;
|
|
for (const candidate of ordered) {
|
|
const visitCount = simulatedVisitCount(candidate);
|
|
if (visitCount === null) continue;
|
|
if (visitCount < bestVisitCount) {
|
|
bestVisitCount = visitCount;
|
|
bestIdx = candidate;
|
|
if (visitCount === 0) break;
|
|
}
|
|
}
|
|
idx = bestIdx;
|
|
}
|
|
|
|
const result = game.debug_apply_legal_move(idx);
|
|
if (!result?.ok) {
|
|
return { ok: false, reason: "apply_failed", step, idx,
|
|
error: result?.error ?? "unknown_error" };
|
|
}
|
|
}
|
|
|
|
const finalSnap = game.debug_snapshot();
|
|
return { ok: !!finalSnap?.invariants?.state_ok, terminal: "step_budget",
|
|
snapshot: finalSnap };
|
|
}
|
|
|
|
function buildReplayPayload() {
|
|
if (!game) return null;
|
|
// Schema v4: the wasm side assembles the full payload,
|
|
// including the session recording and the u64 seed.
|
|
try {
|
|
const json = game.replay_export(
|
|
1,
|
|
new Date().toISOString().slice(0, 10),
|
|
);
|
|
const payload = JSON.parse(json);
|
|
if (!Array.isArray(payload?.recording?.instructions)
|
|
|| payload.recording.instructions.length === 0) return null;
|
|
return payload;
|
|
} catch { return null; }
|
|
}
|
|
|
|
window.__FERROUS_DEBUG__ = {
|
|
seed() { return game ? Math.round(game.seed()) : null; },
|
|
state() { return game ? game.state() : null; },
|
|
legalMoves() { return game ? game.debug_legal_moves() : []; },
|
|
moveHistory() { return game ? game.debug_move_history() : []; },
|
|
snapshot() { return game ? game.debug_snapshot() : null; },
|
|
applyLegalMove(index) {
|
|
if (!game) return { ok: false, error: "game_not_ready" };
|
|
return game.debug_apply_legal_move(index);
|
|
},
|
|
applyMove(move) {
|
|
if (!game) return { ok: false, error: "game_not_ready" };
|
|
const payload = typeof move === "string" ? move : JSON.stringify(move);
|
|
return game.debug_apply_move_json(payload);
|
|
},
|
|
draw() { return game ? game.draw() : { ok: false }; },
|
|
undo() { return game ? game.undo() : { ok: false }; },
|
|
serialize() { return game ? game.serialize() : null; },
|
|
fromSaved(json) {
|
|
try { game = SolitaireGame.from_saved(json); return true; }
|
|
catch { return false; }
|
|
},
|
|
newGame(seed, drawThree) {
|
|
game = new SolitaireGame(seed ?? randomSeed(), !!drawThree);
|
|
return game.state();
|
|
},
|
|
failureReport() {
|
|
if (!game) return null;
|
|
const debug = game.debug_snapshot();
|
|
return { seed: Math.round(game.seed()), moveHistory: debug?.move_history ?? [],
|
|
currentState: debug?.state ?? game.state(), stateJson: debug?.state_json ?? null,
|
|
legalMoves: debug?.legal_moves ?? [], invariants: debug?.invariants ?? null };
|
|
},
|
|
replayPayload() { return buildReplayPayload(); },
|
|
runAutoplay(options) { return runDebugAutoplay(options); },
|
|
};
|
|
}
|
|
|
|
// Start both: the debug bridge and the Bevy canvas.
|
|
await Promise.all([bootstrap(), init()]);
|
|
</script>
|
|
</body>
|
|
</html>
|