8a2a22ff1b
Correction to the previous canvas clamp (#97), which would NOT have fixed the crash. It capped the wrapper to the device's gl.MAX_TEXTURE_SIZE, but that's the hardware limit — on a 4K/integrated GPU it reports 8192+, so the wrapper was never actually capped and the 2560-wide surface still exceeded the limit. The real ceiling is wgpu's, not the hardware's: on wasm Bevy creates the device with Limits::downlevel_webgl2_defaults() (forced by WgpuSettingsPriority::WebGL2 in solitaire_web/src/lib.rs; see bevy_render-0.18.1 settings.rs), whose max_texture_dimension_2d is a fixed 2048 regardless of GPU. So the cap must be the constant 2048. (For the record: the reporter's display is 3840x2160 at 150% scale → a 2560x1440 logical viewport, which is the 2560 in the panic — nothing hardcodes 1440p.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
246 lines
12 KiB
HTML
246 lines
12 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; }
|
|
body { background: #000; overflow: hidden; }
|
|
/* #bevy-wrap fills the viewport but is capped to the GPU's real
|
|
MAX_TEXTURE_SIZE via an inline max-width/height set before init
|
|
(see the clamp script below). fit_canvas_to_parent sizes the wgpu
|
|
surface to this element, so capping it keeps the surface within
|
|
WebGL2's per-dimension limit. Centered so 2048-limited devices
|
|
letterbox symmetrically. */
|
|
#bevy-wrap {
|
|
width: 100vw; height: 100vh; margin: 0 auto;
|
|
display: flex; align-items: center; justify-content: center;
|
|
}
|
|
#bevy-canvas { display: block; width: 100%; height: 100%; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="bevy-wrap"><canvas id="bevy-canvas"></canvas></div>
|
|
<script>
|
|
// Clamp the Bevy canvas's backing buffer to 2048px *before* the wasm
|
|
// initialises. `fit_canvas_to_parent` sizes the wgpu surface to
|
|
// #bevy-wrap, and on wasm Bevy creates the device with
|
|
// `Limits::downlevel_webgl2_defaults()` (forced by
|
|
// `WgpuSettingsPriority::WebGL2` in solitaire_web/src/lib.rs), whose
|
|
// `max_texture_dimension_2d` is a fixed **2048** — independent of the
|
|
// GPU's real MAX_TEXTURE_SIZE (which is why querying the hardware limit
|
|
// doesn't help: a 4K/integrated GPU reports e.g. 8192 but wgpu still
|
|
// caps the surface at 2048). A surface wider/taller than 2048 makes
|
|
// Surface::configure panic and kill the WASM thread on the first frame
|
|
// — e.g. a 3840x2160 display at 150% scale gives a 2560x1440 logical
|
|
// viewport → a 2560-wide surface. So the cap is the constant 2048.
|
|
// Keep in sync with the wgpu limit selected in lib.rs.
|
|
(function clampCanvasToWebgl2Limit() {
|
|
var MAX = 2048; // wgpu downlevel_webgl2_defaults().max_texture_dimension_2d
|
|
var wrap = document.getElementById("bevy-wrap");
|
|
wrap.style.maxWidth = MAX + "px";
|
|
wrap.style.maxHeight = MAX + "px";
|
|
})();
|
|
</script>
|
|
<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;
|
|
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>
|