fix(core): remove stock_recycled limit, replace unwrap, snapshot on recycle, fix derives

- Remove stock_recycled field: recycling is now unlimited; StockEmpty only when both stock and waste are empty
- Replace all .unwrap() in draw() and move_cards() with .ok_or(MoveError::...)? propagation
- Push snapshot before recycling waste→stock so undo can reverse it
- Make StateSnapshot private (struct, not pub struct) and undo_stack private (not pub(crate))
- Add PartialEq, Eq to derives on both StateSnapshot and GameState
- Update draw_from_empty_stock_and_waste_returns_error test to use direct pile clearing since unlimited recycling means the old loop-based approach never reached both-empty

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Solitaire Quest
2026-04-23 11:17:25 -07:00
parent 58f1465927
commit b8dc7cb21c
+24 -28
View File
@@ -17,16 +17,15 @@ pub enum DrawMode {
}
/// Snapshot of game state used for undo.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateSnapshot {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct StateSnapshot {
piles: HashMap<PileType, Pile>,
score: i32,
move_count: u32,
stock_recycled: bool,
}
/// Full state of an in-progress Klondike Solitaire game.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GameState {
pub piles: HashMap<PileType, Pile>,
pub draw_mode: DrawMode,
@@ -36,10 +35,7 @@ pub struct GameState {
pub seed: u64,
pub is_won: bool,
pub is_auto_completable: bool,
pub(crate) undo_stack: Vec<StateSnapshot>,
/// Whether the waste has already been recycled back to stock once.
/// A second recycle attempt returns `StockEmpty`.
stock_recycled: bool,
undo_stack: Vec<StateSnapshot>,
}
impl GameState {
@@ -69,7 +65,6 @@ impl GameState {
is_won: false,
is_auto_completable: false,
undo_stack: Vec::new(),
stock_recycled: false,
}
}
@@ -83,7 +78,6 @@ impl GameState {
piles: self.piles.clone(),
score: self.score,
move_count: self.move_count,
stock_recycled: self.stock_recycled,
}
}
@@ -95,6 +89,7 @@ impl GameState {
}
/// Draw cards from stock to waste. When stock is empty, recycles waste back to stock.
/// Recycling is unlimited: `StockEmpty` is only returned when both stock and waste are empty.
pub fn draw(&mut self) -> Result<(), MoveError> {
if self.is_won {
return Err(MoveError::GameAlreadyWon);
@@ -104,22 +99,22 @@ impl GameState {
if stock_len == 0 {
let waste_len = self.piles[&PileType::Waste].cards.len();
if waste_len == 0 || self.stock_recycled {
if waste_len == 0 {
return Err(MoveError::StockEmpty);
}
// Recycle: reverse waste back onto stock, face-down
// Recycle: snapshot so undo can reverse it, then move waste back to stock face-down
self.push_snapshot();
let waste_cards: Vec<Card> = self.piles
.get_mut(&PileType::Waste)
.unwrap()
.ok_or(MoveError::InvalidSource)?
.cards
.drain(..)
.collect();
let stock = self.piles.get_mut(&PileType::Stock).unwrap();
let stock = self.piles.get_mut(&PileType::Stock).ok_or(MoveError::InvalidDestination)?;
for mut card in waste_cards.into_iter().rev() {
card.face_up = false;
stock.cards.push(card);
}
self.stock_recycled = true;
return Ok(());
}
@@ -134,12 +129,12 @@ impl GameState {
let drawn: Vec<Card> = self.piles
.get_mut(&PileType::Stock)
.unwrap()
.ok_or(MoveError::InvalidSource)?
.cards
.drain(drain_start..)
.collect();
let waste = self.piles.get_mut(&PileType::Waste).unwrap();
let waste = self.piles.get_mut(&PileType::Waste).ok_or(MoveError::InvalidDestination)?;
for mut card in drawn {
card.face_up = true;
waste.cards.push(card);
@@ -207,18 +202,23 @@ impl GameState {
// Execute move
let mut moved: Vec<Card> = self.piles
.get_mut(&from)
.unwrap()
.ok_or(MoveError::InvalidSource)?
.cards
.split_off(move_start);
// Flip the newly exposed top card of the source pile
if let Some(top) = self.piles.get_mut(&from).unwrap().cards.last_mut() {
if let Some(top) = self.piles
.get_mut(&from)
.ok_or(MoveError::InvalidSource)?
.cards
.last_mut()
{
if !top.face_up {
top.face_up = true;
}
}
self.piles.get_mut(&to).unwrap().cards.append(&mut moved);
self.piles.get_mut(&to).ok_or(MoveError::InvalidDestination)?.cards.append(&mut moved);
self.score = (self.score + score_delta).max(0);
self.move_count += 1;
@@ -240,7 +240,6 @@ impl GameState {
self.piles = snapshot.piles;
self.score = (snapshot.score + scoring_undo()).max(0);
self.move_count = snapshot.move_count;
self.stock_recycled = snapshot.stock_recycled;
self.is_won = false;
self.is_auto_completable = false;
Ok(())
@@ -392,14 +391,11 @@ mod tests {
#[test]
fn draw_from_empty_stock_and_waste_returns_error() {
// The only stop condition for draw() is: both stock AND waste are
// simultaneously empty. Manually empty both, then verify the error.
let mut g = new_game();
while !g.piles[&PileType::Stock].cards.is_empty() {
g.draw().unwrap();
}
g.draw().unwrap(); // recycle
while !g.piles[&PileType::Stock].cards.is_empty() {
g.draw().unwrap();
}
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
assert_eq!(g.draw(), Err(MoveError::StockEmpty));
}