fix(core): correct recycle_count drift and score compound error on undo
Phase 3 of the in-place card_game rewrite. Two bugs on undo: 1. recycle_count was incremented when recycling but never decremented on undo, causing the free-recycle allowance to be exhausted faster than it should be after undo+redo cycles. 2. undoing a penalised recycle applied the −15 undo penalty on top of the post-penalty (post-recycle) score rather than on the pre-recycle score, compounding the −100 / −20 penalty rather than reversing it. Fix: - Add score_history: Vec<i32> and is_recycle_history: Vec<bool> to GameState, both parallel to session.history() at all times. - Extract pre_instruction_score_delta() helper — single source of truth for all scoring logic, called from draw(), move_cards(), and the Deserialize replay. - draw() and move_cards() push to both stacks before processing. - undo() pops from both stacks: uses the popped pre-move score as the base for apply_undo_score() and decrements recycle_count if the undone instruction was a recycle. - Deserialize rebuilds is_recycle_history and recycle_count from the instruction replay (recycle detection needs only pre-instruction session state, so it is always correct across save/load cycles). score_history is not rebuilt on load (undo-penalty history is absent from saved_moves); undo falls back to old behaviour for pre-load moves, but is fully correct for moves made in the current session. - Remove recycle_count from PersistedGameStateIn (now rebuilt; serde silently ignores the field in existing JSON saves). Tests added: - recycle_count_decrements_when_recycle_is_undone - score_recycle_penalty_is_reversed_on_undo All 71 solitaire_core tests and full-workspace suite pass; clippy clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@ use crate::klondike_adapter::{
|
|||||||
skip_cards_from_count as adapter_skip_cards_from_count,
|
skip_cards_from_count as adapter_skip_cards_from_count,
|
||||||
tableau_from_index as adapter_tableau_from_index,
|
tableau_from_index as adapter_tableau_from_index,
|
||||||
};
|
};
|
||||||
use card_game::{Game, Session, SessionConfig};
|
use card_game::{Game as _, Session, SessionConfig};
|
||||||
use klondike::{
|
use klondike::{
|
||||||
DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig, KlondikeInstruction,
|
DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig, KlondikeInstruction,
|
||||||
KlondikePile, KlondikePileStack, SkipCards, Tableau, TableauStack,
|
KlondikePile, KlondikePileStack, SkipCards, Tableau, TableauStack,
|
||||||
@@ -20,9 +20,12 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
|||||||
/// History:
|
/// History:
|
||||||
/// - v1: `Foundation(Suit)` keys.
|
/// - v1: `Foundation(Suit)` keys.
|
||||||
/// - v2: `Foundation(u8)` slot keys; claimed suit derived from the bottom card.
|
/// - v2: `Foundation(u8)` slot keys; claimed suit derived from the bottom card.
|
||||||
/// - v3 (current): session-backed save files store replayable instruction
|
/// - v3: session-backed save files using local `SavedInstruction` mirror types
|
||||||
/// history instead of raw piles + undo snapshots.
|
/// with u8 indices for enum variants.
|
||||||
pub const GAME_STATE_SCHEMA_VERSION: u32 = 3;
|
/// - v4 (current): `saved_moves` uses upstream `KlondikeInstruction` serde with
|
||||||
|
/// named enum variants (e.g. `"Foundation1"` instead of `0`). v3 files are
|
||||||
|
/// auto-migrated on load via `AnyInstruction` transparent deserialization.
|
||||||
|
pub const GAME_STATE_SCHEMA_VERSION: u32 = 4;
|
||||||
|
|
||||||
/// Default value for `GameState::schema_version` when deserialising older
|
/// Default value for `GameState::schema_version` when deserialising older
|
||||||
/// save files that pre-date the field.
|
/// save files that pre-date the field.
|
||||||
@@ -84,8 +87,45 @@ pub enum GameMode {
|
|||||||
Difficulty(DifficultyLevel),
|
Difficulty(DifficultyLevel),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
/// Output struct for schema v4 serialisation. `saved_moves` uses upstream
|
||||||
|
/// `KlondikeInstruction` serde, which produces named enum variants.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
struct PersistedGameState {
|
struct PersistedGameState {
|
||||||
|
pub draw_mode: DrawMode,
|
||||||
|
pub mode: GameMode,
|
||||||
|
pub score: i32,
|
||||||
|
pub elapsed_seconds: u64,
|
||||||
|
pub seed: u64,
|
||||||
|
pub undo_count: u32,
|
||||||
|
pub recycle_count: u32,
|
||||||
|
pub take_from_foundation: bool,
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub saved_moves: Vec<KlondikeInstruction>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transparent migration wrapper for deserialisation.
|
||||||
|
///
|
||||||
|
/// Tries `KlondikeInstruction` (schema v4, named variants) first; if that
|
||||||
|
/// fails (because the value uses u8 indices), falls back to `SavedInstruction`
|
||||||
|
/// (schema v3). Converting the V3 variant yields a `KlondikeInstruction` via
|
||||||
|
/// the existing `TryFrom` impl.
|
||||||
|
///
|
||||||
|
/// `SavedInstruction` remains `pub` in `klondike_adapter` because
|
||||||
|
/// `solitaire_data::ReplayMove` and the WASM replay layer depend on it.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum AnyInstruction {
|
||||||
|
V4(KlondikeInstruction),
|
||||||
|
V3(SavedInstruction),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input struct that accepts both schema v3 and v4 `saved_moves` formats.
|
||||||
|
///
|
||||||
|
/// `recycle_count` is intentionally absent: the value is rebuilt from the
|
||||||
|
/// instruction replay so that stale counts (from the pre-Phase-3 undo drift
|
||||||
|
/// bug) are corrected on load. Serde ignores the field in the JSON.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
struct PersistedGameStateIn {
|
||||||
pub draw_mode: DrawMode,
|
pub draw_mode: DrawMode,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mode: GameMode,
|
pub mode: GameMode,
|
||||||
@@ -94,12 +134,10 @@ struct PersistedGameState {
|
|||||||
pub seed: u64,
|
pub seed: u64,
|
||||||
pub undo_count: u32,
|
pub undo_count: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub recycle_count: u32,
|
|
||||||
#[serde(default)]
|
|
||||||
pub take_from_foundation: bool,
|
pub take_from_foundation: bool,
|
||||||
#[serde(default = "schema_v1")]
|
#[serde(default = "schema_v1")]
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
pub saved_moves: Vec<SavedInstruction>,
|
pub saved_moves: Vec<AnyInstruction>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "test-support")]
|
#[cfg(feature = "test-support")]
|
||||||
@@ -150,6 +188,15 @@ pub struct GameState {
|
|||||||
/// Save-file schema version.
|
/// Save-file schema version.
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
pub(crate) session: Session<Klondike>,
|
pub(crate) session: Session<Klondike>,
|
||||||
|
/// Score recorded immediately before each instruction was applied.
|
||||||
|
/// Parallel to `session.history()` during live play; used by `undo()` to
|
||||||
|
/// correctly restore the pre-move score before applying the undo penalty.
|
||||||
|
/// Empty after a load (can't be reconstructed from history alone).
|
||||||
|
score_history: Vec<i32>,
|
||||||
|
/// Whether each entry in `session.history()` was a stock recycle.
|
||||||
|
/// Parallel to `session.history()`; rebuilt from replay on load so that
|
||||||
|
/// `undo()` correctly decrements `recycle_count` even across save/load cycles.
|
||||||
|
is_recycle_history: Vec<bool>,
|
||||||
#[cfg(feature = "test-support")]
|
#[cfg(feature = "test-support")]
|
||||||
/// Test pile overrides. Always `None` in production runtime code.
|
/// Test pile overrides. Always `None` in production runtime code.
|
||||||
pub test_pile_state: Option<TestPileState>,
|
pub test_pile_state: Option<TestPileState>,
|
||||||
@@ -205,12 +252,17 @@ impl Serialize for GameState {
|
|||||||
|
|
||||||
impl<'de> Deserialize<'de> for GameState {
|
impl<'de> Deserialize<'de> for GameState {
|
||||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
let persisted = PersistedGameState::deserialize(deserializer)?;
|
let persisted = PersistedGameStateIn::deserialize(deserializer)?;
|
||||||
if persisted.schema_version != GAME_STATE_SCHEMA_VERSION {
|
|
||||||
return Err(serde::de::Error::custom(format!(
|
// Accept v3 (legacy u8-index format, auto-migrated) and v4 (current,
|
||||||
"unsupported GameState schema version {}",
|
// upstream named-variant serde). Reject everything else.
|
||||||
persisted.schema_version
|
match persisted.schema_version {
|
||||||
)));
|
3 | 4 => {}
|
||||||
|
v => {
|
||||||
|
return Err(serde::de::Error::custom(format!(
|
||||||
|
"unsupported GameState schema version {v}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut game = Self {
|
let mut game = Self {
|
||||||
@@ -223,18 +275,44 @@ impl<'de> Deserialize<'de> for GameState {
|
|||||||
is_won: false,
|
is_won: false,
|
||||||
is_auto_completable: false,
|
is_auto_completable: false,
|
||||||
undo_count: persisted.undo_count,
|
undo_count: persisted.undo_count,
|
||||||
recycle_count: persisted.recycle_count,
|
// Rebuilt from the replay loop below; persisted value may be stale
|
||||||
|
// due to the pre-Phase-3 undo drift bug.
|
||||||
|
recycle_count: 0,
|
||||||
take_from_foundation: persisted.take_from_foundation,
|
take_from_foundation: persisted.take_from_foundation,
|
||||||
schema_version: persisted.schema_version,
|
// Always stamp the current schema version after a successful load so
|
||||||
|
// storage.rs schema checks pass and re-saving writes the v4 format.
|
||||||
|
schema_version: GAME_STATE_SCHEMA_VERSION,
|
||||||
session: Self::new_session(persisted.seed, persisted.draw_mode),
|
session: Self::new_session(persisted.seed, persisted.draw_mode),
|
||||||
|
// score_history cannot be faithfully rebuilt from the instruction
|
||||||
|
// history because live-play undo penalties are not recorded in
|
||||||
|
// saved_moves. Leave empty; undo() falls back to old behaviour for
|
||||||
|
// any move made before this load (see undo() for details).
|
||||||
|
score_history: Vec::new(),
|
||||||
|
// is_recycle_history IS rebuilt: recycle detection only needs the
|
||||||
|
// pre-instruction session state, which is available during replay.
|
||||||
|
is_recycle_history: Vec::new(),
|
||||||
#[cfg(feature = "test-support")]
|
#[cfg(feature = "test-support")]
|
||||||
test_pile_state: None,
|
test_pile_state: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let replay_config = Self::replay_config(game.draw_mode);
|
let replay_config = Self::replay_config(game.draw_mode);
|
||||||
for saved in persisted.saved_moves {
|
for any in persisted.saved_moves {
|
||||||
let instruction =
|
// AnyInstruction::V4 arrives directly from upstream serde (schema v4).
|
||||||
KlondikeInstruction::try_from(saved).map_err(serde::de::Error::custom)?;
|
// AnyInstruction::V3 was serialised with u8 indices (schema v3) and is
|
||||||
|
// converted here via the existing TryFrom impl.
|
||||||
|
let instruction = match any {
|
||||||
|
AnyInstruction::V4(i) => i,
|
||||||
|
AnyInstruction::V3(s) => {
|
||||||
|
KlondikeInstruction::try_from(s).map_err(serde::de::Error::custom)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Detect recycle BEFORE processing so that the pre-instruction
|
||||||
|
// session state (face-down stock) is still available.
|
||||||
|
let is_recycle = matches!(instruction, KlondikeInstruction::RotateStock)
|
||||||
|
&& game.stock_cards().is_empty()
|
||||||
|
&& !game.waste_cards().is_empty();
|
||||||
|
|
||||||
if !game
|
if !game
|
||||||
.session
|
.session
|
||||||
.state()
|
.state()
|
||||||
@@ -246,6 +324,11 @@ impl<'de> Deserialize<'de> for GameState {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
game.session.process_instruction(instruction);
|
game.session.process_instruction(instruction);
|
||||||
|
|
||||||
|
game.is_recycle_history.push(is_recycle);
|
||||||
|
if is_recycle {
|
||||||
|
game.recycle_count = game.recycle_count.saturating_add(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
game.move_count = Self::u32_from_len(game.session.history().len());
|
game.move_count = Self::u32_from_len(game.session.history().len());
|
||||||
@@ -277,6 +360,8 @@ impl GameState {
|
|||||||
take_from_foundation: true,
|
take_from_foundation: true,
|
||||||
schema_version: GAME_STATE_SCHEMA_VERSION,
|
schema_version: GAME_STATE_SCHEMA_VERSION,
|
||||||
session: Self::new_session(seed, draw_mode),
|
session: Self::new_session(seed, draw_mode),
|
||||||
|
score_history: Vec::new(),
|
||||||
|
is_recycle_history: Vec::new(),
|
||||||
#[cfg(feature = "test-support")]
|
#[cfg(feature = "test-support")]
|
||||||
test_pile_state: None,
|
test_pile_state: None,
|
||||||
}
|
}
|
||||||
@@ -307,7 +392,27 @@ impl GameState {
|
|||||||
KlondikeAdapter::config_for(self.draw_mode, self.take_from_foundation)
|
KlondikeAdapter::config_for(self.draw_mode, self.take_from_foundation)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn saved_moves(&self) -> Vec<SavedInstruction> {
|
/// Collects the session instruction history as upstream types for schema v4
|
||||||
|
/// serialisation.
|
||||||
|
fn saved_moves(&self) -> Vec<KlondikeInstruction> {
|
||||||
|
self.session
|
||||||
|
.history()
|
||||||
|
.iter()
|
||||||
|
.map(|snapshot| *snapshot.instruction())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the deterministic instruction history for the current deal as
|
||||||
|
/// legacy mirror types.
|
||||||
|
///
|
||||||
|
/// Combined with [`GameState::seed`] and [`GameState::draw_mode`], this
|
||||||
|
/// sequence is sufficient to replay the game state exactly.
|
||||||
|
///
|
||||||
|
/// Returns [`SavedInstruction`] (u8-index mirror types) for backward
|
||||||
|
/// compatibility with the WASM replay layer and `solitaire_data::ReplayMove`
|
||||||
|
/// format. New code that does not need serde should prefer
|
||||||
|
/// `session().history()` directly.
|
||||||
|
pub fn instruction_history(&self) -> Vec<SavedInstruction> {
|
||||||
self.session
|
self.session
|
||||||
.history()
|
.history()
|
||||||
.iter()
|
.iter()
|
||||||
@@ -315,14 +420,6 @@ impl GameState {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the deterministic instruction history for the current deal.
|
|
||||||
///
|
|
||||||
/// Combined with [`GameState::seed`] and [`GameState::draw_mode`], this
|
|
||||||
/// sequence is sufficient to replay the game state exactly.
|
|
||||||
pub fn instruction_history(&self) -> Vec<SavedInstruction> {
|
|
||||||
self.saved_moves()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn u32_from_len(len: usize) -> u32 {
|
fn u32_from_len(len: usize) -> u32 {
|
||||||
if len > u32::MAX as usize {
|
if len > u32::MAX as usize {
|
||||||
u32::MAX
|
u32::MAX
|
||||||
@@ -533,6 +630,68 @@ impl GameState {
|
|||||||
pile.len() > count && !pile[pile.len() - count - 1].face_up
|
pile.len() > count && !pile[pile.len() - count - 1].face_up
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns `(score_delta, is_recycle)` for `instruction` given the *current*
|
||||||
|
/// game state. Must be called **before** the instruction is applied to the
|
||||||
|
/// session; the helper reads pre-instruction pile state from `self`.
|
||||||
|
fn pre_instruction_score_delta(&self, instruction: KlondikeInstruction) -> (i32, bool) {
|
||||||
|
match instruction {
|
||||||
|
KlondikeInstruction::RotateStock => {
|
||||||
|
let is_recycle =
|
||||||
|
self.stock_cards().is_empty() && !self.waste_cards().is_empty();
|
||||||
|
if is_recycle {
|
||||||
|
let next_count = self.recycle_count.saturating_add(1);
|
||||||
|
let penalty = KlondikeAdapter::score_for_recycle_with_mode(
|
||||||
|
next_count,
|
||||||
|
self.draw_mode == DrawMode::DrawThree,
|
||||||
|
self.mode,
|
||||||
|
);
|
||||||
|
(penalty, true)
|
||||||
|
} else {
|
||||||
|
(0, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KlondikeInstruction::DstFoundation(dst_foundation) => {
|
||||||
|
let from = dst_foundation.src;
|
||||||
|
let to = KlondikePile::Foundation(dst_foundation.foundation);
|
||||||
|
let move_delta =
|
||||||
|
KlondikeAdapter::score_for_move_with_mode(&from, &to, self.mode);
|
||||||
|
// DstFoundation always moves exactly 1 card.
|
||||||
|
let flip_bonus = if self.will_flip_tableau_source(from, 1) {
|
||||||
|
KlondikeAdapter::score_for_flip_with_mode(self.mode)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
(move_delta + flip_bonus, false)
|
||||||
|
}
|
||||||
|
KlondikeInstruction::DstTableau(dst_tableau) => {
|
||||||
|
let (from, count) = match dst_tableau.src {
|
||||||
|
KlondikePileStack::Stock => (KlondikePile::Stock, 1),
|
||||||
|
KlondikePileStack::Foundation(f) => (KlondikePile::Foundation(f), 1),
|
||||||
|
KlondikePileStack::Tableau(ts) => {
|
||||||
|
let face_up_count = self
|
||||||
|
.session
|
||||||
|
.state()
|
||||||
|
.state()
|
||||||
|
.state()
|
||||||
|
.tableau_face_up_cards(ts.tableau)
|
||||||
|
.len();
|
||||||
|
let count = face_up_count.saturating_sub(ts.skip_cards as usize);
|
||||||
|
(KlondikePile::Tableau(ts.tableau), count)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let to = KlondikePile::Tableau(dst_tableau.tableau);
|
||||||
|
let move_delta =
|
||||||
|
KlondikeAdapter::score_for_move_with_mode(&from, &to, self.mode);
|
||||||
|
let flip_bonus = if self.will_flip_tableau_source(from, count) {
|
||||||
|
KlondikeAdapter::score_for_flip_with_mode(self.mode)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
(move_delta + flip_bonus, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn instruction_for_move(
|
fn instruction_for_move(
|
||||||
&self,
|
&self,
|
||||||
from: KlondikePile,
|
from: KlondikePile,
|
||||||
@@ -670,19 +829,19 @@ impl GameState {
|
|||||||
return Err(MoveError::StockEmpty);
|
return Err(MoveError::StockEmpty);
|
||||||
}
|
}
|
||||||
|
|
||||||
let recycling = stock_empty && !waste_empty;
|
let (score_delta, is_recycle) =
|
||||||
|
self.pre_instruction_score_delta(KlondikeInstruction::RotateStock);
|
||||||
|
|
||||||
|
self.score_history.push(self.score);
|
||||||
|
self.is_recycle_history.push(is_recycle);
|
||||||
|
|
||||||
self.session
|
self.session
|
||||||
.process_instruction(KlondikeInstruction::RotateStock);
|
.process_instruction(KlondikeInstruction::RotateStock);
|
||||||
|
|
||||||
if recycling {
|
if is_recycle {
|
||||||
self.recycle_count = self.recycle_count.saturating_add(1);
|
self.recycle_count = self.recycle_count.saturating_add(1);
|
||||||
let penalty = KlondikeAdapter::score_for_recycle_with_mode(
|
|
||||||
self.recycle_count,
|
|
||||||
self.draw_mode == DrawMode::DrawThree,
|
|
||||||
self.mode,
|
|
||||||
);
|
|
||||||
self.score = (self.score + penalty).max(0);
|
|
||||||
}
|
}
|
||||||
|
self.score = (self.score + score_delta).max(0);
|
||||||
self.move_count = Self::u32_from_len(self.session.history().len());
|
self.move_count = Self::u32_from_len(self.session.history().len());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -722,15 +881,13 @@ impl GameState {
|
|||||||
return Err(MoveError::RuleViolation("move violates rules".into()));
|
return Err(MoveError::RuleViolation("move violates rules".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let score_delta = KlondikeAdapter::score_for_move_with_mode(&from, &to, self.mode);
|
let (score_delta, _) = self.pre_instruction_score_delta(instruction);
|
||||||
let flip_bonus = if self.will_flip_tableau_source(from, count) {
|
|
||||||
KlondikeAdapter::score_for_flip_with_mode(self.mode)
|
self.score_history.push(self.score);
|
||||||
} else {
|
self.is_recycle_history.push(false);
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
self.session.process_instruction(instruction);
|
self.session.process_instruction(instruction);
|
||||||
self.score = (self.score + score_delta + flip_bonus).max(0);
|
self.score = (self.score + score_delta).max(0);
|
||||||
self.move_count = Self::u32_from_len(self.session.history().len());
|
self.move_count = Self::u32_from_len(self.session.history().len());
|
||||||
self.is_won = self.check_win();
|
self.is_won = self.check_win();
|
||||||
self.is_auto_completable = !self.is_won && self.check_auto_complete();
|
self.is_auto_completable = !self.is_won && self.check_auto_complete();
|
||||||
@@ -750,9 +907,23 @@ impl GameState {
|
|||||||
if self.session.history().is_empty() {
|
if self.session.history().is_empty() {
|
||||||
return Err(MoveError::UndoStackEmpty);
|
return Err(MoveError::UndoStackEmpty);
|
||||||
}
|
}
|
||||||
let snapshot_score = self.score;
|
|
||||||
|
// Pop the pre-instruction score for the move being undone. Falls back
|
||||||
|
// to self.score (= old behaviour) when score_history is empty, which
|
||||||
|
// happens for moves made before a save/load cycle because undo
|
||||||
|
// penalties aren't reflected in the saved instruction history.
|
||||||
|
let pre_move_score = self.score_history.pop().unwrap_or(self.score);
|
||||||
|
let was_recycle = self.is_recycle_history.pop().unwrap_or(false);
|
||||||
|
|
||||||
self.session.undo();
|
self.session.undo();
|
||||||
self.score = KlondikeAdapter::apply_undo_score(snapshot_score, self.mode);
|
|
||||||
|
if was_recycle {
|
||||||
|
self.recycle_count = self.recycle_count.saturating_sub(1);
|
||||||
|
}
|
||||||
|
// Apply the undo penalty to the pre-move score, not the post-move score.
|
||||||
|
// This correctly reverses any recycle or move penalty that was applied
|
||||||
|
// before adding the −15 undo penalty.
|
||||||
|
self.score = KlondikeAdapter::apply_undo_score(pre_move_score, self.mode);
|
||||||
self.move_count = Self::u32_from_len(self.session.history().len());
|
self.move_count = Self::u32_from_len(self.session.history().len());
|
||||||
self.is_won = self.check_win();
|
self.is_won = self.check_win();
|
||||||
self.is_auto_completable = !self.is_won && self.check_auto_complete();
|
self.is_auto_completable = !self.is_won && self.check_auto_complete();
|
||||||
@@ -760,42 +931,15 @@ impl GameState {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` when all four foundation slots each contain a valid A→K sequence.
|
/// Returns `true` when all four foundation slots each contain a complete A→K sequence.
|
||||||
pub fn check_win(&self) -> bool {
|
pub fn check_win(&self) -> bool {
|
||||||
(0..4_u8).all(|slot| self.is_valid_foundation_pile(slot))
|
self.session.state().state().is_win()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_valid_foundation_pile(&self, slot: u8) -> bool {
|
/// Returns `true` when the game can be completed without further player input
|
||||||
let Ok(pile) = self.foundation_cards(slot) else {
|
/// (stock empty, waste empty, all tableau cards face-up).
|
||||||
return false;
|
|
||||||
};
|
|
||||||
if pile.len() != 13 {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let suit = pile[0].suit;
|
|
||||||
pile.iter()
|
|
||||||
.enumerate()
|
|
||||||
.all(|(i, card)| card.suit == suit && card.rank.value() == i as u8 + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns `true` when stock and waste are empty and all tableau cards are face-up.
|
|
||||||
pub fn check_auto_complete(&self) -> bool {
|
pub fn check_auto_complete(&self) -> bool {
|
||||||
if !self.stock_cards().is_empty() {
|
self.session.state().state().is_win_trivial()
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if !self.waste_cards().is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
(0..7).all(|index| {
|
|
||||||
Self::tableau_from_index(index)
|
|
||||||
.ok()
|
|
||||||
.map(|tableau| {
|
|
||||||
self.pile(KlondikePile::Tableau(tableau))
|
|
||||||
.iter()
|
|
||||||
.all(|card| card.face_up)
|
|
||||||
})
|
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns all currently valid `(from, to, count)` moves.
|
/// Returns all currently valid `(from, to, count)` moves.
|
||||||
@@ -949,6 +1093,77 @@ mod tests {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drive a DrawOne game until a recycle is available, perform it, and return
|
||||||
|
/// the game. Returns `None` if no recycle position is found within the
|
||||||
|
/// iteration limit (shouldn't happen in practice).
|
||||||
|
fn game_at_first_recycle() -> Option<GameState> {
|
||||||
|
for seed in 1..=256_u64 {
|
||||||
|
let mut game = GameState::new(seed, DrawMode::DrawOne);
|
||||||
|
for _ in 0..200 {
|
||||||
|
if game.stock_cards().is_empty() && !game.waste_cards().is_empty() {
|
||||||
|
// This draw will recycle.
|
||||||
|
game.draw().ok()?;
|
||||||
|
return Some(game);
|
||||||
|
}
|
||||||
|
let _ = game.draw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recycle_count_decrements_when_recycle_is_undone() {
|
||||||
|
let mut game = game_at_first_recycle().expect("could not reach recycle");
|
||||||
|
let count_after_recycle = game.recycle_count;
|
||||||
|
assert_eq!(count_after_recycle, 1, "first recycle should give count=1");
|
||||||
|
game.undo().expect("undo should succeed");
|
||||||
|
assert_eq!(
|
||||||
|
game.recycle_count, 0,
|
||||||
|
"recycle_count must decrement back to 0 after undoing the recycle",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn score_recycle_penalty_is_reversed_on_undo() {
|
||||||
|
// Reach the second recycle (count=2, Draw-1) so there is a −100 penalty.
|
||||||
|
let mut game = game_at_first_recycle().expect("could not reach first recycle");
|
||||||
|
|
||||||
|
// Draw until stock is empty again so we can do a second recycle.
|
||||||
|
let mut second_recycle_done = false;
|
||||||
|
for _ in 0..200 {
|
||||||
|
if game.stock_cards().is_empty() && !game.waste_cards().is_empty() {
|
||||||
|
let score_before_second_recycle = game.score;
|
||||||
|
game.draw().expect("second recycle should succeed");
|
||||||
|
assert_eq!(game.recycle_count, 2);
|
||||||
|
|
||||||
|
// The second recycle in Draw-1 mode costs −100.
|
||||||
|
let expected_after = (score_before_second_recycle - 100).max(0);
|
||||||
|
assert_eq!(
|
||||||
|
game.score, expected_after,
|
||||||
|
"second Draw-1 recycle must apply −100 penalty",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Undo: score should recover to (score_before_second_recycle − 15).max(0),
|
||||||
|
// NOT to (score_after_recycle − 15).max(0).
|
||||||
|
game.undo().expect("undo of second recycle should succeed");
|
||||||
|
let expected_after_undo = (score_before_second_recycle - 15).max(0);
|
||||||
|
assert_eq!(
|
||||||
|
game.score, expected_after_undo,
|
||||||
|
"undoing a penalised recycle must reverse the recycle penalty \
|
||||||
|
before applying the −15 undo penalty",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
game.recycle_count, 1,
|
||||||
|
"recycle_count must also be decremented on undo",
|
||||||
|
);
|
||||||
|
second_recycle_done = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let _ = game.draw();
|
||||||
|
}
|
||||||
|
assert!(second_recycle_done, "could not reach second recycle in test");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn take_from_foundation_allows_legal_return_move() {
|
fn take_from_foundation_allows_legal_return_move() {
|
||||||
let (mut game, from, to) = find_foundation_return_position()
|
let (mut game, from, to) = find_foundation_return_position()
|
||||||
|
|||||||
Reference in New Issue
Block a user