Files
Ferrous-Solitaire/solitaire_server/web/game.js
T
funman300 d5d869a6c8
Build and Deploy / build-and-push (push) Successful in 4m12s
fix(multi): resolve 16 bugs from comprehensive rules and code review
Core (solitaire_core):
- fix(core): auto-complete now requires waste empty to prevent deadlock
- fix(core): reject multi-card moves from waste pile (Klondike rule)
- fix(core): reject foundation-to-foundation moves (score farming exploit)
- fix(core): undo restores score from snapshot baseline, not live score
- feat(scoring): add +5 flip bonus when face-down tableau card is exposed
- feat(scoring): add recycle penalty (Draw-1: -100/pass, Draw-3: -20/pass)

Engine (solitaire_engine):
- fix(engine): remove TokioRuntimeResource::default() panic; degrade gracefully
- fix(engine): add ModalScrim guard to handle_new_game spawn site
- fix(engine): add ModalScrim guard to spawn_restore_prompt spawn site
- fix(engine): add ModalScrim guard to check_no_moves spawn site

Server / Web (solitaire_server):
- fix(web): correct draw_mode casing in replay submission (DrawOne/DrawThree)
- fix(web): correct mode casing in replay submission (Classic) for leaderboard
- fix(web): trim recorded_at to YYYY-MM-DD for NaiveDate deserialization
- fix(server): move /avatars route outside auth middleware (was always 401)

Data / Sync (solitaire_data, solitaire_sync):
- fix(data): namespace Android token file under APP_DIR_NAME with migration
- fix(data): Android token store now multi-user (HashMap); no silent overwrite
- fix(sync): draw_one_wins + draw_three_wins invariant preserved after merge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:27:09 -07:00

782 lines
30 KiB
JavaScript
Raw 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");
// ── 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 urlSeed = params.has("seed") ? Number(params.get("seed")) : 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; }
showWin(s);
} else {
saveState();
}
}
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);
}
async function submitReplay(s) {
const token = localStorage.getItem('fs_token');
if (!token) return;
const payload = {
schema_version: 1,
seed: Math.round(game.seed()),
draw_mode: drawThree ? "DrawThree" : "DrawOne",
mode: "Classic",
time_seconds: elapsedSecs,
final_score: s.score,
move_count: s.move_count,
recorded_at: new Date().toISOString().slice(0, 10),
moves: [],
};
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;
// Store current translate so the shake keyframe can reference it.
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()));
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());
});
document.addEventListener("keydown", (e) => {
if (e.target.tagName === "INPUT") return;
if (e.key === "z" || e.key === "Z") doUndo();
if (e.key === "n" || e.key === "N") startGame(randomSeed());
});
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) 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) 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 */ }
}
// ── Start ─────────────────────────────────────────────────────────────────────
bootstrap().catch(console.error);
loadAvatar();