81893788c1
The 2048 surface limit was never wgpu's or the GPU's — it's downlevel_webgl2_defaults().max_texture_dimension_2d (2048), which WgpuSettingsPriority::WebGL2 forces. Switch to Functionality: on the WebGL2 (Gl) backend Bevy then adopts the adapter's real limits, which are still WebGL2-constrained for features/buffers (shaders stay GLES-compatible) but report the GPU's true max texture dimension (e.g. 16384). The device is requested with exactly what the adapter offers, so creation can't fail, the surface is no longer capped, and large viewports (4K) render with no letterbox and no hardcoded cap. Removes the resize_constraints cap and the play.html max-width/height caps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
218 lines
10 KiB
HTML
218 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;
|
|
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>
|