feat(e2e): add window.__FERROUS_DEBUG__ bridge to /play for automation
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:
@@ -103,6 +103,9 @@ async function main() {
|
||||
const policyArg = readArg("--policy", "loop_aware");
|
||||
const policy = policyArg === "baseline" ? "baseline" : "loop_aware";
|
||||
const outPath = readArg("--out", "/tmp/playwright-cycle-metrics.json");
|
||||
// --route play-classic (default) or --route play
|
||||
const routeArg = readArg("--route", "play-classic");
|
||||
const route = routeArg === "play" ? "play" : "play-classic";
|
||||
const maxCycleRateAll = parseOptionalNumber("--max-cycle-rate-all");
|
||||
const maxCycleRateDraw1 = parseOptionalNumber("--max-cycle-rate-draw1");
|
||||
const maxCycleRateDraw3 = parseOptionalNumber("--max-cycle-rate-draw3");
|
||||
@@ -155,17 +158,19 @@ async function main() {
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(`${baseUrl}/play-classic?seed=${seed}${suffix}`, {
|
||||
await page.goto(`${baseUrl}/${route}?seed=${seed}${suffix}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
|
||||
const resumeVisible = await page
|
||||
.locator("#resume-overlay:not(.hidden)")
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
if (resumeVisible) {
|
||||
await page.evaluate(() => localStorage.removeItem("fs_game_save"));
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
if (route === "play-classic") {
|
||||
const resumeVisible = await page
|
||||
.locator("#resume-overlay:not(.hidden)")
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
if (resumeVisible) {
|
||||
await page.evaluate(() => localStorage.removeItem("fs_game_save"));
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
}
|
||||
}
|
||||
|
||||
await page.waitForFunction(
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user