Files
funman300 4cb4212829
Test / test (pull_request) Successful in 36m34s
fix(replay): store the deal via upstream card_game serializers (schema v4)
Replays previously persisted only seed + moves and re-dealt the board
from the seed at playback time, so any change to the seed->deal mapping
(RNG bumps, upstream upgrades) silently invalidated every existing
replay. Schema v4 instead embeds a SessionRecording - the upstream
card_game Session serde ({config, initial_state, instructions}) - so
playback rebuilds the exact recorded board; seed/draw_mode/mode remain
caption metadata only.

- core: SessionRecording newtype delegating to Session<Klondike> serde;
  GameState::recording() / from_recording(); from_instructions_unchecked
  fixture helper; serde_json added to dev-deps (tests only)
- data: Replay v4 (recording replaces moves); v1-v3 files rejected by
  the existing version gate
- engine: win-recording and sync upload freeze game.recording();
  playback rebuilds from the recording; Playing carries the extracted
  move list (+ Box<Replay> for clippy large_enum_variant)
- wasm: replay_export() builds the full v4 upload payload so JS never
  hand-assembles it (the old game.js path hardcoded schema_version: 2
  and corrupted u64 seeds via Math.round); ReplayPlayer::from_json
  enforces schema_version == 4 with a descriptive error
- web: game.js/play.html use replay_export; replay.js surfaces player
  construction errors in the caption instead of dying silently
