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 policyArg = readArg("--policy", "loop_aware");
|
||||||
const policy = policyArg === "baseline" ? "baseline" : "loop_aware";
|
const policy = policyArg === "baseline" ? "baseline" : "loop_aware";
|
||||||
const outPath = readArg("--out", "/tmp/playwright-cycle-metrics.json");
|
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 maxCycleRateAll = parseOptionalNumber("--max-cycle-rate-all");
|
||||||
const maxCycleRateDraw1 = parseOptionalNumber("--max-cycle-rate-draw1");
|
const maxCycleRateDraw1 = parseOptionalNumber("--max-cycle-rate-draw1");
|
||||||
const maxCycleRateDraw3 = parseOptionalNumber("--max-cycle-rate-draw3");
|
const maxCycleRateDraw3 = parseOptionalNumber("--max-cycle-rate-draw3");
|
||||||
@@ -155,10 +158,11 @@ async function main() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto(`${baseUrl}/play-classic?seed=${seed}${suffix}`, {
|
await page.goto(`${baseUrl}/${route}?seed=${seed}${suffix}`, {
|
||||||
waitUntil: "domcontentloaded",
|
waitUntil: "domcontentloaded",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (route === "play-classic") {
|
||||||
const resumeVisible = await page
|
const resumeVisible = await page
|
||||||
.locator("#resume-overlay:not(.hidden)")
|
.locator("#resume-overlay:not(.hidden)")
|
||||||
.isVisible()
|
.isVisible()
|
||||||
@@ -167,6 +171,7 @@ async function main() {
|
|||||||
await page.evaluate(() => localStorage.removeItem("fs_game_save"));
|
await page.evaluate(() => localStorage.removeItem("fs_game_save"));
|
||||||
await page.reload({ waitUntil: "domcontentloaded" });
|
await page.reload({ waitUntil: "domcontentloaded" });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -14,7 +14,201 @@
|
|||||||
<canvas id="bevy-canvas"></canvas>
|
<canvas id="bevy-canvas"></canvas>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import init from "/web/pkg/canvas.js";
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user