Files
funman300 81893788c1 fix(web): use adapter limits (Functionality) so /play renders at native res
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>
2026-06-24 09:18:07 -07:00

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>