diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index 3d1aa0e..341a52a 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -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 = None; - let mut empty_slot: Option = 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 { + let mut candidate: Option = None; + let mut empty_slot: Option = 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] diff --git a/solitaire_server/web/pkg/solitaire_wasm.js b/solitaire_server/web/pkg/solitaire_wasm.js index cbf30e5..a6fe4e9 100644 --- a/solitaire_server/web/pkg/solitaire_wasm.js +++ b/solitaire_server/web/pkg/solitaire_wasm.js @@ -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() { diff --git a/solitaire_server/web/pkg/solitaire_wasm_bg.wasm b/solitaire_server/web/pkg/solitaire_wasm_bg.wasm index 4a4ace6..771d9d3 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 e602282..71b6e7d 100644 --- a/solitaire_wasm/src/lib.rs +++ b/solitaire_wasm/src/lib.rs @@ -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, } } }