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:
@@ -426,12 +426,11 @@ impl GameState {
|
|||||||
/// Returns `true` when stock and waste are empty and all tableau cards are face-up.
|
/// 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.
|
/// At that point the game can be completed without further player input.
|
||||||
pub fn check_auto_complete(&self) -> bool {
|
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() {
|
if !self.piles[&PileType::Stock].cards.is_empty() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if !self.piles[&PileType::Waste].cards.is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
(0..7).all(|i| {
|
(0..7).all(|i| {
|
||||||
self.piles[&PileType::Tableau(i)]
|
self.piles[&PileType::Tableau(i)]
|
||||||
.cards
|
.cards
|
||||||
@@ -459,17 +458,36 @@ impl GameState {
|
|||||||
if !self.is_auto_completable || self.is_won {
|
if !self.is_auto_completable || self.is_won {
|
||||||
return None;
|
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 {
|
for i in 0..7 {
|
||||||
let tableau = PileType::Tableau(i);
|
let tableau = PileType::Tableau(i);
|
||||||
if let Some(card) = self.piles[&tableau].cards.last() {
|
if let Some(slot) = self.piles[&tableau].cards.last()
|
||||||
// Prefer the slot that already claims this card's suit so
|
.and_then(|c| self.foundation_slot_for(c))
|
||||||
// Aces don't sometimes land in slot 0 and then leave the
|
{
|
||||||
// matching suit-claimed slot empty.
|
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 candidate: Option<u8> = None;
|
||||||
let mut empty_slot: Option<u8> = None;
|
let mut empty_slot: Option<u8> = None;
|
||||||
for slot in 0..4_u8 {
|
for slot in 0..4_u8 {
|
||||||
let foundation = PileType::Foundation(slot);
|
let pile = &self.piles[&PileType::Foundation(slot)];
|
||||||
let pile = &self.piles[&foundation];
|
|
||||||
if pile.cards.is_empty() {
|
if pile.cards.is_empty() {
|
||||||
if empty_slot.is_none() {
|
if empty_slot.is_none() {
|
||||||
empty_slot = Some(slot);
|
empty_slot = Some(slot);
|
||||||
@@ -479,20 +497,12 @@ impl GameState {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let target_slot = candidate.or_else(|| {
|
let target = 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 card.rank.value() == 1 { empty_slot } else { None }
|
||||||
});
|
});
|
||||||
if let Some(slot) = target_slot {
|
target.filter(|&slot| {
|
||||||
let foundation = PileType::Foundation(slot);
|
can_place_on_foundation(card, &self.piles[&PileType::Foundation(slot)])
|
||||||
if can_place_on_foundation(card, &self.piles[&foundation]) {
|
})
|
||||||
return Some((tableau, foundation));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0).
|
/// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0).
|
||||||
@@ -1022,24 +1032,24 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
let mut g = new_game();
|
||||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
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 {
|
g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card {
|
||||||
id: 99,
|
id: 99,
|
||||||
suit: Suit::Clubs,
|
suit: Suit::Clubs,
|
||||||
rank: Rank::Ace,
|
rank: Rank::Ace,
|
||||||
face_up: true,
|
face_up: true,
|
||||||
});
|
});
|
||||||
// Make all tableau cards face-up so only the waste guard is the blocker.
|
|
||||||
for i in 0..7 {
|
for i in 0..7 {
|
||||||
for c in g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.iter_mut() {
|
for c in g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.iter_mut() {
|
||||||
c.face_up = true;
|
c.face_up = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert!(!g.check_auto_complete());
|
assert!(g.check_auto_complete());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
/* @ts-self-types="./solitaire_wasm.d.ts" */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Browser-side replay state machine. Owns a live `GameState` and the
|
* Browser-side replay state machine. Owns a live `GameState` and the
|
||||||
* replay's move list; each `step()` applies the next move.
|
* 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`).
|
* 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}
|
* @returns {any}
|
||||||
*/
|
*/
|
||||||
auto_complete_step() {
|
auto_complete_step() {
|
||||||
|
|||||||
Binary file not shown.
@@ -412,17 +412,22 @@ impl SolitaireGame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Apply one auto-complete move (only valid when `is_auto_completable`).
|
/// 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 {
|
pub fn auto_complete_step(&mut self) -> JsValue {
|
||||||
if !self.game.is_auto_completable {
|
if !self.game.is_auto_completable {
|
||||||
return JsValue::NULL;
|
return JsValue::NULL;
|
||||||
}
|
}
|
||||||
match self.game.next_auto_complete_move() {
|
if let Some((from, to)) = self.game.next_auto_complete_move() {
|
||||||
Some((from, to)) => {
|
|
||||||
let _ = self.game.move_cards(from, to, 1);
|
let _ = self.game.move_cards(from, to, 1);
|
||||||
self.ok_js()
|
return self.ok_js();
|
||||||
}
|
}
|
||||||
None => JsValue::NULL,
|
// No direct foundation move — advance through the waste.
|
||||||
|
match self.game.draw() {
|
||||||
|
Ok(()) => self.ok_js(),
|
||||||
|
Err(_) => JsValue::NULL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user