feat(e2e): add window.__FERROUS_DEBUG__ bridge to /play for automation
Build and Deploy / build-and-push (push) Successful in 4m42s
Web E2E / web-e2e (push) Successful in 4m10s

play.html now loads solitaire_wasm.js alongside the Bevy canvas and
exposes the same window.__FERROUS_DEBUG__ object as /play-classic.
The bridge runs an independent SolitaireGame (WASM logic layer) seeded
from ?seed= / ?draw3= URL params; Bevy renders the visual game in
parallel without coupling.

Methods exposed: seed, state, legalMoves, moveHistory, snapshot,
applyLegalMove, applyMove, draw, undo, serialize, fromSaved, newGame,
failureReport, replayPayload, runAutoplay — matching the /play-classic
contract so the shared Playwright harness targets either route without
modification.

cycle_metrics.js: add --route play-classic|play flag (default
play-classic). Routes to /${route}?seed=N. The resume-overlay clear
step is skipped for /play since the Bevy build uses localStorage-backed
WasmStorage, not a #resume-overlay element.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-02 13:41:07 -07:00
parent 8b262afcd2
commit 2cf728210e
2 changed files with 208 additions and 9 deletions
+195 -1
View File
@@ -14,7 +14,201 @@
<canvas id="bevy-canvas"></canvas>
<script type="module">
import init from "/web/pkg/canvas.js";
await init();
// 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;
try {
const moves = game.replay_moves();
if (!Array.isArray(moves) || moves.length === 0) return null;
return {
schema_version: 2,
seed: Math.round(game.seed()),
draw_mode: game.debug_snapshot()?.draw_mode ?? "DrawOne",
mode: "Classic",
moves,
};
} 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>