// 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() { 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(s) { if (!game || !s) return null; let moves; try { moves = game.replay_moves(); if (!Array.isArray(moves) || moves.length === 0) return null; } catch (e) { console.warn("fs: replay export failed", e); return null; } return { schema_version: 2, seed: Math.round(game.seed()), draw_mode: drawThree ? "DrawThree" : "DrawOne", mode: "Classic", time_seconds: Math.max(1, elapsedSecs), final_score: s.score, recorded_at: new Date().toISOString().slice(0, 10), moves, win_move_index: moves.length - 1, }; } async function submitReplay(s) { const token = localStorage.getItem('fs_token'); if (!token || !game) return; const payload = buildReplayPayload(s); 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; }, 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(snap ?? game.state()); }, runAutoplay(options) { return runDebugAutoplay(options); }, }; // ── Start ───────────────────────────────────────────────────────────────────── bootstrap().catch(console.error); loadAvatar();