diff --git a/solitaire_server/src/lib.rs b/solitaire_server/src/lib.rs
index 22a50a8..1eac740 100644
--- a/solitaire_server/src/lib.rs
+++ b/solitaire_server/src/lib.rs
@@ -205,6 +205,10 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
"/replays/{id}",
get(|| async { Html(include_str!("../web/index.html")) }),
)
+ .route(
+ "/play",
+ get(|| async { Html(include_str!("../web/game.html")) }),
+ )
.nest_service("/web", ServeDir::new("solitaire_server/web"));
Router::new()
diff --git a/solitaire_server/web/game.css b/solitaire_server/web/game.css
new file mode 100644
index 0000000..964065b
--- /dev/null
+++ b/solitaire_server/web/game.css
@@ -0,0 +1,239 @@
+/* Solitaire Quest — interactive game page.
+ Palette and card styles mirror replay.css; adds drag, selection,
+ HUD, and win-overlay layers. */
+
+:root {
+ --bg: #0f0a1f;
+ --felt: #0f4c30;
+ --panel: #1a0f2e;
+ --panel-hi: #2d1b69;
+ --text: #f5f0ff;
+ --text-muted: #b5a8d5;
+ --accent: #ffd23f;
+ --red: #cc3344;
+ --black: #1a0f2e;
+ --card-bg: #ffffff;
+ --card-border: #ccc;
+ --card-w: 80px;
+ --card-h: 112px;
+ --gap: 12px;
+ --fan: 28px;
+}
+
+* { box-sizing: border-box; margin: 0; padding: 0; }
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ user-select: none;
+}
+
+/* ── Header / HUD ────────────────────────────────────────────────────── */
+
+header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 20px;
+ border-bottom: 1px solid rgba(255,255,255,0.07);
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.hud-left { display: flex; align-items: center; gap: 12px; }
+.hud-center { display: flex; gap: 20px; font-size: 14px; font-weight: 600; }
+.hud-right { display: flex; align-items: center; gap: 10px; }
+
+.logo { font-size: 16px; font-weight: 700; }
+.muted { color: var(--text-muted); font-size: 12px; }
+
+button {
+ background: var(--panel-hi);
+ color: var(--text);
+ border: 1px solid rgba(255,255,255,0.12);
+ border-radius: 6px;
+ padding: 6px 14px;
+ cursor: pointer;
+ font-size: 13px;
+ font-family: inherit;
+ transition: background 120ms;
+}
+button:hover { background: var(--accent); color: var(--black); }
+button:disabled { opacity: 0.4; cursor: default; }
+
+.toggle-label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 13px;
+ cursor: pointer;
+ color: var(--text-muted);
+}
+.toggle-label:hover { color: var(--text); }
+
+/* ── Board ───────────────────────────────────────────────────────────── */
+
+main {
+ flex: 1;
+ display: flex;
+ justify-content: center;
+ align-items: flex-start;
+ padding: 20px;
+}
+
+#board {
+ position: relative;
+ background: var(--felt);
+ border-radius: 12px;
+ padding: 20px;
+ /* 7 columns wide */
+ width: calc(7 * var(--card-w) + 6 * var(--gap) + 40px);
+ /* top row + generous fan budget for a 13-card column */
+ height: calc(var(--card-h) + 28px + var(--card-h) + 12 * var(--fan) + 40px);
+ cursor: default;
+}
+
+/* Empty-pile slot markers */
+.slot {
+ position: absolute;
+ width: var(--card-w);
+ height: var(--card-h);
+ border: 2px dashed rgba(255,255,255,0.15);
+ border-radius: 8px;
+}
+
+.slot.drop-active {
+ border-color: var(--accent);
+ background: rgba(255, 210, 63, 0.08);
+}
+
+/* ── Cards ───────────────────────────────────────────────────────────── */
+
+.card {
+ position: absolute;
+ top: 0; left: 0;
+ width: var(--card-w);
+ height: var(--card-h);
+ background: var(--card-bg);
+ border: 1px solid var(--card-border);
+ border-radius: 6px;
+ box-shadow: 0 2px 5px rgba(0,0,0,0.35);
+ padding: 4px 6px;
+ font-weight: 600;
+ line-height: 1;
+ cursor: grab;
+ transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1),
+ opacity 200ms ease,
+ box-shadow 120ms ease;
+ will-change: transform;
+}
+
+.card:active { cursor: grabbing; }
+
+.card.face-down {
+ background:
+ repeating-linear-gradient(
+ 45deg,
+ #482f97 0, #482f97 6px,
+ #2d1b69 6px, #2d1b69 12px
+ );
+ color: transparent;
+ border-color: #4a3a8a;
+ cursor: default;
+}
+
+.card .corner {
+ position: absolute;
+ font-size: 14px;
+ line-height: 1.1;
+ text-align: center;
+}
+.card .corner.top { top: 4px; left: 6px; }
+.card .corner.bottom { bottom: 4px; right: 6px; transform: rotate(180deg); }
+
+.card.red { color: var(--red); }
+.card.black { color: var(--black); }
+
+.card .center {
+ position: absolute;
+ top: 50%; left: 50%;
+ transform: translate(-50%, -50%);
+ font-size: 28px;
+}
+
+/* Stock pile: pointer cursor since it's a click target, not draggable */
+.card.stock-card { cursor: pointer; }
+
+/* Selected / being-dragged state */
+.card.selected {
+ box-shadow: 0 0 0 2px var(--accent), 0 4px 12px rgba(0,0,0,0.5);
+ z-index: 200;
+ transition: none; /* snap instantly while dragging */
+}
+
+/* Drop-target highlight on cards (top of a tableau column) */
+.card.drop-target {
+ box-shadow: 0 0 0 2px var(--accent), 0 4px 12px rgba(0,0,0,0.5);
+}
+
+/* Recycle indicator on empty stock */
+.recycle-label {
+ position: absolute;
+ top: 50%; left: 50%;
+ transform: translate(-50%, -50%);
+ font-size: 26px;
+ color: rgba(255,255,255,0.3);
+ pointer-events: none;
+}
+
+/* ── Win overlay ─────────────────────────────────────────────────────── */
+
+#win-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(10, 5, 20, 0.75);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+}
+
+#win-overlay.hidden { display: none; }
+
+.win-card {
+ background: var(--panel);
+ border: 1px solid rgba(255,255,255,0.12);
+ border-radius: 16px;
+ padding: 40px 48px;
+ text-align: center;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ box-shadow: 0 20px 60px rgba(0,0,0,0.6);
+}
+
+.win-title {
+ font-size: 36px;
+ font-weight: 700;
+ color: var(--accent);
+}
+
+.win-score {
+ font-size: 24px;
+ font-weight: 600;
+}
+
+.win-detail {
+ font-size: 14px;
+ color: var(--text-muted);
+}
+
+#btn-win-new {
+ margin-top: 8px;
+ padding: 12px 32px;
+ font-size: 16px;
+}
diff --git a/solitaire_server/web/game.html b/solitaire_server/web/game.html
new file mode 100644
index 0000000..1914594
--- /dev/null
+++ b/solitaire_server/web/game.html
@@ -0,0 +1,43 @@
+
+
+
+
+
+ Solitaire Quest — Play
+
+
+
+
+
+
+
+
+
+
+
+
You Won!
+
+
+
+
+
+
+
+
+
diff --git a/solitaire_server/web/game.js b/solitaire_server/web/game.js
new file mode 100644
index 0000000..b82f9c7
--- /dev/null
+++ b/solitaire_server/web/game.js
@@ -0,0 +1,569 @@
+// Solitaire Quest — 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) ──────
+const CARD_W = 80;
+const CARD_H = 112;
+const GAP = 12;
+const PAD = 20; // board padding
+const FAN = 28; // vertical offset per fanned tableau card
+const WASTE_FAN = 18; // horizontal offset for draw-3 waste fan
+
+// Top-row Y origin (relative to board interior = after padding).
+const TOP_Y = 0;
+const BOTTOM_Y = CARD_H + 28; // tableau row
+
+const colX = (c) => c * (CARD_W + GAP);
+
+// Absolute position of each pile's origin (top-left of its slot),
+// relative to the board's padded interior (0, 0).
+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 },
+};
+
+const SUIT_GLYPH = { clubs: "♣", diamonds: "♦", hearts: "♥", spades: "♠" };
+const RANK_LABELS = ["","A","2","3","4","5","6","7","8","9","10","J","Q","K"];
+const RED_SUITS = new Set(["diamonds", "hearts"]);
+
+// ── State ────────────────────────────────────────────────────────────────────
+let game = null;
+let snap = null; // last rendered GameSnapshot
+let drawThree = false;
+
+// Persistent card → DOM element map (keyed by card.id).
+const cardEls = new Map();
+
+// Drag state
+let drag = null;
+// drag = {
+// fromPile: string,
+// fromIndex: number, // index of bottom card of the dragged run in its pile
+// cardIds: number[], // ids of cards being dragged (bottom → top)
+// startX: number, startY: number, // pointer start (board-relative)
+// offsetX: number, offsetY: number, // cursor offset within the grabbed card
+// }
+
+// Auto-complete timer handle
+let acTimer = null;
+
+// ── DOM refs ─────────────────────────────────────────────────────────────────
+const board = document.getElementById("board");
+const hudScore = document.getElementById("hud-score");
+const hudMoves = document.getElementById("hud-moves");
+const hudSeed = document.getElementById("hud-seed");
+const btnUndo = document.getElementById("btn-undo");
+const btnNew = document.getElementById("btn-new");
+const chkDraw3 = document.getElementById("chk-draw3");
+const winOverlay = document.getElementById("win-overlay");
+const winScore = document.getElementById("win-score");
+const winMoves = document.getElementById("win-moves");
+const btnWinNew = document.getElementById("btn-win-new");
+
+// ── Bootstrap ────────────────────────────────────────────────────────────────
+async function bootstrap() {
+ await init();
+
+ // Seed from URL param ?seed=N, otherwise random.
+ const params = new URLSearchParams(window.location.search);
+ const urlSeed = params.has("seed") ? Number(params.get("seed")) : randomSeed();
+ drawThree = params.has("draw3");
+ chkDraw3.checked = drawThree;
+
+ buildSlots();
+ startGame(urlSeed);
+ attachHandlers();
+}
+
+function randomSeed() {
+ // Math.random gives a float in [0,1); multiply to get a large integer.
+ return Math.floor(Math.random() * 9007199254740991);
+}
+
+function startGame(seed) {
+ if (acTimer) { clearInterval(acTimer); acTimer = null; }
+ game = new SolitaireGame(seed, drawThree);
+ snap = game.state();
+ hudSeed.textContent = `seed ${Math.round(game.seed())}`;
+ winOverlay.classList.add("hidden");
+ cardEls.clear();
+ board.querySelectorAll(".card").forEach(el => el.remove());
+ render(snap);
+}
+
+// ── 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)`;
+ board.appendChild(el);
+ }
+}
+
+// ── Card position math ────────────────────────────────────────────────────────
+function cardPos(pileName, indexInPile, pileLength, pileCards) {
+ const origin = PILE_ORIGIN[pileName];
+ let x = origin.x;
+ let y = origin.y;
+
+ if (pileName === "waste" && drawThree && pileLength >= 2) {
+ // Show top-3 of waste fanned horizontally.
+ const fanStart = Math.max(0, pileLength - 3);
+ const fanPos = indexInPile - fanStart;
+ if (fanPos >= 0) {
+ x += fanPos * WASTE_FAN;
+ } else {
+ // Cards below the fan window are stacked at origin.
+ }
+ } else if (pileName.startsWith("tableau-")) {
+ y += indexInPile * FAN;
+ }
+ // Stock, foundations: stack (no offset).
+ return { x, y };
+}
+
+// Z-index: higher index in pile = drawn on top.
+function cardZ(pileName, indexInPile, total) {
+ if (pileName === "stock") return 10 + indexInPile;
+ if (pileName === "waste") return 10 + indexInPile;
+ if (pileName.startsWith("found")) return 10 + indexInPile;
+ return 10 + indexInPile;
+}
+
+// ── Renderer ─────────────────────────────────────────────────────────────────
+function render(s) {
+ snap = s;
+
+ // Update HUD
+ hudScore.textContent = `Score: ${s.score}`;
+ hudMoves.textContent = `Moves: ${s.move_count}`;
+ btnUndo.disabled = s.move_count === 0;
+
+ // Collect all cards visible in this snapshot, keyed by id → {pile, idx}.
+ const visible = new Map();
+ const addPile = (pileName, cards) => {
+ cards.forEach((c, i) => visible.set(c.id, { pile: pileName, 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));
+
+ // Create or update card elements.
+ for (const [id, info] of visible) {
+ let el = cardEls.get(id);
+ if (!el) {
+ el = createCardEl(info.card);
+ cardEls.set(id, el);
+ board.appendChild(el);
+ }
+
+ updateCardEl(el, info.card, info.pile, info.idx, info.total, s);
+ }
+
+ // Remove cards no longer in the snapshot (shouldn't happen in solitaire
+ // but guards against stale state after undo).
+ for (const [id, el] of cardEls) {
+ if (!visible.has(id)) {
+ el.remove();
+ cardEls.delete(id);
+ }
+ }
+
+ // Update slot drop-active highlights (cleared on every render).
+ board.querySelectorAll(".slot.drop-active").forEach(el => el.classList.remove("drop-active"));
+ board.querySelectorAll(".card.drop-target").forEach(el => el.classList.remove("drop-target"));
+
+ // Show 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.left = `${o.x + CARD_W / 2}px`;
+ recycleEl.style.top = `${o.y + CARD_H / 2}px`;
+ } else if (recycleEl) {
+ recycleEl.remove();
+ }
+
+ // Trigger auto-complete if applicable.
+ if (s.is_auto_completable && !s.is_won && !acTimer) {
+ acTimer = setInterval(doAutoCompleteStep, 400);
+ }
+ if (s.is_won) {
+ if (acTimer) { clearInterval(acTimer); acTimer = null; }
+ showWin(s);
+ }
+}
+
+function createCardEl(card) {
+ const el = document.createElement("div");
+ el.dataset.cardId = card.id;
+ return el;
+}
+
+function updateCardEl(el, card, pileName, idx, total, s) {
+ const pos = cardPos(pileName, idx, total, null);
+ const z = cardZ(pileName, idx, total);
+
+ el.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
+ el.style.zIndex = z;
+
+ const isTop = idx === total - 1;
+
+ if (!card.face_up) {
+ el.className = "card face-down";
+ if (pileName === "stock") el.classList.add("stock-card");
+ el.innerHTML = "";
+ } else {
+ const isRed = RED_SUITS.has(card.suit);
+ el.className = `card ${isRed ? "red" : "black"}`;
+ if (pileName === "stock") el.classList.add("stock-card");
+
+ const rankLabel = RANK_LABELS[card.rank];
+ const suit = SUIT_GLYPH[card.suit];
+ el.innerHTML = `
+ ${rankLabel}
${suit}
+ ${suit}
+ ${rankLabel}
${suit}
`;
+ }
+}
+
+// ── Win overlay ───────────────────────────────────────────────────────────────
+function showWin(s) {
+ winScore.textContent = `Score: ${s.score}`;
+ winMoves.textContent = `${s.move_count} moves`;
+ winOverlay.classList.remove("hidden");
+}
+
+// ── Auto-complete ─────────────────────────────────────────────────────────────
+function doAutoCompleteStep() {
+ if (!game || !snap || !snap.is_auto_completable) {
+ clearInterval(acTimer);
+ acTimer = null;
+ return;
+ }
+ const result = game.auto_complete_step();
+ if (result && result.ok) {
+ render(result.snapshot);
+ } else {
+ clearInterval(acTimer);
+ acTimer = null;
+ }
+}
+
+// ── Input handling ────────────────────────────────────────────────────────────
+function attachHandlers() {
+ // Buttons
+ btnUndo.addEventListener("click", () => {
+ const r = game.undo();
+ if (r.ok) render(r.snapshot);
+ });
+ btnNew.addEventListener("click", () => startGame(randomSeed()));
+ btnWinNew.addEventListener("click", () => startGame(randomSeed()));
+ chkDraw3.addEventListener("change", () => {
+ drawThree = chkDraw3.checked;
+ startGame(randomSeed());
+ });
+
+ // Keyboard shortcuts
+ document.addEventListener("keydown", (e) => {
+ if (e.key === "z" || e.key === "Z") {
+ const r = game.undo();
+ if (r.ok) render(r.snapshot);
+ }
+ if (e.key === "n" || e.key === "N") {
+ startGame(randomSeed());
+ }
+ });
+
+ // Board pointer events (handles both mouse and touch via PointerEvents API)
+ board.addEventListener("pointerdown", onPointerDown);
+ board.addEventListener("pointermove", onPointerMove);
+ board.addEventListener("pointerup", onPointerUp);
+ board.addEventListener("pointercancel", onPointerCancel);
+ board.addEventListener("click", onBoardClick);
+ board.addEventListener("dblclick", onBoardDblClick);
+}
+
+// ── Coordinate helpers ────────────────────────────────────────────────────────
+function boardRelative(clientX, clientY) {
+ const rect = board.getBoundingClientRect();
+ // Subtract board padding to get interior coordinates.
+ return {
+ x: clientX - rect.left - PAD,
+ y: clientY - rect.top - PAD,
+ };
+}
+
+function hitTestCard(bx, by) {
+ // Walk all visible piles, find the topmost card at (bx, by).
+ // Returns { pileName, cardIndex, cardId } or null.
+ 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;
+ let 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, cards);
+ if (bx >= pos.x && bx <= pos.x + CARD_W &&
+ by >= pos.y && by <= pos.y + CARD_H) {
+ const z = cardZ(pileName, i, cards.length);
+ if (z > bestZ) {
+ bestZ = z;
+ best = { pileName, cardIndex: i, cardId: cards[i].id, 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-")) {
+ const slot = parseInt(pileName.split("-")[1]);
+ return snap.foundations[slot];
+ }
+ if (pileName.startsWith("tableau-")) {
+ const col = parseInt(pileName.split("-")[1]);
+ return snap.tableaus[col];
+ }
+ return null;
+}
+
+function hitTestSlot(bx, by) {
+ // Returns the pile name whose slot rect contains (bx, by), favouring
+ // tableau column slots over top-row slots when both overlap.
+ for (const [pile, origin] of Object.entries(PILE_ORIGIN)) {
+ if (bx >= origin.x && bx <= origin.x + CARD_W &&
+ by >= origin.y && by <= origin.y + CARD_H) {
+ return pile;
+ }
+ }
+ return null;
+}
+
+// For a tableau pile, hit-test for drop: the drop zone extends to the last
+// card's bottom edge (or the slot if empty).
+function findDropTarget(bx, by) {
+ // Check tableau columns first (they have tall hit areas).
+ for (let c = 0; c < 7; c++) {
+ const pile = `tableau-${c}`;
+ const cards = snap.tableaus[c];
+ const origin = PILE_ORIGIN[pile];
+ // Top boundary: origin.y. Bottom boundary: last card bottom or empty slot.
+ 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;
+ }
+ }
+ // Foundation slots (top row).
+ 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 event handlers ────────────────────────────────────────────────────
+function onPointerDown(e) {
+ if (e.button !== 0 && e.pointerType === "mouse") return;
+ if (drag) return; // ignore second finger
+
+ const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
+ const hit = hitTestCard(bx, by);
+ if (!hit) return;
+ if (!hit.card.face_up) return; // can't drag face-down cards
+
+ const cards = getPileCards(hit.pileName);
+ if (!cards) return;
+
+ // For tableau, allow dragging a run from any face-up card.
+ // For waste/foundation, only the top card.
+ let fromIndex = hit.cardIndex;
+ if (!hit.pileName.startsWith("tableau-")) {
+ fromIndex = cards.length - 1; // only top card
+ if (hit.cardIndex !== fromIndex) return;
+ }
+
+ const draggedCards = cards.slice(fromIndex);
+ if (draggedCards.some(c => !c.face_up)) return; // face-down in run — blocked
+
+ const cardOriginPos = cardPos(hit.pileName, fromIndex, cards.length, cards);
+
+ drag = {
+ fromPile: hit.pileName,
+ fromIndex,
+ cardIds: draggedCards.map(c => c.id),
+ startX: bx,
+ startY: by,
+ offsetX: bx - cardOriginPos.x,
+ offsetY: by - cardOriginPos.y,
+ };
+
+ // Lift the dragged cards visually.
+ 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 basePos = cardPos(drag.fromPile, drag.fromIndex + i, (cards ? cards.length : 1), null);
+ el.style.transform = `translate(${basePos.x + dx}px, ${basePos.y + dy}px)`;
+ });
+
+ // Highlight drop target.
+ board.querySelectorAll(".slot.drop-active").forEach(el => el.classList.remove("drop-active"));
+ board.querySelectorAll(".card.drop-target").forEach(el => el.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 && targetCards.length > 0) {
+ const topCard = cardEls.get(targetCards[targetCards.length - 1].id);
+ if (topCard) topCard.classList.add("drop-target");
+ }
+ }
+
+ e.preventDefault();
+}
+
+function onPointerUp(e) {
+ if (!drag) return;
+ board.querySelectorAll(".slot.drop-active").forEach(el => el.classList.remove("drop-active"));
+ board.querySelectorAll(".card.drop-target").forEach(el => el.classList.remove("drop-target"));
+
+ const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
+ const targetPile = findDropTarget(bx, by);
+
+ let moved = false;
+ if (targetPile && targetPile !== drag.fromPile) {
+ const count = drag.cardIds.length;
+ const r = game.move_cards(drag.fromPile, targetPile, count);
+ if (r.ok) {
+ render(r.snapshot);
+ moved = true;
+ }
+ }
+
+ if (!moved) {
+ // Snap cards back to their original positions.
+ drag.cardIds.forEach(id => {
+ const el = cardEls.get(id);
+ if (el) el.classList.remove("selected");
+ });
+ render(snap); // re-render restores transforms
+ }
+
+ drag = null;
+}
+
+function onPointerCancel() {
+ if (drag) {
+ drag.cardIds.forEach(id => {
+ const el = cardEls.get(id);
+ if (el) el.classList.remove("selected");
+ });
+ render(snap);
+ drag = null;
+ }
+}
+
+// ── Click handlers ────────────────────────────────────────────────────────────
+function onBoardClick(e) {
+ if (drag) return; // swallowed by pointer-up
+
+ const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
+
+ // Stock click → draw.
+ const stockOrigin = PILE_ORIGIN.stock;
+ if (bx >= stockOrigin.x && bx <= stockOrigin.x + CARD_W &&
+ by >= stockOrigin.y && by <= stockOrigin.y + CARD_H) {
+ const r = game.draw();
+ if (r.ok) render(r.snapshot);
+ return;
+ }
+}
+
+function onBoardDblClick(e) {
+ const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
+ const hit = hitTestCard(bx, by);
+ if (!hit || !hit.card.face_up) return;
+
+ // Only try to move the top card of its pile.
+ const cards = getPileCards(hit.pileName);
+ if (!cards || hit.cardIndex !== cards.length - 1) return;
+
+ // Try each foundation slot.
+ for (let s = 0; s < 4; s++) {
+ const r = game.move_cards(hit.pileName, `foundation-${s}`, 1);
+ if (r.ok) { render(r.snapshot); return; }
+ }
+}
+
+// ── Start ─────────────────────────────────────────────────────────────────────
+bootstrap().catch(console.error);
diff --git a/solitaire_server/web/pkg/solitaire_wasm.js b/solitaire_server/web/pkg/solitaire_wasm.js
index 7f477ce..cbf30e5 100644
--- a/solitaire_server/web/pkg/solitaire_wasm.js
+++ b/solitaire_server/web/pkg/solitaire_wasm.js
@@ -71,6 +71,103 @@ export class ReplayPlayer {
}
}
if (Symbol.dispose) ReplayPlayer.prototype[Symbol.dispose] = ReplayPlayer.prototype.free;
+
+/**
+ * Interactive Klondike game backed by the real `solitaire_core` rules engine.
+ *
+ * Construct with `new(seed, draw_three)`, then call `draw()`, `move_cards()`,
+ * `undo()`, `auto_complete_step()` to advance the game. `state()` returns the
+ * full pile snapshot at any time without mutating state.
+ */
+export class SolitaireGame {
+ __destroy_into_raw() {
+ const ptr = this.__wbg_ptr;
+ this.__wbg_ptr = 0;
+ SolitaireGameFinalization.unregister(this);
+ return ptr;
+ }
+ free() {
+ const ptr = this.__destroy_into_raw();
+ wasm.__wbg_solitairegame_free(ptr, 0);
+ }
+ /**
+ * Apply one auto-complete move (only valid when `is_auto_completable`).
+ * Returns the post-move snapshot or `null` when auto-complete is unavailable.
+ * @returns {any}
+ */
+ auto_complete_step() {
+ const ret = wasm.solitairegame_auto_complete_step(this.__wbg_ptr);
+ return ret;
+ }
+ /**
+ * Draw from stock to waste (or recycle waste → stock when stock is empty).
+ * Returns `{ok, error?, snapshot?}`.
+ * @returns {any}
+ */
+ draw() {
+ const ret = wasm.solitairegame_draw(this.__wbg_ptr);
+ return ret;
+ }
+ /**
+ * Move `count` cards from pile `from` to pile `to`.
+ *
+ * Pile names: `"stock"`, `"waste"`, `"foundation-0"` .. `"foundation-3"`,
+ * `"tableau-0"` .. `"tableau-6"`.
+ *
+ * Returns `{ok, error?, snapshot?}`.
+ * @param {string} from
+ * @param {string} to
+ * @param {number} count
+ * @returns {any}
+ */
+ move_cards(from, to, count) {
+ const ptr0 = passStringToWasm0(from, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
+ const len0 = WASM_VECTOR_LEN;
+ const ptr1 = passStringToWasm0(to, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
+ const len1 = WASM_VECTOR_LEN;
+ const ret = wasm.solitairegame_move_cards(this.__wbg_ptr, ptr0, len0, ptr1, len1, count);
+ return ret;
+ }
+ /**
+ * Create a new DrawOne or DrawThree Classic game from the given seed.
+ *
+ * `seed` is a JS `number` (f64); values up to 2^53 are represented exactly.
+ * Pass `Date.now()` or a random integer from JS for variety.
+ * @param {number} seed
+ * @param {boolean} draw_three
+ */
+ constructor(seed, draw_three) {
+ const ret = wasm.solitairegame_new(seed, draw_three);
+ this.__wbg_ptr = ret;
+ SolitaireGameFinalization.register(this, this.__wbg_ptr, this);
+ return this;
+ }
+ /**
+ * The seed used to deal this game.
+ * @returns {number}
+ */
+ seed() {
+ const ret = wasm.solitairegame_seed(this.__wbg_ptr);
+ return ret;
+ }
+ /**
+ * Full pile snapshot as a JS object.
+ * @returns {any}
+ */
+ state() {
+ const ret = wasm.solitairegame_state(this.__wbg_ptr);
+ return ret;
+ }
+ /**
+ * Undo the last move. Returns `{ok, error?, snapshot?}`.
+ * @returns {any}
+ */
+ undo() {
+ const ret = wasm.solitairegame_undo(this.__wbg_ptr);
+ return ret;
+ }
+}
+if (Symbol.dispose) SolitaireGame.prototype[Symbol.dispose] = SolitaireGame.prototype.free;
function __wbg_get_imports() {
const import0 = {
__proto__: null,
@@ -151,6 +248,9 @@ function __wbg_get_imports() {
const ReplayPlayerFinalization = (typeof FinalizationRegistry === 'undefined')
? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry(ptr => wasm.__wbg_replayplayer_free(ptr, 1));
+const SolitaireGameFinalization = (typeof FinalizationRegistry === 'undefined')
+ ? { register: () => {}, unregister: () => {} }
+ : new FinalizationRegistry(ptr => wasm.__wbg_solitairegame_free(ptr, 1));
let cachedDataViewMemory0 = null;
function getDataViewMemory0() {
diff --git a/solitaire_server/web/pkg/solitaire_wasm_bg.wasm b/solitaire_server/web/pkg/solitaire_wasm_bg.wasm
index a03c60d..7ddcbff 100644
Binary files a/solitaire_server/web/pkg/solitaire_wasm_bg.wasm and b/solitaire_server/web/pkg/solitaire_wasm_bg.wasm differ
diff --git a/solitaire_wasm/src/lib.rs b/solitaire_wasm/src/lib.rs
index 1c27d0c..0fb7ffd 100644
--- a/solitaire_wasm/src/lib.rs
+++ b/solitaire_wasm/src/lib.rs
@@ -1,4 +1,4 @@
-//! WebAssembly bindings for browser-side replay playback.
+//! WebAssembly bindings for browser-side replay playback and interactive gameplay.
//!
//! The web replay player at `/replays/` fetches a [`Replay`]
//! JSON via `GET /api/replays/:id`, hands it to [`ReplayPlayer::new`],
@@ -222,6 +222,208 @@ impl ReplayPlayer {
}
}
+// ---------------------------------------------------------------------------
+// Interactive game surface
+// ---------------------------------------------------------------------------
+
+/// Full snapshot of a live `SolitaireGame` for the JS renderer.
+#[derive(Debug, Clone, Serialize)]
+pub struct GameSnapshot {
+ pub score: i32,
+ pub move_count: u32,
+ pub is_won: bool,
+ pub is_auto_completable: bool,
+ pub undo_count: u32,
+ pub stock: Vec,
+ pub waste: Vec,
+ pub foundations: [Vec; 4],
+ pub tableaus: [Vec; 7],
+}
+
+/// Result returned to JS from every mutating game action.
+#[derive(Debug, Clone, Serialize)]
+pub struct ActionResult {
+ pub ok: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub error: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub snapshot: Option,
+}
+
+/// Interactive Klondike game backed by the real `solitaire_core` rules engine.
+///
+/// Construct with `new(seed, draw_three)`, then call `draw()`, `move_cards()`,
+/// `undo()`, `auto_complete_step()` to advance the game. `state()` returns the
+/// full pile snapshot at any time without mutating state.
+#[wasm_bindgen]
+pub struct SolitaireGame {
+ game: GameState,
+}
+
+impl SolitaireGame {
+ fn snap(&self) -> GameSnapshot {
+ let cards = |t: PileType| -> Vec {
+ self.game
+ .piles
+ .get(&t)
+ .map(|p| p.cards.iter().map(CardSnapshot::from).collect())
+ .unwrap_or_default()
+ };
+ GameSnapshot {
+ score: self.game.score,
+ move_count: self.game.move_count,
+ is_won: self.game.is_won,
+ is_auto_completable: self.game.is_auto_completable,
+ undo_count: self.game.undo_count,
+ stock: cards(PileType::Stock),
+ waste: cards(PileType::Waste),
+ foundations: [
+ cards(PileType::Foundation(0)),
+ cards(PileType::Foundation(1)),
+ cards(PileType::Foundation(2)),
+ cards(PileType::Foundation(3)),
+ ],
+ tableaus: [
+ cards(PileType::Tableau(0)),
+ cards(PileType::Tableau(1)),
+ cards(PileType::Tableau(2)),
+ cards(PileType::Tableau(3)),
+ cards(PileType::Tableau(4)),
+ cards(PileType::Tableau(5)),
+ cards(PileType::Tableau(6)),
+ ],
+ }
+ }
+
+ fn pile_from_str(s: &str) -> Result {
+ match s {
+ "stock" => Ok(PileType::Stock),
+ "waste" => Ok(PileType::Waste),
+ _ if s.starts_with("foundation-") => {
+ let slot: u8 = s["foundation-".len()..]
+ .parse()
+ .map_err(|_| format!("bad pile: {s}"))?;
+ if slot >= 4 {
+ return Err(format!("foundation slot out of range: {slot}"));
+ }
+ Ok(PileType::Foundation(slot))
+ }
+ _ if s.starts_with("tableau-") => {
+ let col: usize = s["tableau-".len()..]
+ .parse()
+ .map_err(|_| format!("bad pile: {s}"))?;
+ if col >= 7 {
+ return Err(format!("tableau col out of range: {col}"));
+ }
+ Ok(PileType::Tableau(col))
+ }
+ _ => Err(format!("unknown pile: {s}")),
+ }
+ }
+
+ fn ok_js(&self) -> JsValue {
+ serde_wasm_bindgen::to_value(&ActionResult {
+ ok: true,
+ error: None,
+ snapshot: Some(self.snap()),
+ })
+ .unwrap_or(JsValue::NULL)
+ }
+
+ fn err_js(msg: impl std::fmt::Display) -> JsValue {
+ serde_wasm_bindgen::to_value(&ActionResult {
+ ok: false,
+ error: Some(msg.to_string()),
+ snapshot: None,
+ })
+ .unwrap_or(JsValue::NULL)
+ }
+}
+
+#[wasm_bindgen]
+impl SolitaireGame {
+ /// Create a new DrawOne or DrawThree Classic game from the given seed.
+ ///
+ /// `seed` is a JS `number` (f64); values up to 2^53 are represented exactly.
+ /// Pass `Date.now()` or a random integer from JS for variety.
+ #[wasm_bindgen(constructor)]
+ pub fn new(seed: f64, draw_three: bool) -> SolitaireGame {
+ #[cfg(feature = "console_error_panic_hook")]
+ console_error_panic_hook::set_once();
+ let dm = if draw_three {
+ DrawMode::DrawThree
+ } else {
+ DrawMode::DrawOne
+ };
+ SolitaireGame {
+ game: GameState::new_with_mode(seed as u64, dm, GameMode::Classic),
+ }
+ }
+
+ /// Full pile snapshot as a JS object.
+ pub fn state(&self) -> JsValue {
+ serde_wasm_bindgen::to_value(&self.snap()).unwrap_or(JsValue::NULL)
+ }
+
+ /// The seed used to deal this game.
+ pub fn seed(&self) -> f64 {
+ self.game.seed as f64
+ }
+
+ /// Draw from stock to waste (or recycle waste → stock when stock is empty).
+ /// Returns `{ok, error?, snapshot?}`.
+ pub fn draw(&mut self) -> JsValue {
+ match self.game.draw() {
+ Ok(()) => self.ok_js(),
+ Err(e) => Self::err_js(e),
+ }
+ }
+
+ /// Move `count` cards from pile `from` to pile `to`.
+ ///
+ /// Pile names: `"stock"`, `"waste"`, `"foundation-0"` .. `"foundation-3"`,
+ /// `"tableau-0"` .. `"tableau-6"`.
+ ///
+ /// Returns `{ok, error?, snapshot?}`.
+ pub fn move_cards(&mut self, from: &str, to: &str, count: usize) -> JsValue {
+ let from_pile = match Self::pile_from_str(from) {
+ Ok(p) => p,
+ Err(e) => return Self::err_js(e),
+ };
+ let to_pile = match Self::pile_from_str(to) {
+ Ok(p) => p,
+ Err(e) => return Self::err_js(e),
+ };
+ match self.game.move_cards(from_pile, to_pile, count) {
+ Ok(()) => self.ok_js(),
+ Err(e) => Self::err_js(e),
+ }
+ }
+
+ /// Undo the last move. Returns `{ok, error?, snapshot?}`.
+ pub fn undo(&mut self) -> JsValue {
+ match self.game.undo() {
+ Ok(()) => self.ok_js(),
+ Err(e) => Self::err_js(e),
+ }
+ }
+
+ /// Apply one auto-complete move (only valid when `is_auto_completable`).
+ /// Returns the post-move snapshot or `null` when auto-complete is unavailable.
+ pub fn auto_complete_step(&mut self) -> JsValue {
+ if !self.game.is_auto_completable {
+ return JsValue::NULL;
+ }
+ match self.game.next_auto_complete_move() {
+ Some((from, to)) => {
+ let _ = self.game.move_cards(from, to, 1);
+ self.ok_js()
+ }
+ None => JsValue::NULL,
+ }
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;