- server: mode validation accepts data-carrying GameMode variants
  (Difficulty uploads previously 400'd against the String field)

Both replays on prod are May-era v1 rows with empty move lists - every
shared replay was already unplayable; the viewer now says why.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:10:38 -07:00

1039 lines
39 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Ferrous Solitaire — interactive browser game.
//
// Architecture:
// - `SolitaireGame` (Rust/WASM via solitaire_core) owns all rule logic.
// - This file owns the DOM renderer, drag-and-drop input, and the game loop.
// - Cards are persistent DOM elements keyed by `card.id`; positions are
// updated via `transform: translate(...)` so the browser can animate
// flights on the compositor thread.
//
// Pile name convention (mirrors solitaire_wasm::SolitaireGame::move_cards):
// "stock" | "waste" | "foundation-0".."foundation-3" | "tableau-0".."tableau-6"
import init, { SolitaireGame } from "/web/pkg/solitaire_wasm.js";
// ── Layout constants (must match game.css --card-w / --card-h / --gap / --pad)
const CARD_W = 80;
const CARD_H = 120; // 2:3 ratio matching 256×384 card PNGs
const GAP = 12;
const PAD = 20; // board inner padding — cards start at (PAD, PAD)
const FAN = 28; // vertical offset per fanned tableau card
const WASTE_FAN = 18; // horizontal offset for draw-3 waste fan
// Natural board dimensions — used for scale-to-fit calculation.
const BOARD_W = PAD * 2 + 7 * CARD_W + 6 * GAP; // 672
const BOARD_H = PAD * 2 + CARD_H + 28 + CARD_H + 12 * FAN; // 644
// Pile origins in board-element coordinates (include PAD so (0,0) = board edge).
const TOP_Y = PAD;
const BOTTOM_Y = PAD + CARD_H + 28;
const colX = (c) => PAD + c * (CARD_W + GAP);
const PILE_ORIGIN = {
stock: { x: colX(0), y: TOP_Y },
waste: { x: colX(1), y: TOP_Y },
"foundation-0": { x: colX(3), y: TOP_Y },
"foundation-1": { x: colX(4), y: TOP_Y },
"foundation-2": { x: colX(5), y: TOP_Y },
"foundation-3": { x: colX(6), y: TOP_Y },
"tableau-0": { x: colX(0), y: BOTTOM_Y },
"tableau-1": { x: colX(1), y: BOTTOM_Y },
"tableau-2": { x: colX(2), y: BOTTOM_Y },
"tableau-3": { x: colX(3), y: BOTTOM_Y },
"tableau-4": { x: colX(4), y: BOTTOM_Y },
"tableau-5": { x: colX(5), y: BOTTOM_Y },
"tableau-6": { x: colX(6), y: BOTTOM_Y },
};
// Foundation suit hints shown when the slot is empty.
const FOUND_SUIT_HINT = ["♠", "♥", "♦", "♣"];
const SUIT_CODE = { clubs: "C", diamonds: "D", hearts: "H", spades: "S" };
const RANK_LABELS = ["","A","2","3","4","5","6","7","8","9","10","J","Q","K"];
const RED_SUITS = new Set(["diamonds", "hearts"]);
// ── Theme ─────────────────────────────────────────────────────────────────────
let cardTheme = localStorage.getItem("fs_theme") || "classic";
function preloadTheme(theme) {
const suits = Object.values(SUIT_CODE);
const ranks = RANK_LABELS.slice(1);
for (const r of ranks) for (const s of suits) {
new Image().src = `/assets/cards/faces/${theme}/${r}${s}.png`;
}
new Image().src = `/assets/cards/backs/${theme}/back_0.png`;
}
// Preload both themes on load so switching is instant.
preloadTheme("classic");
preloadTheme("dark");
// ── Persistence ──────────────────────────────────────────────────────────────
const LS_SAVE_KEY = "fs_game_save";
function saveState() {
if (!game) return;
try {
const gameState = game.serialize();
if (typeof gameState !== "string") return;
localStorage.setItem(LS_SAVE_KEY, JSON.stringify({ gameState, elapsedSecs, drawThree }));
} catch (e) {
// localStorage may be unavailable (private browsing quota, etc.) — never block gameplay.
console.warn("fs: save failed", e);
}
}
function clearSave() {
try { localStorage.removeItem(LS_SAVE_KEY); } catch { /* ignore */ }
}
function loadSave() {
try {
const raw = localStorage.getItem(LS_SAVE_KEY);
if (!raw) return null;
const save = JSON.parse(raw);
return save?.gameState ? save : null;
} catch { return null; }
}
// ── State ────────────────────────────────────────────────────────────────────
let game = null;
let snap = null; // last rendered GameSnapshot
let drawThree = false;
// Persistent card-id → DOM element map.
const cardEls = new Map();
// Drag state
let drag = null;
// drag = {
// fromPile: string,
// fromIndex: number, // index of bottom dragged card in its pile
// cardIds: number[], // ids bottom→top
// startX: number, startY: number, // board-relative pointer start
// }
// Timer
let timerInterval = null;
let elapsedSecs = 0;
// Auto-complete
let acTimer = null;
// Current scale factor applied to #board.
let boardScale = 1.0;
// ── DOM refs ─────────────────────────────────────────────────────────────────
const board = document.getElementById("card-area");
const hudScore = document.getElementById("hud-score");
const hudMoves = document.getElementById("hud-moves");
const hudTimer = document.getElementById("hud-timer");
const hudStock = document.getElementById("hud-stock");
const hudSeed = document.getElementById("hud-seed");
const btnUndo = document.getElementById("btn-undo");
const btnBoardUndo = document.getElementById("btn-board-undo");
const btnNew = document.getElementById("btn-new");
const chkDraw3 = document.getElementById("chk-draw3");
const btnTheme = document.getElementById("btn-theme");
const winOverlay = document.getElementById("win-overlay");
const winScore = document.getElementById("win-score");
const winMoves = document.getElementById("win-moves");
const winTime = document.getElementById("win-time");
const btnWinNew = document.getElementById("btn-win-new");
const noMovesBanner = document.getElementById("no-moves-banner");
// ── Scale to fit ─────────────────────────────────────────────────────────────
// Scales #card-area to fill #board without overflowing either dimension.
// boardRelative() divides by boardScale to keep hit-testing correct.
function scaleBoard() {
// Measure the actual rendered #board element — more reliable than
// computing window.innerHeight minus estimated header height, which
// breaks under different browser chrome / OS scaling factors.
const outerBoard = document.getElementById("board");
const bw = outerBoard.clientWidth;
const bh = outerBoard.clientHeight;
boardScale = Math.min(bw / BOARD_W, bh / BOARD_H, 2.0);
board.style.transform = `scale(${boardScale})`;
board.style.transformOrigin = "center center";
}
function syncThemeButton() {
if (btnTheme) btnTheme.textContent = cardTheme === "classic" ? "Dark" : "Classic";
}
// ── Bootstrap ────────────────────────────────────────────────────────────────
async function bootstrap() {
await init();
syncThemeButton();
buildSlots();
scaleBoard();
window.addEventListener("resize", scaleBoard);
attachHandlers();
const saved = loadSave();
if (saved) {
showResumeDialog(saved);
} else {
const params = new URLSearchParams(window.location.search);
const rawSeed = Number(params.get("seed"));
const urlSeed = params.has("seed") && Number.isFinite(rawSeed) && rawSeed > 0
? Math.floor(rawSeed)
: randomSeed();
drawThree = params.has("draw3");
chkDraw3.checked = drawThree;
startGame(urlSeed);
}
}
function showResumeDialog(saved) {
const overlay = document.getElementById("resume-overlay");
if (overlay) overlay.classList.remove("hidden");
document.getElementById("btn-resume").onclick = () => {
if (overlay) overlay.classList.add("hidden");
resumeGame(saved);
};
document.getElementById("btn-resume-new").onclick = () => {
clearSave();
if (overlay) overlay.classList.add("hidden");
drawThree = false;
chkDraw3.checked = false;
startGame(randomSeed());
};
}
function resumeGame(saved) {
let restored;
try {
restored = SolitaireGame.from_saved(saved.gameState);
} catch (e) {
console.warn("fs: restore failed, starting new game", e);
clearSave();
startGame(randomSeed());
return;
}
game = restored;
drawThree = !!saved.drawThree;
elapsedSecs = saved.elapsedSecs || 0;
chkDraw3.checked = drawThree;
const displaySeed = Math.round(game.seed());
hudSeed.textContent = `seed ${displaySeed}`;
winOverlay.classList.add("hidden");
cardEls.clear();
board.querySelectorAll(".card, .recycle-label").forEach(el => el.remove());
const url = new URL(window.location);
url.searchParams.set("seed", displaySeed);
if (drawThree) url.searchParams.set("draw3", "");
else url.searchParams.delete("draw3");
history.replaceState(null, "", url);
const s = game.state();
snap = s;
render(s);
if (!s.is_won) startTimer();
}
function randomSeed() {
return Math.floor(Math.random() * 9007199254740991);
}
function startGame(seed) {
if (acTimer) { clearInterval(acTimer); acTimer = null; }
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
elapsedSecs = 0;
updateTimerDisplay();
game = new SolitaireGame(seed, drawThree);
snap = game.state();
const displaySeed = Math.round(game.seed());
hudSeed.textContent = `seed ${displaySeed}`;
winOverlay.classList.add("hidden");
cardEls.clear();
board.querySelectorAll(".card, .recycle-label").forEach(el => el.remove());
// Persist seed in URL so the game can be shared / refreshed.
const url = new URL(window.location);
url.searchParams.set("seed", displaySeed);
if (drawThree) url.searchParams.set("draw3", "");
else url.searchParams.delete("draw3");
history.replaceState(null, "", url);
render(snap);
startTimer();
}
// ── Timer ────────────────────────────────────────────────────────────────────
function startTimer() {
// Idempotent: never stack a second interval. The visibilitychange handler
// and startGame can both call this, and a stray call (e.g. a load-time
// visibilitychange while a timer is already running) would otherwise leak
// the old interval and make elapsedSecs increment twice per second.
if (timerInterval) return;
timerInterval = setInterval(() => {
elapsedSecs++;
updateTimerDisplay();
}, 1000);
}
function stopTimer() {
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
}
function updateTimerDisplay() {
const m = Math.floor(elapsedSecs / 60);
const s = elapsedSecs % 60;
if (hudTimer) hudTimer.textContent = `${m}:${s.toString().padStart(2, "0")}`;
}
// ── Slot placeholders ─────────────────────────────────────────────────────────
function buildSlots() {
for (const [pile, origin] of Object.entries(PILE_ORIGIN)) {
const el = document.createElement("div");
el.className = "slot";
el.dataset.pile = pile;
el.style.transform = `translate(${origin.x}px, ${origin.y}px)`;
if (pile.startsWith("foundation-")) {
const slot = parseInt(pile.split("-")[1]);
const hint = document.createElement("div");
hint.className = "slot-hint";
hint.textContent = FOUND_SUIT_HINT[slot];
el.appendChild(hint);
}
board.appendChild(el);
}
}
// ── Card position math ────────────────────────────────────────────────────────
function cardPos(pileName, indexInPile, pileLength) {
const origin = PILE_ORIGIN[pileName];
let x = origin.x;
let y = origin.y;
if (pileName === "waste" && drawThree && pileLength >= 2) {
const fanStart = Math.max(0, pileLength - 3);
const fanPos = indexInPile - fanStart;
if (fanPos > 0) x += fanPos * WASTE_FAN;
} else if (pileName.startsWith("tableau-")) {
y += indexInPile * FAN;
}
return { x, y };
}
function cardZ(pileName, indexInPile) {
return 10 + indexInPile;
}
// ── Renderer ──────────────────────────────────────────────────────────────────
function render(s) {
snap = s;
hudScore.textContent = `Score: ${s.score}`;
hudMoves.textContent = `Moves: ${s.move_count}`;
if (hudStock) hudStock.textContent = `Stock: ${s.stock.length}`;
btnUndo.disabled = s.undo_stack_len === 0;
btnBoardUndo.disabled = s.undo_stack_len === 0;
const visible = new Map();
const addPile = (name, cards) =>
cards.forEach((c, i) => visible.set(c.id, { pile: name, idx: i, card: c, total: cards.length }));
addPile("stock", s.stock);
addPile("waste", s.waste);
s.foundations.forEach((f, i) => addPile(`foundation-${i}`, f));
s.tableaus.forEach((t, i) => addPile(`tableau-${i}`, t));
for (const [id, info] of visible) {
let el = cardEls.get(id);
if (!el) {
el = document.createElement("div");
el.dataset.cardId = id;
cardEls.set(id, el);
board.appendChild(el);
}
updateCardEl(el, info.card, info.pile, info.idx, info.total);
}
for (const [id, el] of cardEls) {
if (!visible.has(id)) { el.remove(); cardEls.delete(id); }
}
// Foundation suit hints: hide when pile has cards.
s.foundations.forEach((f, i) => {
const slotEl = board.querySelector(`.slot[data-pile="foundation-${i}"]`);
if (slotEl) {
const hint = slotEl.querySelector(".slot-hint");
if (hint) hint.style.visibility = f.length > 0 ? "hidden" : "";
}
});
// Recycle indicator on empty stock.
let recycleEl = board.querySelector(".recycle-label");
if (s.stock.length === 0 && s.waste.length > 0) {
if (!recycleEl) {
recycleEl = document.createElement("div");
recycleEl.className = "recycle-label";
recycleEl.textContent = "↺";
board.appendChild(recycleEl);
}
const o = PILE_ORIGIN.stock;
recycleEl.style.transform = `translate(${o.x}px, ${o.y}px)`;
} else if (recycleEl) {
recycleEl.remove();
}
// Clear drag highlights left from pointer-move.
board.querySelectorAll(".slot.drop-active").forEach(e => e.classList.remove("drop-active"));
board.querySelectorAll(".card.drop-target").forEach(e => e.classList.remove("drop-target"));
if (s.is_auto_completable && !s.is_won && !acTimer) {
stopTimer(); // freeze elapsed time at the moment the player's last move completes
acTimer = setInterval(doAutoCompleteStep, 380);
}
if (s.is_won) {
clearSave();
stopTimer();
if (acTimer) { clearInterval(acTimer); acTimer = null; }
if (noMovesBanner) noMovesBanner.classList.add("hidden");
// Delay slightly so the last card's CSS transition finishes before
// the win overlay covers the board. Card transitions are ~260 ms.
setTimeout(() => showWin(s), 320);
} else {
// If the player undid out of auto-complete, restart the timer —
// stopTimer() was called when auto-complete began, but no code path
// before here restarts it after an undo.
if (!s.is_auto_completable && !timerInterval) {
startTimer();
}
saveState();
const noMoves = !s.has_moves && !s.is_auto_completable;
if (noMovesBanner) noMovesBanner.classList.toggle("hidden", !noMoves);
}
}
function updateCardEl(el, card, pileName, idx, total) {
const pos = cardPos(pileName, idx, total);
el.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
el.style.zIndex = cardZ(pileName, idx);
if (!card.face_up) {
el.className = "card face-down";
el.style.backgroundImage = `url('/assets/cards/backs/${cardTheme}/back_0.png')`;
el.innerHTML = "";
} else {
const isRed = RED_SUITS.has(card.suit);
el.className = `card ${isRed ? "red" : "black"}`;
el.style.backgroundImage = `url('/assets/cards/faces/${cardTheme}/${RANK_LABELS[card.rank]}${SUIT_CODE[card.suit]}.png')`;
el.innerHTML = "";
}
}
// ── Win overlay ───────────────────────────────────────────────────────────────
function showWin(s) {
winScore.textContent = `Score: ${s.score}`;
winMoves.textContent = `${s.move_count} moves`;
const m = Math.floor(elapsedSecs / 60);
const sec = elapsedSecs % 60;
if (winTime) winTime.textContent = `${m}:${sec.toString().padStart(2, "0")}`;
winOverlay.classList.remove("hidden");
submitReplay(s);
}
function buildReplayPayload() {
if (!game) return null;
// The wasm side assembles the entire schema-v4 payload (including the
// session recording and the u64 seed, which JS numbers can't hold);
// JS only supplies the wall-clock time and today's date.
try {
const json = game.replay_export(
Math.max(1, elapsedSecs),
new Date().toISOString().slice(0, 10),
);
const payload = JSON.parse(json);
if (!Array.isArray(payload?.recording?.instructions)
|| payload.recording.instructions.length === 0) return null;
return payload;
} catch (e) {
console.warn("fs: replay export failed", e);
return null;
}
}
async function submitReplay(s) {
const token = localStorage.getItem('fs_token');
if (!token || !game) return;
const payload = buildReplayPayload();
if (!payload) return;
try {
await fetch('/api/replays', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify(payload),
});
} catch (_) { /* best-effort — never block the win screen */ }
}
// ── Auto-complete ─────────────────────────────────────────────────────────────
function doAutoCompleteStep() {
if (!game || !snap?.is_auto_completable) {
clearInterval(acTimer); acTimer = null; return;
}
const result = game.auto_complete_step();
if (result?.ok) render(result.snapshot);
else { clearInterval(acTimer); acTimer = null; }
}
// ── Illegal move flash ────────────────────────────────────────────────────────
function flashIllegal(cardIds) {
for (const id of cardIds) {
const el = cardEls.get(id);
if (!el) continue;
// Remove any in-progress shake before restarting. Reading offsetWidth
// forces a synchronous layout flush so the browser sees the removal
// before we re-add the class, restarting the animation from frame 0.
el.classList.remove("illegal");
el.style.removeProperty("--card-tx");
void el.offsetWidth; // flush layout — do not remove
el.style.setProperty("--card-tx", el.style.transform || "translate(0,0)");
el.classList.add("illegal");
el.addEventListener("animationend", () => {
el.classList.remove("illegal");
el.style.removeProperty("--card-tx");
}, { once: true });
}
}
// ── Input ─────────────────────────────────────────────────────────────────────
function attachHandlers() {
const doUndo = () => { const r = game.undo(); if (r.ok) render(r.snapshot); };
btnUndo.addEventListener("click", doUndo);
btnBoardUndo.addEventListener("click", doUndo);
btnNew.addEventListener("click", () => startGame(randomSeed()));
document.getElementById("btn-no-moves-undo")?.addEventListener("click", doUndo);
document.getElementById("btn-no-moves-new")?.addEventListener("click", () => startGame(randomSeed()));
btnWinNew.addEventListener("click", () => startGame(randomSeed()));
chkDraw3.addEventListener("change", () => {
drawThree = chkDraw3.checked;
startGame(randomSeed());
});
btnTheme.addEventListener("click", () => {
cardTheme = cardTheme === "classic" ? "dark" : "classic";
localStorage.setItem("fs_theme", cardTheme);
syncThemeButton();
if (game) render(game.state());
});
const doDraw = () => { const r = game.draw(); if (r.ok) render(r.snapshot); };
document.addEventListener("keydown", (e) => {
const tag = e.target?.tagName;
if (e.target?.isContentEditable || tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
if (e.key === "z" || e.key === "Z" || e.key === "u" || e.key === "U") {
e.preventDefault();
doUndo();
return;
}
if (e.key === "n" || e.key === "N") {
startGame(randomSeed());
return;
}
if (!e.repeat && (e.code === "Space" || e.key === " ")) {
e.preventDefault();
doDraw();
}
});
// Pause the game timer while the tab is hidden so background time doesn't
// inflate the player's recorded game duration.
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
stopTimer();
} else if (snap && !snap.is_won && !snap.is_auto_completable) {
startTimer();
}
});
board.addEventListener("pointerdown", onPointerDown);
board.addEventListener("pointermove", onPointerMove);
board.addEventListener("pointerup", onPointerUp);
board.addEventListener("pointercancel", onPointerCancel);
board.addEventListener("click", onBoardClick);
board.addEventListener("dblclick", onBoardDblClick);
board.addEventListener("contextmenu", (e) => {
e.preventDefault();
if (drag) return;
const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
const hit = hitTestCard(bx, by);
if (!hit || !hit.card.face_up) return;
const cards = getPileCards(hit.pileName);
if (!cards) return;
let fromIndex = hit.cardIndex;
if (!hit.pileName.startsWith("tableau-")) {
if (fromIndex !== cards.length - 1) return;
}
smartMove(hit.pileName, fromIndex);
});
}
// ── Coordinate helpers ────────────────────────────────────────────────────────
// Returns cursor position in board-element coordinates
// (0,0 = board element top-left corner, which is the padding edge).
// Divides by boardScale because getBoundingClientRect() returns the SCALED
// visual rect; we need coordinates in the natural (pre-scale) system.
function boardRelative(clientX, clientY) {
const rect = board.getBoundingClientRect();
return {
x: (clientX - rect.left) / boardScale,
y: (clientY - rect.top) / boardScale,
};
}
function hitTestCard(bx, by) {
const pileOrder = [
"waste",
"foundation-0","foundation-1","foundation-2","foundation-3",
"tableau-0","tableau-1","tableau-2","tableau-3","tableau-4","tableau-5","tableau-6",
"stock",
];
let best = null, bestZ = -1;
for (const pileName of pileOrder) {
const cards = getPileCards(pileName);
if (!cards) continue;
for (let i = 0; i < cards.length; i++) {
const pos = cardPos(pileName, i, cards.length);
if (bx >= pos.x && bx <= pos.x + CARD_W &&
by >= pos.y && by <= pos.y + CARD_H) {
const z = cardZ(pileName, i);
if (z > bestZ) {
bestZ = z;
best = { pileName, cardIndex: i, card: cards[i] };
}
}
}
}
return best;
}
function getPileCards(pileName) {
if (!snap) return null;
if (pileName === "stock") return snap.stock;
if (pileName === "waste") return snap.waste;
if (pileName.startsWith("foundation-")) return snap.foundations[parseInt(pileName.split("-")[1])];
if (pileName.startsWith("tableau-")) return snap.tableaus [parseInt(pileName.split("-")[1])];
return null;
}
// Drop-target: tableau has tall hit areas; foundations use their slot box.
function findDropTarget(bx, by) {
for (let c = 0; c < 7; c++) {
const pile = `tableau-${c}`;
const cards = snap.tableaus[c];
const origin = PILE_ORIGIN[pile];
const bottomY = cards.length > 0
? origin.y + (cards.length - 1) * FAN + CARD_H
: origin.y + CARD_H;
if (bx >= origin.x && bx <= origin.x + CARD_W && by >= origin.y && by <= bottomY)
return pile;
}
for (let s = 0; s < 4; s++) {
const pile = `foundation-${s}`;
const origin = PILE_ORIGIN[pile];
if (bx >= origin.x && bx <= origin.x + CARD_W &&
by >= origin.y && by <= origin.y + CARD_H)
return pile;
}
return null;
}
// ── Pointer handlers ──────────────────────────────────────────────────────────
function onPointerDown(e) {
if (e.button !== 0 && e.pointerType === "mouse") return;
if (drag) return;
const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
const hit = hitTestCard(bx, by);
if (!hit || !hit.card.face_up) return;
const cards = getPileCards(hit.pileName);
if (!cards) return;
let fromIndex = hit.cardIndex;
if (!hit.pileName.startsWith("tableau-")) {
fromIndex = cards.length - 1;
if (hit.cardIndex !== fromIndex) return;
}
const draggedCards = cards.slice(fromIndex);
if (draggedCards.some(c => !c.face_up)) return;
drag = {
fromPile: hit.pileName,
fromIndex,
cardIds: draggedCards.map(c => c.id),
startX: bx,
startY: by,
};
drag.cardIds.forEach((id, i) => {
const el = cardEls.get(id);
if (el) { el.classList.add("selected"); el.style.zIndex = 500 + i; }
});
board.setPointerCapture(e.pointerId);
e.preventDefault();
}
function onPointerMove(e) {
if (!drag) return;
const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
const dx = bx - drag.startX;
const dy = by - drag.startY;
const cards = getPileCards(drag.fromPile);
drag.cardIds.forEach((id, i) => {
const el = cardEls.get(id);
if (!el) return;
const base = cardPos(drag.fromPile, drag.fromIndex + i, cards ? cards.length : 1);
el.style.transform = `translate(${base.x + dx}px, ${base.y + dy}px)`;
});
// Highlight drop target.
board.querySelectorAll(".slot.drop-active").forEach(e => e.classList.remove("drop-active"));
board.querySelectorAll(".card.drop-target").forEach(e => e.classList.remove("drop-target"));
const targetPile = findDropTarget(bx, by);
if (targetPile) {
const slotEl = board.querySelector(`.slot[data-pile="${targetPile}"]`);
if (slotEl) slotEl.classList.add("drop-active");
const targetCards = getPileCards(targetPile);
if (targetCards?.length > 0) {
const topEl = cardEls.get(targetCards[targetCards.length - 1].id);
if (topEl) topEl.classList.add("drop-target");
}
}
e.preventDefault();
}
function onPointerUp(e) {
if (!drag) return;
board.querySelectorAll(".slot.drop-active").forEach(e => e.classList.remove("drop-active"));
board.querySelectorAll(".card.drop-target").forEach(e => e.classList.remove("drop-target"));
const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
const targetPile = findDropTarget(bx, by);
let moved = false;
let illegalAttempt = false;
if (targetPile && targetPile !== drag.fromPile) {
const r = game.move_cards(drag.fromPile, targetPile, drag.cardIds.length);
if (r.ok) {
moved = true;
drag.cardIds.forEach(id => cardEls.get(id)?.classList.remove("selected"));
render(r.snapshot);
} else {
illegalAttempt = true;
}
}
if (!moved) {
drag.cardIds.forEach(id => cardEls.get(id)?.classList.remove("selected"));
render(snap); // snap cards back first — then animate so shake plays on settled positions
if (illegalAttempt) flashIllegal(drag.cardIds);
}
drag = null;
}
function onPointerCancel() {
if (!drag) return;
drag.cardIds.forEach(id => cardEls.get(id)?.classList.remove("selected"));
render(snap);
drag = null;
}
// ── Click / dblclick ──────────────────────────────────────────────────────────
function onBoardClick(e) {
if (drag || snap?.is_won) return;
const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
const stock = PILE_ORIGIN.stock;
if (bx >= stock.x && bx <= stock.x + CARD_W && by >= stock.y && by <= stock.y + CARD_H) {
const r = game.draw();
if (r.ok) render(r.snapshot);
}
}
// Try to move cards from pileName starting at fromIndex to the best valid target.
// Tries foundations first (single card only), then tableau columns.
// Returns true if a move was made.
function smartMove(pileName, fromIndex) {
const cards = getPileCards(pileName);
if (!cards || fromIndex >= cards.length) return false;
const count = cards.length - fromIndex;
if (count === 1) {
for (let s = 0; s < 4; s++) {
const r = game.move_cards(pileName, `foundation-${s}`, 1);
if (r.ok) { render(r.snapshot); return true; }
}
}
for (let t = 0; t < 7; t++) {
const target = `tableau-${t}`;
if (target === pileName) continue;
const r = game.move_cards(pileName, target, count);
if (r.ok) { render(r.snapshot); return true; }
}
return false;
}
function onBoardDblClick(e) {
if (drag || snap?.is_won) return;
const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
const hit = hitTestCard(bx, by);
if (!hit || !hit.card.face_up) return;
const cards = getPileCards(hit.pileName);
if (!cards) return;
let fromIndex = hit.cardIndex;
if (!hit.pileName.startsWith("tableau-")) {
if (fromIndex !== cards.length - 1) return;
}
if (!smartMove(hit.pileName, fromIndex)) flashIllegal([cards[fromIndex].id]);
}
// ── Avatar ────────────────────────────────────────────────────────────────────
async function loadAvatar() {
const token = localStorage.getItem("fs_token");
if (!token) return;
try {
const res = await fetch("/api/me", {
headers: { Authorization: "Bearer " + token },
});
if (!res.ok) return;
const me = await res.json();
const link = document.getElementById("hud-avatar");
const img = document.getElementById("hud-avatar-img");
const init = document.getElementById("hud-avatar-initials");
link.style.display = "flex";
if (me.avatar_url) {
img.src = me.avatar_url;
img.style.display = "block";
init.style.display = "none";
} else {
img.style.display = "none";
init.textContent = (me.username || "P")[0].toUpperCase();
}
} catch { /* not signed in — avatar stays hidden */ }
}
function debugStateKey(state) {
if (!state) return "missing";
if (Array.isArray(state.stock) || Array.isArray(state.tableaus)) {
const out = [];
const push = cards => {
for (const c of cards || []) out.push(`${c.id}:${c.face_up ? 1 : 0}`);
out.push("|");
};
push(state.stock);
push(state.waste);
for (const pile of state.foundations || []) push(pile);
for (const pile of state.tableaus || []) push(pile);
return out.join("");
}
return JSON.stringify(state);
}
function orderBaselineDebugMoves(legalMoves) {
const foundationSingles = [];
const moveKind = [];
const rest = [];
for (let i = 0; i < legalMoves.length; i++) {
const move = legalMoves[i];
if (
move?.kind === "move" &&
typeof move.to === "string" &&
move.to.startsWith("foundation-") &&
move.count === 1
) {
foundationSingles.push(i);
} else if (move?.kind === "move") {
moveKind.push(i);
} else {
rest.push(i);
}
}
return [...foundationSingles, ...moveKind, ...rest];
}
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",
};
}
if (result.snapshot) render(result.snapshot);
}
const finalSnap = game.debug_snapshot();
return { ok: !!finalSnap?.invariants?.state_ok, terminal: "step_budget", snapshot: finalSnap };
}
// ── Debug API (engine-first automation surface) ───────────────────────────────
// Playwright and other automation harnesses use this object instead of pixel
// analysis or hardcoded coordinates. Every operation delegates to the Rust
// rules engine exported by `solitaire_wasm`.
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;
},
serialize() {
return game ? game.serialize() : null;
},
// Reset to a fresh seeded game in place (no page reload). Lets the cycle
// regression harness reuse one page across hundreds of games instead of
// navigating per game.
newGame(seed, drawThreeMode) {
drawThree = !!drawThreeMode;
startGame(seed ?? randomSeed());
return game ? game.state() : null;
},
applyLegalMove(index) {
if (!game) return { ok: false, error: "game_not_ready" };
const result = game.debug_apply_legal_move(index);
if (result?.ok && result.snapshot) render(result.snapshot);
return result;
},
applyMove(move) {
if (!game) return { ok: false, error: "game_not_ready" };
const payload = typeof move === "string" ? move : JSON.stringify(move);
const result = game.debug_apply_move_json(payload);
if (result?.ok && result.snapshot) render(result.snapshot);
return result;
},
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() {
if (!game) return null;
return buildReplayPayload();
},
runAutoplay(options) {
return runDebugAutoplay(options);
},
};
// ── Start ─────────────────────────────────────────────────────────────────────
bootstrap().catch(console.error);
loadAvatar();