fix(web): auto-complete now works with cards remaining in waste

check_auto_complete no longer requires the waste pile to be empty —
only the stock must be exhausted and all tableau cards face-up.
next_auto_complete_move checks the waste top card before scanning
tableau, and auto_complete_step falls back to draw() when no direct
foundation move is available so the waste drains automatically.

Fixes the end-game state where the player could see a clear win but
the auto-complete interval never fired because the waste was non-empty.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-13 21:30:42 -07:00
parent d685224ce6
commit 1b7c4d92aa
4 changed files with 65 additions and 45 deletions
+47 -37
View File
@@ -426,12 +426,11 @@ impl GameState {
/// Returns `true` when stock and waste are empty and all tableau cards are face-up.
/// At that point the game can be completed without further player input.
pub fn check_auto_complete(&self) -> bool {
// Stock must be empty; waste may still have cards (they are resolved
// by draw() calls inside next_auto_complete_move / auto_complete_step).
if !self.piles[&PileType::Stock].cards.is_empty() {
return false;
}
if !self.piles[&PileType::Waste].cards.is_empty() {
return false;
}
(0..7).all(|i| {
self.piles[&PileType::Tableau(i)]
.cards
@@ -459,42 +458,53 @@ impl GameState {
if !self.is_auto_completable || self.is_won {
return None;
}
// Check waste top first — when stock is exhausted the waste may still
// contain cards that can go directly to a foundation.
let waste = PileType::Waste;
if let Some((card, slot)) = self.piles[&waste].cards.last()
.and_then(|c| self.foundation_slot_for(c).map(|s| (c, s)))
{
let _ = card; // borrow ends here
return Some((waste, PileType::Foundation(slot)));
}
for i in 0..7 {
let tableau = PileType::Tableau(i);
if let Some(card) = self.piles[&tableau].cards.last() {
// Prefer the slot that already claims this card's suit so
// Aces don't sometimes land in slot 0 and then leave the
// matching suit-claimed slot empty.
let mut candidate: Option<u8> = None;
let mut empty_slot: Option<u8> = None;
for slot in 0..4_u8 {
let foundation = PileType::Foundation(slot);
let pile = &self.piles[&foundation];
if pile.cards.is_empty() {
if empty_slot.is_none() {
empty_slot = Some(slot);
}
} else if pile.claimed_suit() == Some(card.suit) {
candidate = Some(slot);
break;
}
}
let target_slot = candidate.or_else(|| {
// Only fall back to an empty slot if the card is an Ace,
// which is the only rank that can claim an empty slot.
if card.rank.value() == 1 { empty_slot } else { None }
});
if let Some(slot) = target_slot {
let foundation = PileType::Foundation(slot);
if can_place_on_foundation(card, &self.piles[&foundation]) {
return Some((tableau, foundation));
}
}
if let Some(slot) = self.piles[&tableau].cards.last()
.and_then(|c| self.foundation_slot_for(c))
{
return Some((tableau, PileType::Foundation(slot)));
}
}
None
}
/// Return the foundation slot index that `card` can legally move to, or
/// `None` if no such slot exists.
///
/// Prefers the slot already claiming this card's suit so Aces always land
/// in a consistent column. Falls back to an empty slot only for Aces.
fn foundation_slot_for(&self, card: &crate::card::Card) -> Option<u8> {
let mut candidate: Option<u8> = None;
let mut empty_slot: Option<u8> = None;
for slot in 0..4_u8 {
let pile = &self.piles[&PileType::Foundation(slot)];
if pile.cards.is_empty() {
if empty_slot.is_none() {
empty_slot = Some(slot);
}
} else if pile.claimed_suit() == Some(card.suit) {
candidate = Some(slot);
break;
}
}
let target = candidate.or_else(|| {
if card.rank.value() == 1 { empty_slot } else { None }
});
target.filter(|&slot| {
can_place_on_foundation(card, &self.piles[&PileType::Foundation(slot)])
})
}
/// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0).
pub fn compute_time_bonus(&self) -> i32 {
scoring_time_bonus(self.elapsed_seconds)
@@ -1022,24 +1032,24 @@ mod tests {
}
#[test]
fn auto_complete_false_when_waste_not_empty() {
fn auto_complete_true_when_stock_empty_waste_has_cards() {
// Waste no longer blocks auto-complete — draw() drains it during
// auto-complete steps. Only stock-not-empty and face-down tableau
// cards block the flag.
let mut g = new_game();
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
// Leave the waste pile untouched (it may be empty after clearing stock,
// so add a card explicitly to ensure the waste guard is exercised).
g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card {
id: 99,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
});
// Make all tableau cards face-up so only the waste guard is the blocker.
for i in 0..7 {
for c in g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.iter_mut() {
c.face_up = true;
}
}
assert!(!g.check_auto_complete());
assert!(g.check_auto_complete());
}
#[test]
+6 -1
View File
@@ -1,3 +1,5 @@
/* @ts-self-types="./solitaire_wasm.d.ts" */
/**
* Browser-side replay state machine. Owns a live `GameState` and the
* replay's move list; each `step()` applies the next move.
@@ -92,7 +94,10 @@ export class SolitaireGame {
}
/**
* Apply one auto-complete move (only valid when `is_auto_completable`).
* Returns the post-move snapshot or `null` when auto-complete is unavailable.
*
* If no card can go directly to a foundation this step, advances the
* waste by calling `draw()` so the next step can try again. Returns the
* post-move snapshot, or `null` when no progress is possible.
* @returns {any}
*/
auto_complete_step() {
Binary file not shown.
+12 -7
View File
@@ -412,17 +412,22 @@ impl SolitaireGame {
}
/// Apply one auto-complete move (only valid when `is_auto_completable`).
/// Returns the post-move snapshot or `null` when auto-complete is unavailable.
///
/// If no card can go directly to a foundation this step, advances the
/// waste by calling `draw()` so the next step can try again. Returns the
/// post-move snapshot, or `null` when no progress is possible.
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,
if let Some((from, to)) = self.game.next_auto_complete_move() {
let _ = self.game.move_cards(from, to, 1);
return self.ok_js();
}
// No direct foundation move — advance through the waste.
match self.game.draw() {
Ok(()) => self.ok_js(),
Err(_) => JsValue::NULL,
}
}
}