fix(engine,wasm,web): detect no-legal-moves correctly and surface banner
Build and Deploy / build-and-push (push) Successful in 4m24s
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:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -77,6 +77,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="no-moves-banner" class="hidden">
|
||||
<div class="no-moves-card">
|
||||
<div class="no-moves-title">No Moves Available</div>
|
||||
<p class="no-moves-detail">No legal moves remain. Undo to go back or start a new game.</p>
|
||||
<div class="no-moves-actions">
|
||||
<button id="btn-no-moves-undo">↩ Undo</button>
|
||||
<button id="btn-no-moves-new" class="secondary">↺ New Game</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/web/game.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -141,6 +141,7 @@ 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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user