d5d869a6c8
Build and Deploy / build-and-push (push) Successful in 4m12s
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>
782 lines
30 KiB
JavaScript
782 lines
30 KiB
JavaScript
// 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();
|