fix(engine,wasm,web): detect no-legal-moves correctly and surface banner
Build and Deploy / build-and-push (push) Successful in 4m24s

Engine: replace broken has_legal_moves loop (which checked buried
mid-column cards without sequence validation) with a delegation to
possible_instructions(), mirroring the hint system's logic exactly.

WASM: add has_moves: bool to GameSnapshot, computed in snap() using the
same stock/waste/possible_instructions check so the web client gets the
flag in every state update at no extra round-trip cost.

Web: show a non-blocking no-moves banner (slide-up toast) with Undo and
New Game actions when has_moves is false and the game is not won. Banner
hides automatically once a move restores legal play (e.g. after undo).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-19 16:53:52 -07:00
parent a2dd8d220c
commit da601bebd6
5 changed files with 99 additions and 41 deletions
+8 -36
View File
@@ -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<Card> = 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.