diff --git a/solitaire_engine/src/game_plugin.rs b/solitaire_engine/src/game_plugin.rs index f3101e7..fdb5fce 100644 --- a/solitaire_engine/src/game_plugin.rs +++ b/solitaire_engine/src/game_plugin.rs @@ -1045,9 +1045,7 @@ pub fn record_replay_on_win( /// previous heuristic incorrectly did (Quat hit this with 4 cards /// remaining and the game just sat there). pub fn has_legal_moves(game: &GameState) -> bool { - use solitaire_core::card::Card; use solitaire_core::pile::PileType; - use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau}; // Drawing from a non-empty stock, and recycling a non-empty waste back to // stock, are always legal moves in standard Klondike (unlimited recycles). @@ -1058,40 +1056,14 @@ pub fn has_legal_moves(game: &GameState) -> bool { return true; } - // Stock and waste exhausted — check whether any visible card can be placed. - let mut sources: Vec = Vec::new(); - // Top waste card (waste is empty here, but included for completeness). - if let Some(p) = game.piles.get(&PileType::Waste) - && let Some(top) = p.cards.last() - { - sources.push(top.clone()); - } - // Any face-up card in a tableau column can be the base of a movable run. - for i in 0..7_usize { - if let Some(t) = game.piles.get(&PileType::Tableau(i)) { - for card in t.cards.iter().filter(|c| c.face_up) { - sources.push(card.clone()); - } - } - } - - for card in &sources { - for slot in 0..4_u8 { - if let Some(dest) = game.piles.get(&PileType::Foundation(slot)) - && can_place_on_foundation(card, dest) - { - return true; - } - } - for i in 0..7_usize { - if let Some(dest) = game.piles.get(&PileType::Tableau(i)) - && can_place_on_tableau(card, dest) - { - return true; - } - } - } - false + // Stock and waste both exhausted — delegate to the authoritative move + // enumeration in core, which validates tableau sequence structure and + // foundation placement correctly. The previous hand-rolled loop only + // checked can_place_on_tableau(card, dest) for individual face-up cards + // without verifying that the cards above them form a valid alternating run, + // causing false positives when a useful-looking card was buried under an + // invalid sequence. + !game.possible_instructions().is_empty() } /// After each `StateChangedEvent`, check if the game has no legal moves. diff --git a/solitaire_server/web/game.css b/solitaire_server/web/game.css index 587f2fb..cd2b18d 100644 --- a/solitaire_server/web/game.css +++ b/solitaire_server/web/game.css @@ -355,6 +355,67 @@ main { animation: illegal-shake 320ms ease; } +/* ── No-moves banner ─────────────────────────────────────────────────── */ + +#no-moves-banner { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + z-index: 900; + animation: slide-up 240ms ease; +} + +#no-moves-banner.hidden { display: none; } + +.no-moves-card { + background: var(--panel); + border: 1px solid rgba(255,255,255,0.15); + border-radius: 12px; + padding: 20px 32px; + text-align: center; + display: flex; + flex-direction: column; + gap: 12px; + box-shadow: 0 8px 32px rgba(0,0,0,0.7); + min-width: 300px; +} + +.no-moves-title { + font-size: 18px; + font-weight: 700; + color: var(--accent); +} + +.no-moves-detail { + font-size: 13px; + color: var(--text-muted); + margin: 0; + line-height: 1.5; +} + +.no-moves-actions { + display: flex; + gap: 10px; + justify-content: center; + margin-top: 4px; +} + +.no-moves-actions button.secondary { + background: transparent; + border: 1px solid rgba(255,255,255,0.2); + color: var(--text-muted); +} + +.no-moves-actions button.secondary:hover { + background: rgba(255,255,255,0.05); +} + +@keyframes slide-up { + from { opacity: 0; transform: translateX(-50%) translateY(12px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} + /* ── Foundation slot suit hints ──────────────────────────────────────────── */ .slot-hint { diff --git a/solitaire_server/web/game.html b/solitaire_server/web/game.html index 7c481a6..8c86401 100644 --- a/solitaire_server/web/game.html +++ b/solitaire_server/web/game.html @@ -77,6 +77,17 @@ + + diff --git a/solitaire_server/web/game.js b/solitaire_server/web/game.js index bfd660c..1fe50fd 100644 --- a/solitaire_server/web/game.js +++ b/solitaire_server/web/game.js @@ -136,11 +136,12 @@ 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 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. @@ -391,9 +392,12 @@ function render(s) { clearSave(); stopTimer(); if (acTimer) { clearInterval(acTimer); acTimer = null; } + if (noMovesBanner) noMovesBanner.classList.add("hidden"); showWin(s); } else { saveState(); + const noMoves = !s.has_moves && !s.is_auto_completable; + if (noMovesBanner) noMovesBanner.classList.toggle("hidden", !noMoves); } } @@ -479,6 +483,8 @@ function attachHandlers() { 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; diff --git a/solitaire_wasm/src/lib.rs b/solitaire_wasm/src/lib.rs index 2eef52a..c1a7029 100644 --- a/solitaire_wasm/src/lib.rs +++ b/solitaire_wasm/src/lib.rs @@ -241,6 +241,8 @@ pub struct GameSnapshot { pub move_count: u32, pub is_won: bool, pub is_auto_completable: bool, + /// `false` when stock, waste, and all pile-to-pile moves are exhausted. + pub has_moves: bool, pub undo_count: u32, /// Number of snapshots currently on the undo stack; 0 means undo is unavailable. pub undo_stack_len: usize, @@ -279,11 +281,17 @@ impl SolitaireGame { .map(|p| p.cards.iter().map(CardSnapshot::from).collect()) .unwrap_or_default() }; + let has_moves = { + let stock_empty = self.game.piles.get(&PileType::Stock).is_none_or(|p| p.cards.is_empty()); + let waste_empty = self.game.piles.get(&PileType::Waste).is_none_or(|p| p.cards.is_empty()); + !stock_empty || !waste_empty || !self.game.possible_instructions().is_empty() + }; 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, + has_moves, undo_count: self.game.undo_count, undo_stack_len: self.game.undo_stack_len(), stock: cards(PileType::Stock),