refactor(core): derive score/undo/recycle from upstream session stats
Replace the bespoke WXP scoring engine with the upstream card_game/klondike session stats, eliminating duplicated state that could drift from the single source of truth. score()/undo_count()/recycle_count() now read session.stats(); the -15 undo penalty is configured as SessionConfig::undo_penalty and applied by the upstream score formula. Save schema bumped v4 -> v5 (the three counters are no longer persisted -- they are rebuilt by replaying the forward instruction history on load). - Remove GameState fields score, undo_count, recycle_count (#87) - Remove score_history / is_recycle_history undo journal (#86) - Remove KlondikeAdapter::apply_undo_score and the score_for_* helpers, plus pre_instruction_score_delta / will_flip_tableau_source (#84) These three issues are a single atomic change: each removed field/helper is consumed by the same draw/apply_instruction/undo/serde/PartialEq paths, so they cannot compile or pass tests in isolation. Behaviour changes (intentional): the escalating recycle penalty and per-step score floor are gone (upstream linear scoring, floored once at 0); recycle_count is now cumulative; undo_count resets across save/load. Refs #84, #86, #87 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+171
-223
@@ -21,10 +21,14 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
|||||||
/// - 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: session-backed save files using local `SavedInstruction` mirror types
|
/// - v3: session-backed save files using local `SavedInstruction` mirror types
|
||||||
/// with u8 indices for enum variants.
|
/// with u8 indices for enum variants.
|
||||||
/// - v4 (current): `saved_moves` uses upstream `KlondikeInstruction` serde with
|
/// - v4: `saved_moves` uses upstream `KlondikeInstruction` serde with named enum
|
||||||
/// named enum variants (e.g. `"Foundation1"` instead of `0`). v3 files are
|
/// variants (e.g. `"Foundation1"` instead of `0`). v3 files are auto-migrated
|
||||||
/// auto-migrated on load via `AnyInstruction` transparent deserialization.
|
/// on load via `AnyInstruction` transparent deserialization.
|
||||||
pub const GAME_STATE_SCHEMA_VERSION: u32 = 4;
|
/// - v5 (current): `score`, `undo_count`, and `recycle_count` are no longer
|
||||||
|
/// persisted. They are derived from the upstream `card_game`/`klondike` session
|
||||||
|
/// stats, which are rebuilt by replaying `saved_moves` on load. Older files that
|
||||||
|
/// still carry those keys load fine — the extra fields are ignored.
|
||||||
|
pub const GAME_STATE_SCHEMA_VERSION: u32 = 5;
|
||||||
|
|
||||||
/// 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.
|
||||||
@@ -83,11 +87,8 @@ pub enum GameMode {
|
|||||||
struct PersistedGameState {
|
struct PersistedGameState {
|
||||||
pub draw_mode: DrawMode,
|
pub draw_mode: DrawMode,
|
||||||
pub mode: GameMode,
|
pub mode: GameMode,
|
||||||
pub score: i32,
|
|
||||||
pub elapsed_seconds: u64,
|
pub elapsed_seconds: u64,
|
||||||
pub seed: u64,
|
pub seed: u64,
|
||||||
pub undo_count: u32,
|
|
||||||
pub recycle_count: u32,
|
|
||||||
pub take_from_foundation: bool,
|
pub take_from_foundation: bool,
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
pub saved_moves: Vec<KlondikeInstruction>,
|
pub saved_moves: Vec<KlondikeInstruction>,
|
||||||
@@ -109,20 +110,19 @@ enum AnyInstruction {
|
|||||||
V3(SavedInstruction),
|
V3(SavedInstruction),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Input struct that accepts both schema v3 and v4 `saved_moves` formats.
|
/// Input struct that accepts schema v3, v4, and v5 `saved_moves` formats.
|
||||||
///
|
///
|
||||||
/// `recycle_count` is intentionally absent: the value is rebuilt from the
|
/// `score`, `undo_count`, and `recycle_count` are intentionally absent: all
|
||||||
/// instruction replay so that stale counts (from the pre-Phase-3 undo drift
|
/// three are rebuilt by replaying the instruction history through the upstream
|
||||||
/// bug) are corrected on load. Serde ignores the field in the JSON.
|
/// session stats. Older save files (v3/v4) still carry those keys; serde ignores
|
||||||
|
/// them.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
struct PersistedGameStateIn {
|
struct PersistedGameStateIn {
|
||||||
pub draw_mode: DrawMode,
|
pub draw_mode: DrawMode,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mode: GameMode,
|
pub mode: GameMode,
|
||||||
pub score: i32,
|
|
||||||
pub elapsed_seconds: u64,
|
pub elapsed_seconds: u64,
|
||||||
pub seed: u64,
|
pub seed: u64,
|
||||||
pub undo_count: u32,
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub take_from_foundation: bool,
|
pub take_from_foundation: bool,
|
||||||
#[serde(default = "schema_v1")]
|
#[serde(default = "schema_v1")]
|
||||||
@@ -161,33 +161,25 @@ pub struct TestPileState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Full state of an in-progress Klondike Solitaire game.
|
/// Full state of an in-progress Klondike Solitaire game.
|
||||||
|
///
|
||||||
|
/// Score, undo count, and recycle count are **not** stored here. They are
|
||||||
|
/// derived on demand from the upstream `card_game`/`klondike` session stats via
|
||||||
|
/// [`GameState::score`], [`GameState::undo_count`], and
|
||||||
|
/// [`GameState::recycle_count`]. The session is the single source of truth; the
|
||||||
|
/// −15 undo penalty is configured on the session ([`Self::session_config`]) and
|
||||||
|
/// applied by the upstream score formula.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct GameState {
|
pub struct GameState {
|
||||||
/// Top-level mode (Classic / Zen).
|
/// Top-level mode (Classic / Zen).
|
||||||
pub mode: GameMode,
|
pub mode: GameMode,
|
||||||
/// Current game score. Can be negative (undo penalties subtract from score).
|
|
||||||
pub score: i32,
|
|
||||||
/// Seconds elapsed since the game started, used for time-bonus scoring.
|
/// Seconds elapsed since the game started, used for time-bonus scoring.
|
||||||
pub elapsed_seconds: u64,
|
pub elapsed_seconds: u64,
|
||||||
/// RNG seed used to deal this game. Same seed always produces the same layout.
|
/// RNG seed used to deal this game. Same seed always produces the same layout.
|
||||||
pub seed: u64,
|
pub seed: u64,
|
||||||
/// Number of times `undo()` has been successfully invoked this game.
|
|
||||||
pub undo_count: u32,
|
|
||||||
/// Number of times the waste pile has been recycled back to stock this game.
|
|
||||||
pub recycle_count: u32,
|
|
||||||
/// When `true`, the player may move the top card of a foundation pile back
|
/// When `true`, the player may move the top card of a foundation pile back
|
||||||
/// onto a compatible tableau column.
|
/// onto a compatible tableau column.
|
||||||
pub take_from_foundation: bool,
|
pub take_from_foundation: bool,
|
||||||
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>,
|
||||||
@@ -197,14 +189,14 @@ impl PartialEq for GameState {
|
|||||||
fn eq(&self, other: &Self) -> bool {
|
fn eq(&self, other: &Self) -> bool {
|
||||||
self.draw_mode() == other.draw_mode()
|
self.draw_mode() == other.draw_mode()
|
||||||
&& self.mode == other.mode
|
&& self.mode == other.mode
|
||||||
&& self.score == other.score
|
&& self.score() == other.score()
|
||||||
&& self.move_count() == other.move_count()
|
&& self.move_count() == other.move_count()
|
||||||
&& self.elapsed_seconds == other.elapsed_seconds
|
&& self.elapsed_seconds == other.elapsed_seconds
|
||||||
&& self.seed == other.seed
|
&& self.seed == other.seed
|
||||||
&& self.is_won() == other.is_won()
|
&& self.is_won() == other.is_won()
|
||||||
&& self.is_auto_completable() == other.is_auto_completable()
|
&& self.is_auto_completable() == other.is_auto_completable()
|
||||||
&& self.undo_count == other.undo_count
|
&& self.undo_count() == other.undo_count()
|
||||||
&& self.recycle_count == other.recycle_count
|
&& self.recycle_count() == other.recycle_count()
|
||||||
&& self.take_from_foundation == other.take_from_foundation
|
&& self.take_from_foundation == other.take_from_foundation
|
||||||
&& self.stock_cards() == other.stock_cards()
|
&& self.stock_cards() == other.stock_cards()
|
||||||
&& self.waste_cards() == other.waste_cards()
|
&& self.waste_cards() == other.waste_cards()
|
||||||
@@ -227,11 +219,8 @@ impl Serialize for GameState {
|
|||||||
PersistedGameState {
|
PersistedGameState {
|
||||||
draw_mode: self.draw_mode(),
|
draw_mode: self.draw_mode(),
|
||||||
mode: self.mode,
|
mode: self.mode,
|
||||||
score: self.score,
|
|
||||||
elapsed_seconds: self.elapsed_seconds,
|
elapsed_seconds: self.elapsed_seconds,
|
||||||
seed: self.seed,
|
seed: self.seed,
|
||||||
undo_count: self.undo_count,
|
|
||||||
recycle_count: self.recycle_count,
|
|
||||||
take_from_foundation: self.take_from_foundation,
|
take_from_foundation: self.take_from_foundation,
|
||||||
schema_version: GAME_STATE_SCHEMA_VERSION,
|
schema_version: GAME_STATE_SCHEMA_VERSION,
|
||||||
saved_moves: self.saved_moves(),
|
saved_moves: self.saved_moves(),
|
||||||
@@ -244,10 +233,10 @@ 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 = PersistedGameStateIn::deserialize(deserializer)?;
|
let persisted = PersistedGameStateIn::deserialize(deserializer)?;
|
||||||
|
|
||||||
// Accept v3 (legacy u8-index format, auto-migrated) and v4 (current,
|
// Accept v3 (legacy u8-index format, auto-migrated), v4 (upstream
|
||||||
// upstream named-variant serde). Reject everything else.
|
// named-variant serde), and v5 (current, derived stats). Reject the rest.
|
||||||
match persisted.schema_version {
|
match persisted.schema_version {
|
||||||
3 | 4 => {}
|
3..=5 => {}
|
||||||
v => {
|
v => {
|
||||||
return Err(serde::de::Error::custom(format!(
|
return Err(serde::de::Error::custom(format!(
|
||||||
"unsupported GameState schema version {v}"
|
"unsupported GameState schema version {v}"
|
||||||
@@ -257,30 +246,22 @@ impl<'de> Deserialize<'de> for GameState {
|
|||||||
|
|
||||||
let mut game = Self {
|
let mut game = Self {
|
||||||
mode: persisted.mode,
|
mode: persisted.mode,
|
||||||
score: persisted.score,
|
|
||||||
elapsed_seconds: persisted.elapsed_seconds,
|
elapsed_seconds: persisted.elapsed_seconds,
|
||||||
seed: persisted.seed,
|
seed: persisted.seed,
|
||||||
undo_count: persisted.undo_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,
|
||||||
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Replay the saved instruction history. The upstream session tracks
|
||||||
|
// score components and recycle_count as it processes each move, so the
|
||||||
|
// derived stats are correct once replay completes. `undo_count()` resets
|
||||||
|
// to 0 across save/load because undone moves are not part of the saved
|
||||||
|
// forward history.
|
||||||
let replay_config = Self::replay_config(persisted.draw_mode);
|
let replay_config = Self::replay_config(persisted.draw_mode);
|
||||||
for any in persisted.saved_moves {
|
for any in persisted.saved_moves {
|
||||||
// AnyInstruction::V4 arrives directly from upstream serde (schema v4).
|
// AnyInstruction::V4 arrives directly from upstream serde (schema v4+).
|
||||||
// AnyInstruction::V3 was serialised with u8 indices (schema v3) and is
|
// AnyInstruction::V3 was serialised with u8 indices (schema v3) and is
|
||||||
// converted here via the existing TryFrom impl.
|
// converted here via the existing TryFrom impl.
|
||||||
let instruction = match any {
|
let instruction = match any {
|
||||||
@@ -290,12 +271,6 @@ impl<'de> Deserialize<'de> for GameState {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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()
|
||||||
@@ -307,11 +282,6 @@ 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(game)
|
Ok(game)
|
||||||
@@ -328,15 +298,10 @@ impl GameState {
|
|||||||
pub fn new_with_mode(seed: u64, draw_mode: DrawMode, mode: GameMode) -> Self {
|
pub fn new_with_mode(seed: u64, draw_mode: DrawMode, mode: GameMode) -> Self {
|
||||||
Self {
|
Self {
|
||||||
mode,
|
mode,
|
||||||
score: 0,
|
|
||||||
elapsed_seconds: 0,
|
elapsed_seconds: 0,
|
||||||
seed,
|
seed,
|
||||||
undo_count: 0,
|
|
||||||
recycle_count: 0,
|
|
||||||
take_from_foundation: true,
|
take_from_foundation: true,
|
||||||
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,
|
||||||
}
|
}
|
||||||
@@ -351,6 +316,43 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Current game score, derived from the upstream session stats.
|
||||||
|
///
|
||||||
|
/// The upstream score is a linear sum of move-type counts (foundation/
|
||||||
|
/// tableau/flip deltas) plus `undos * undo_penalty` (−15 each). Floored at 0
|
||||||
|
/// so the displayed score is never negative. Returns 0 in [`GameMode::Zen`],
|
||||||
|
/// where scoring is suppressed entirely.
|
||||||
|
///
|
||||||
|
/// Note: the win-time bonus (`compute_time_bonus`) is layered on by the
|
||||||
|
/// engine's win-summary, not included here — this is the in-play base score.
|
||||||
|
pub fn score(&self) -> i32 {
|
||||||
|
if self.mode == GameMode::Zen {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
self.session
|
||||||
|
.state()
|
||||||
|
.score(self.session.stats(), self.session.config())
|
||||||
|
.max(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of times `undo()` has been successfully invoked this game, read
|
||||||
|
/// from the upstream session stats.
|
||||||
|
///
|
||||||
|
/// Resets to 0 across a save/load cycle: only the forward instruction
|
||||||
|
/// history is persisted, so undone moves leave no trace to replay.
|
||||||
|
pub fn undo_count(&self) -> u32 {
|
||||||
|
self.session.stats().undos()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of times the waste pile has been recycled back to stock this game,
|
||||||
|
/// read from the upstream session stats.
|
||||||
|
///
|
||||||
|
/// This is a **cumulative** count — the upstream stat is not rolled back when
|
||||||
|
/// a recycle is undone, so it reflects total recycles ever performed.
|
||||||
|
pub fn recycle_count(&self) -> u32 {
|
||||||
|
self.session.stats().stats().recycle_count()
|
||||||
|
}
|
||||||
|
|
||||||
/// Total moves made this game (draws, recycles, and card moves), derived
|
/// Total moves made this game (draws, recycles, and card moves), derived
|
||||||
/// from the session's instruction history length.
|
/// from the session's instruction history length.
|
||||||
pub fn move_count(&self) -> u32 {
|
pub fn move_count(&self) -> u32 {
|
||||||
@@ -394,7 +396,9 @@ impl GameState {
|
|||||||
fn session_config(draw_mode: DrawMode) -> SessionConfig<KlondikeConfig> {
|
fn session_config(draw_mode: DrawMode) -> SessionConfig<KlondikeConfig> {
|
||||||
SessionConfig {
|
SessionConfig {
|
||||||
inner: Self::replay_config(draw_mode),
|
inner: Self::replay_config(draw_mode),
|
||||||
undo_penalty: 0,
|
// The −15 WXP undo penalty is now applied by the upstream score
|
||||||
|
// formula (`undos * undo_penalty`) rather than by hand in `undo()`.
|
||||||
|
undo_penalty: -15,
|
||||||
..SessionConfig::default()
|
..SessionConfig::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -601,6 +605,67 @@ impl GameState {
|
|||||||
state.move_count = Some(move_count);
|
state.move_count = Some(move_count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test-support helper: perform `n` real undos so [`Self::undo_count`]
|
||||||
|
/// reports `n`. Each iteration draws a card then immediately undoes it,
|
||||||
|
/// leaving the board unchanged but advancing the upstream `undos` counter.
|
||||||
|
///
|
||||||
|
/// Since `score`/`undo_count`/`recycle_count` are now derived from the
|
||||||
|
/// session stats rather than stored fields, tests drive the real session to
|
||||||
|
/// reach a desired stat instead of assigning the value directly.
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
|
pub fn force_test_undos(&mut self, n: u32) {
|
||||||
|
for _ in 0..n {
|
||||||
|
if self.draw().is_ok() {
|
||||||
|
let _ = self.undo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test-support helper: perform `n` real stock recycles so
|
||||||
|
/// [`Self::recycle_count`] reports `n`. Draws until the stock empties, then
|
||||||
|
/// draws once more to recycle, repeated `n` times.
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
|
pub fn force_test_recycles(&mut self, n: u32) {
|
||||||
|
for _ in 0..n {
|
||||||
|
let mut guard = 0;
|
||||||
|
while !self.stock_cards().is_empty() && guard < 200 {
|
||||||
|
guard += 1;
|
||||||
|
if self.draw().is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Stock now empty (waste full) — this draw recycles waste → stock.
|
||||||
|
let _ = self.draw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test-support helper: drive real moves until [`Self::score`] reaches at
|
||||||
|
/// least `target`, returning the resulting score. Prefers foundation moves
|
||||||
|
/// (+10 each) and falls back to the solver-priority move, so a modest target
|
||||||
|
/// is reached within a handful of moves on a typical deal.
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
|
pub fn force_test_score(&mut self, target: i32) -> i32 {
|
||||||
|
let mut guard = 0;
|
||||||
|
while self.score() < target && !self.is_won() && guard < 4000 {
|
||||||
|
guard += 1;
|
||||||
|
let instructions = self.possible_instructions();
|
||||||
|
let next = instructions
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.find(|i| matches!(i, KlondikeInstruction::DstFoundation(_)))
|
||||||
|
.or_else(|| instructions.into_iter().next());
|
||||||
|
match next {
|
||||||
|
Some(instruction) => {
|
||||||
|
if self.apply_instruction(instruction).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.score()
|
||||||
|
}
|
||||||
|
|
||||||
/// Test-support helper: override face-down stock cards returned by
|
/// Test-support helper: override face-down stock cards returned by
|
||||||
/// [`Self::stock_cards`].
|
/// [`Self::stock_cards`].
|
||||||
#[cfg(feature = "test-support")]
|
#[cfg(feature = "test-support")]
|
||||||
@@ -672,79 +737,6 @@ impl GameState {
|
|||||||
.ok_or_else(|| MoveError::RuleViolation("invalid tableau card count".into()))
|
.ok_or_else(|| MoveError::RuleViolation("invalid tableau card count".into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn will_flip_tableau_source(&self, from: KlondikePile, count: usize) -> bool {
|
|
||||||
let KlondikePile::Tableau(_) = from else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let pile = self.pile(from);
|
|
||||||
if pile.is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
pile.len() > count && !pile[pile.len() - count - 1].1
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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,
|
||||||
@@ -899,19 +891,10 @@ impl GameState {
|
|||||||
return Err(MoveError::StockEmpty);
|
return Err(MoveError::StockEmpty);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (score_delta, is_recycle) =
|
// The session tracks score components and recycle_count as it processes
|
||||||
self.pre_instruction_score_delta(KlondikeInstruction::RotateStock);
|
// the instruction; no local bookkeeping required.
|
||||||
|
|
||||||
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 is_recycle {
|
|
||||||
self.recycle_count = self.recycle_count.saturating_add(1);
|
|
||||||
}
|
|
||||||
self.score = (self.score + score_delta).max(0);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -949,8 +932,9 @@ impl GameState {
|
|||||||
/// instruction form — solver hints, auto-complete, replay, and the property
|
/// instruction form — solver hints, auto-complete, replay, and the property
|
||||||
/// tests. User drag-and-drop enters through [`Self::move_cards`], which is a
|
/// tests. User drag-and-drop enters through [`Self::move_cards`], which is a
|
||||||
/// thin adapter that converts pile coordinates to an instruction and
|
/// thin adapter that converts pile coordinates to an instruction and
|
||||||
/// delegates here, so the move bookkeeping (rule validation, score history,
|
/// delegates here, so the move bookkeeping (rule validation, the undo
|
||||||
/// recycle accounting, undo snapshot) lives in exactly one place.
|
/// snapshot, and the session's score/recycle stats) lives in exactly one
|
||||||
|
/// place.
|
||||||
///
|
///
|
||||||
/// Returns [`MoveError::RuleViolation`] if the instruction is illegal in the
|
/// Returns [`MoveError::RuleViolation`] if the instruction is illegal in the
|
||||||
/// current position, or [`MoveError::GameAlreadyWon`] once the game is over.
|
/// current position, or [`MoveError::GameAlreadyWon`] once the game is over.
|
||||||
@@ -972,21 +956,17 @@ impl GameState {
|
|||||||
return Err(MoveError::RuleViolation("move violates rules".into()));
|
return Err(MoveError::RuleViolation("move violates rules".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let (score_delta, is_recycle) = self.pre_instruction_score_delta(instruction);
|
// The session records the move snapshot and updates score/recycle stats.
|
||||||
|
|
||||||
self.score_history.push(self.score);
|
|
||||||
self.is_recycle_history.push(is_recycle);
|
|
||||||
|
|
||||||
self.session.process_instruction(instruction);
|
self.session.process_instruction(instruction);
|
||||||
|
|
||||||
if is_recycle {
|
|
||||||
self.recycle_count = self.recycle_count.saturating_add(1);
|
|
||||||
}
|
|
||||||
self.score = (self.score + score_delta).max(0);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore the most recent undo snapshot and apply the undo score penalty (-15).
|
/// Restore the most recent undo snapshot.
|
||||||
|
///
|
||||||
|
/// The −15 undo penalty is applied by the upstream score formula
|
||||||
|
/// (`undos * undo_penalty`), and the session increments its `undos` counter,
|
||||||
|
/// so this method only has to delegate to [`Session::undo`] after the mode
|
||||||
|
/// guards. See [`Self::score`] / [`Self::undo_count`] for the derived values.
|
||||||
pub fn undo(&mut self) -> Result<(), MoveError> {
|
pub fn undo(&mut self) -> Result<(), MoveError> {
|
||||||
if self.is_won() {
|
if self.is_won() {
|
||||||
return Err(MoveError::GameAlreadyWon);
|
return Err(MoveError::GameAlreadyWon);
|
||||||
@@ -1000,23 +980,7 @@ impl GameState {
|
|||||||
return Err(MoveError::UndoStackEmpty);
|
return Err(MoveError::UndoStackEmpty);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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();
|
||||||
|
|
||||||
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.undo_count = self.undo_count.saturating_add(1);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1210,56 +1174,40 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recycle_count_decrements_when_recycle_is_undone() {
|
fn recycle_count_is_cumulative_and_not_rolled_back_on_undo() {
|
||||||
|
// Upstream `KlondikeStats::recycle_count` counts every recycle ever
|
||||||
|
// performed; it is intentionally NOT decremented when a recycle is
|
||||||
|
// undone (the session restores the board but leaves the stat). This is
|
||||||
|
// the post-migration semantics: a cumulative count, not a net count.
|
||||||
let mut game = game_at_first_recycle().expect("could not reach recycle");
|
let mut game = game_at_first_recycle().expect("could not reach recycle");
|
||||||
let count_after_recycle = game.recycle_count;
|
assert_eq!(game.recycle_count(), 1, "first recycle should give count=1");
|
||||||
assert_eq!(count_after_recycle, 1, "first recycle should give count=1");
|
|
||||||
game.undo().expect("undo should succeed");
|
game.undo().expect("undo should succeed");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
game.recycle_count, 0,
|
game.recycle_count(),
|
||||||
"recycle_count must decrement back to 0 after undoing the recycle",
|
1,
|
||||||
|
"recycle_count is cumulative: undoing a recycle does not roll it back",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn score_recycle_penalty_is_reversed_on_undo() {
|
fn undo_applies_minus_15_penalty_via_upstream_score() {
|
||||||
// Reach the second recycle (count=2, Draw-1) so there is a −100 penalty.
|
// A foundation move scores +10 upstream; undoing it nets the move score
|
||||||
let mut game = game_at_first_recycle().expect("could not reach first recycle");
|
// back to 0 and adds the −15 undo penalty, which `score()` floors at 0.
|
||||||
|
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||||
// Draw until stock is empty again so we can do a second recycle.
|
// Find and play any scoring move, then undo it.
|
||||||
let mut second_recycle_done = false;
|
let scoring_move = game
|
||||||
for _ in 0..200 {
|
.possible_instructions()
|
||||||
if game.stock_cards().is_empty() && !game.waste_cards().is_empty() {
|
.into_iter()
|
||||||
let score_before_second_recycle = game.score;
|
.find(|i| matches!(i, KlondikeInstruction::DstFoundation(_)));
|
||||||
game.draw().expect("second recycle should succeed");
|
if let Some(instruction) = scoring_move {
|
||||||
assert_eq!(game.recycle_count, 2);
|
game.apply_instruction(instruction)
|
||||||
|
.expect("scoring move should apply");
|
||||||
// The second recycle in Draw-1 mode costs −100.
|
assert!(game.score() > 0, "a foundation move should raise the score");
|
||||||
let expected_after = (score_before_second_recycle - 100).max(0);
|
game.undo().expect("undo should succeed");
|
||||||
assert_eq!(
|
assert_eq!(game.undo_count(), 1, "undo increments the upstream counter");
|
||||||
game.score, expected_after,
|
// base score returns to 0, minus 15 undo penalty, floored at 0.
|
||||||
"second Draw-1 recycle must apply −100 penalty",
|
assert_eq!(game.score(), 0, "score floors at 0 after the undo 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]
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ use klondike::{
|
|||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::game_state::GameMode;
|
|
||||||
|
|
||||||
/// Whether cards are drawn one at a time or three at a time from the stock.
|
/// Whether cards are drawn one at a time or three at a time from the stock.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum DrawMode {
|
pub enum DrawMode {
|
||||||
@@ -51,116 +49,6 @@ impl KlondikeAdapter {
|
|||||||
scoring: ScoringConfig::DEFAULT,
|
scoring: ScoringConfig::DEFAULT,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Scoring helpers ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Score delta for a card move.
|
|
||||||
///
|
|
||||||
/// Reads from [`ScoringConfig`] (WXP Standard values):
|
|
||||||
/// - Any pile → Foundation: +10
|
|
||||||
/// - Waste → Tableau: +5
|
|
||||||
/// - Foundation → Tableau: −15
|
|
||||||
/// - All other moves: 0
|
|
||||||
pub fn score_for_move(from: &KlondikePile, to: &KlondikePile) -> i32 {
|
|
||||||
let sc = ScoringConfig::DEFAULT;
|
|
||||||
match (from, to) {
|
|
||||||
(_, KlondikePile::Foundation(_)) => sc.move_to_foundation,
|
|
||||||
(KlondikePile::Stock, KlondikePile::Tableau(_)) => sc.move_to_tableau,
|
|
||||||
(KlondikePile::Foundation(_), KlondikePile::Tableau(_)) => sc.move_from_foundation,
|
|
||||||
_ => 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Score delta for exposing a face-down tableau card: +5.
|
|
||||||
pub fn score_for_flip() -> i32 {
|
|
||||||
ScoringConfig::DEFAULT.flip_up_bonus
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Score delta for undo: −15.
|
|
||||||
///
|
|
||||||
/// This is a Ferrous product policy — `card_game::SessionConfig::undo_penalty`
|
|
||||||
/// defaults to 0; the solver overrides it to 0 explicitly. The −15 WXP penalty
|
|
||||||
/// is applied here by `GameState` on every undo.
|
|
||||||
pub fn score_for_undo() -> i32 {
|
|
||||||
-15
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Score delta for recycling waste → stock.
|
|
||||||
///
|
|
||||||
/// [`ScoringConfig::recycle`] is a flat delta (default 0 = always free).
|
|
||||||
/// WXP allows a fixed number of free recycles before charging a penalty,
|
|
||||||
/// which the upstream library cannot express with a single delta:
|
|
||||||
///
|
|
||||||
/// | Mode | Free recycles | Penalty per extra recycle |
|
|
||||||
/// |---|---|---|
|
|
||||||
/// | Draw-1 | 1 | −100 |
|
|
||||||
/// | Draw-3 | 3 | −20 |
|
|
||||||
///
|
|
||||||
/// **Design note:** recycling is *never* blocked — only penalised.
|
|
||||||
/// This is intentional: Draw-1 can be played indefinitely with the score
|
|
||||||
/// dropping toward zero after the first free recycle. A hard cap would
|
|
||||||
/// create unwinnable positions when the solver cannot find a path without
|
|
||||||
/// additional recycling. Zen mode suppresses the penalty entirely.
|
|
||||||
///
|
|
||||||
/// `recycle_count` must be the new total **after** this recycle.
|
|
||||||
pub fn score_for_recycle(recycle_count: u32, is_draw_three: bool) -> i32 {
|
|
||||||
if is_draw_three {
|
|
||||||
if recycle_count > 3 { -20 } else { 0 }
|
|
||||||
} else if recycle_count > 1 {
|
|
||||||
-100
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Score delta for a card move, accounting for game mode.
|
|
||||||
///
|
|
||||||
/// Returns 0 in [`GameMode::Zen`] (all scoring suppressed).
|
|
||||||
pub fn score_for_move_with_mode(from: &KlondikePile, to: &KlondikePile, mode: GameMode) -> i32 {
|
|
||||||
if mode == GameMode::Zen {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
Self::score_for_move(from, to)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Score delta for exposing a face-down card, accounting for game mode.
|
|
||||||
///
|
|
||||||
/// Returns 0 in [`GameMode::Zen`].
|
|
||||||
pub fn score_for_flip_with_mode(mode: GameMode) -> i32 {
|
|
||||||
if mode == GameMode::Zen {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
Self::score_for_flip()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compute the new score after an undo, accounting for game mode.
|
|
||||||
///
|
|
||||||
/// In [`GameMode::Zen`] the score is always 0. Otherwise applies the
|
|
||||||
/// −15 undo penalty and clamps to 0 via [`Self::score_for_undo`].
|
|
||||||
pub fn apply_undo_score(snapshot_score: i32, mode: GameMode) -> i32 {
|
|
||||||
if mode == GameMode::Zen {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
(snapshot_score + Self::score_for_undo()).max(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Score delta for recycling, accounting for game mode.
|
|
||||||
///
|
|
||||||
/// Returns 0 in [`GameMode::Zen`].
|
|
||||||
pub fn score_for_recycle_with_mode(
|
|
||||||
recycle_count: u32,
|
|
||||||
is_draw_three: bool,
|
|
||||||
mode: GameMode,
|
|
||||||
) -> i32 {
|
|
||||||
if mode == GameMode::Zen {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
Self::score_for_recycle(recycle_count, is_draw_three)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert a zero-based tableau index (0..=6) into [`Tableau`].
|
/// Convert a zero-based tableau index (0..=6) into [`Tableau`].
|
||||||
|
|||||||
@@ -497,15 +497,15 @@ mod tests {
|
|||||||
/// replays all `saved_moves` to reconstruct every pile.
|
/// replays all `saved_moves` to reconstruct every pile.
|
||||||
///
|
///
|
||||||
/// A fresh-game test (zero moves) never exercises that replay path, so this
|
/// A fresh-game test (zero moves) never exercises that replay path, so this
|
||||||
/// test plays several real moves — including an undo — before saving, then
|
/// test plays several real moves — including an undo — before saving.
|
||||||
/// asserts the full pile layout round-trips exactly.
|
|
||||||
///
|
///
|
||||||
/// `GameState::PartialEq` covers stock, waste, all four foundations, all
|
/// Since schema v5 no longer persists `score`/`undo_count`/`recycle_count`
|
||||||
/// seven tableau columns, `score`, `move_count`, `undo_count`, and
|
/// (they are derived from the replayed session stats), round-trip fidelity is
|
||||||
/// `recycle_count`. Any breakage in the upstream serde or replay path
|
/// verified by **re-save idempotency**: reloading the save and serialising it
|
||||||
/// will cause at least one pile to disagree.
|
/// again must reproduce byte-identical JSON. `undo_count` deliberately resets
|
||||||
|
/// to 0 on load because only the forward instruction history is persisted.
|
||||||
#[test]
|
#[test]
|
||||||
fn game_state_v4_mid_game_round_trip() {
|
fn game_state_v5_mid_game_round_trip() {
|
||||||
use solitaire_core::KlondikeInstruction;
|
use solitaire_core::KlondikeInstruction;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
@@ -546,19 +546,40 @@ mod tests {
|
|||||||
|
|
||||||
save_game_state_to(&path, &gs).expect("save");
|
save_game_state_to(&path, &gs).expect("save");
|
||||||
|
|
||||||
// Verify the file contains the v4 schema marker (tolerates pretty-print whitespace).
|
// Verify the file carries the v5 schema marker.
|
||||||
let json = fs::read_to_string(&path).expect("read json");
|
let json = fs::read_to_string(&path).expect("read json");
|
||||||
assert!(
|
assert!(
|
||||||
json.contains("schema_version") && json.contains('4') && !json.contains(": 3"),
|
json.contains("\"schema_version\"") && json.contains('5'),
|
||||||
"saved file must use schema version 4",
|
"saved file must use schema version 5",
|
||||||
);
|
);
|
||||||
|
|
||||||
let loaded = load_game_state_from(&path)
|
let loaded = load_game_state_from(&path)
|
||||||
.expect("a valid in-progress game must load without error");
|
.expect("a valid in-progress game must load without error");
|
||||||
|
|
||||||
|
// The forward instruction history round-trips, so the reconstructed board
|
||||||
|
// re-serialises to byte-identical JSON.
|
||||||
|
let path_reload = gs_path("v5_mid_game_reload");
|
||||||
|
let _ = fs::remove_file(&path_reload);
|
||||||
|
save_game_state_to(&path_reload, &loaded).expect("re-save loaded");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
loaded, gs,
|
fs::read_to_string(&path).expect("read original save"),
|
||||||
"all pile layouts and counters must be identical after schema-v4 round-trip",
|
fs::read_to_string(&path_reload).expect("read re-saved"),
|
||||||
|
"re-saving the loaded game must reproduce the original save exactly",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Derived board reads match the live game (move count + recycle count are
|
||||||
|
// both rebuilt from the replayed forward history).
|
||||||
|
assert_eq!(loaded.move_count(), gs.move_count(), "move_count round-trips");
|
||||||
|
assert_eq!(
|
||||||
|
loaded.recycle_count(),
|
||||||
|
gs.recycle_count(),
|
||||||
|
"recycle_count round-trips",
|
||||||
|
);
|
||||||
|
// undo_count is intentionally not persisted: it resets to 0 on load.
|
||||||
|
assert_eq!(
|
||||||
|
loaded.undo_count(),
|
||||||
|
0,
|
||||||
|
"undo_count resets across save/load under schema v5",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -176,9 +176,9 @@ fn evaluate_on_win(
|
|||||||
daily_challenge_streak: progress.0.daily_challenge_streak,
|
daily_challenge_streak: progress.0.daily_challenge_streak,
|
||||||
last_win_score: ev.score,
|
last_win_score: ev.score,
|
||||||
last_win_time_seconds: ev.time_seconds,
|
last_win_time_seconds: ev.time_seconds,
|
||||||
last_win_used_undo: game.0.undo_count > 0,
|
last_win_used_undo: game.0.undo_count() > 0,
|
||||||
wall_clock_hour: Some(Local::now().hour()),
|
wall_clock_hour: Some(Local::now().hour()),
|
||||||
last_win_recycle_count: game.0.recycle_count,
|
last_win_recycle_count: game.0.recycle_count(),
|
||||||
last_win_is_zen: game.0.mode == solitaire_core::game_state::GameMode::Zen,
|
last_win_is_zen: game.0.mode == solitaire_core::game_state::GameMode::Zen,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -779,7 +779,7 @@ mod tests {
|
|||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<GameStateResource>()
|
.resource_mut::<GameStateResource>()
|
||||||
.0
|
.0
|
||||||
.undo_count = 1;
|
.force_test_undos(1);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 1000,
|
score: 1000,
|
||||||
|
|||||||
@@ -920,7 +920,7 @@ fn handle_move(
|
|||||||
changed.write(StateChangedEvent);
|
changed.write(StateChangedEvent);
|
||||||
if !was_won && game.0.is_won() {
|
if !was_won && game.0.is_won() {
|
||||||
won.write(GameWonEvent {
|
won.write(GameWonEvent {
|
||||||
score: game.0.score,
|
score: game.0.score(),
|
||||||
time_seconds: game.0.elapsed_seconds,
|
time_seconds: game.0.elapsed_seconds,
|
||||||
});
|
});
|
||||||
// Delete the saved state — a won game should not be resumed.
|
// Delete the saved state — a won game should not be resumed.
|
||||||
@@ -1117,7 +1117,7 @@ fn check_no_moves(
|
|||||||
// Only spawn the overlay if one does not already exist, and no other
|
// Only spawn the overlay if one does not already exist, and no other
|
||||||
// modal scrim is currently open (global ModalScrim guard).
|
// modal scrim is currently open (global ModalScrim guard).
|
||||||
if game_over_screens.is_empty() && scrims.is_empty() {
|
if game_over_screens.is_empty() && scrims.is_empty() {
|
||||||
spawn_game_over_screen(&mut commands, game.0.score, font_res.as_deref());
|
spawn_game_over_screen(&mut commands, game.0.score(), font_res.as_deref());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1818,7 +1818,7 @@ fn detect_score_change(
|
|||||||
score_q: Query<Entity, With<HudScore>>,
|
score_q: Query<Entity, With<HudScore>>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
let current = game.0.score;
|
let current = game.0.score();
|
||||||
let delta = current - prev.0;
|
let delta = current - prev.0;
|
||||||
prev.0 = current;
|
prev.0 = current;
|
||||||
if delta <= 0 {
|
if delta <= 0 {
|
||||||
@@ -2275,7 +2275,7 @@ fn update_hud(
|
|||||||
**t = if is_zen {
|
**t = if is_zen {
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
format!("Score: {}", g.score)
|
format!("Score: {}", g.score())
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if let Ok(mut t) = moves_q.single_mut() {
|
if let Ok(mut t) = moves_q.single_mut() {
|
||||||
@@ -2311,7 +2311,7 @@ fn update_hud(
|
|||||||
|
|
||||||
// --- Undo count ---
|
// --- Undo count ---
|
||||||
if let Ok((mut t, mut color)) = undos_q.single_mut() {
|
if let Ok((mut t, mut color)) = undos_q.single_mut() {
|
||||||
let count = g.undo_count;
|
let count = g.undo_count();
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
**t = String::new();
|
**t = String::new();
|
||||||
*color = TextColor(TEXT_PRIMARY);
|
*color = TextColor(TEXT_PRIMARY);
|
||||||
@@ -2325,8 +2325,8 @@ fn update_hud(
|
|||||||
|
|
||||||
// --- Recycle counter (both modes, hidden until first recycle) ---
|
// --- Recycle counter (both modes, hidden until first recycle) ---
|
||||||
if let Ok(mut t) = recycles_q.single_mut() {
|
if let Ok(mut t) = recycles_q.single_mut() {
|
||||||
**t = if g.recycle_count > 0 {
|
**t = if g.recycle_count() > 0 {
|
||||||
format!("Recycles: {}", g.recycle_count)
|
format!("Recycles: {}", g.recycle_count())
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
@@ -2763,9 +2763,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn score_reflects_game_state() {
|
fn score_reflects_game_state() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.score = 750;
|
let score = app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(20);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(read_hud_text::<HudScore>(&mut app), "Score: 750");
|
assert_eq!(read_hud_text::<HudScore>(&mut app), format!("Score: {score}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2795,7 +2795,6 @@ mod tests {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen);
|
GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen);
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.score = 999;
|
|
||||||
app.update();
|
app.update();
|
||||||
// Zen mode spec: "No score display" → text must be empty.
|
// Zen mode spec: "No score display" → text must be empty.
|
||||||
assert_eq!(read_hud_text::<HudScore>(&mut app), "");
|
assert_eq!(read_hud_text::<HudScore>(&mut app), "");
|
||||||
@@ -2916,7 +2915,7 @@ mod tests {
|
|||||||
fn challenge_hud_empty_when_no_daily_resource() {
|
fn challenge_hud_empty_when_no_daily_resource() {
|
||||||
// No DailyChallengeResource inserted → HudChallenge must be empty.
|
// No DailyChallengeResource inserted → HudChallenge must be empty.
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.score = 1; // force change
|
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
||||||
}
|
}
|
||||||
@@ -2931,7 +2930,7 @@ mod tests {
|
|||||||
target_score: None,
|
target_score: None,
|
||||||
max_time_secs: Some(300),
|
max_time_secs: Some(300),
|
||||||
});
|
});
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.score = 1; // force change
|
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00");
|
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00");
|
||||||
}
|
}
|
||||||
@@ -2946,7 +2945,7 @@ mod tests {
|
|||||||
target_score: Some(4000),
|
target_score: Some(4000),
|
||||||
max_time_secs: None,
|
max_time_secs: None,
|
||||||
});
|
});
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.score = 1;
|
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts");
|
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts");
|
||||||
}
|
}
|
||||||
@@ -2984,7 +2983,7 @@ mod tests {
|
|||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<GameStateResource>()
|
.resource_mut::<GameStateResource>()
|
||||||
.0
|
.0
|
||||||
.undo_count = 3;
|
.force_test_undos(3);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(read_hud_text::<HudUndos>(&mut app), "Undos: 3");
|
assert_eq!(read_hud_text::<HudUndos>(&mut app), "Undos: 3");
|
||||||
}
|
}
|
||||||
@@ -3057,7 +3056,7 @@ mod tests {
|
|||||||
fn recycles_hud_shows_count_draw_three() {
|
fn recycles_hud_shows_count_draw_three() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
let mut gs = GameState::new(42, DrawMode::DrawThree);
|
let mut gs = GameState::new(42, DrawMode::DrawThree);
|
||||||
gs.recycle_count = 3;
|
gs.force_test_recycles(3);
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 3");
|
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 3");
|
||||||
@@ -3068,7 +3067,7 @@ mod tests {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
// Draw-One with recycle_count > 0 must now show the counter too.
|
// Draw-One with recycle_count > 0 must now show the counter too.
|
||||||
let mut gs = GameState::new(42, DrawMode::DrawOne);
|
let mut gs = GameState::new(42, DrawMode::DrawOne);
|
||||||
gs.recycle_count = 2;
|
gs.force_test_recycles(2);
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 2");
|
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 2");
|
||||||
@@ -3108,7 +3107,7 @@ mod tests {
|
|||||||
set_manual_time_step(&mut app, 0.0);
|
set_manual_time_step(&mut app, 0.0);
|
||||||
// Initial state has score=0; bumping by 50 (the threshold)
|
// Initial state has score=0; bumping by 50 (the threshold)
|
||||||
// is the smallest jump that triggers the floater.
|
// is the smallest jump that triggers the floater.
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.score = 50;
|
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
// One floater should now exist.
|
// One floater should now exist.
|
||||||
@@ -3129,7 +3128,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn score_floater_despawns_after_full_lifetime() {
|
fn score_floater_despawns_after_full_lifetime() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.score = 100;
|
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(count_with::<ScoreFloater>(&mut app), 1);
|
assert_eq!(count_with::<ScoreFloater>(&mut app), 1);
|
||||||
|
|
||||||
@@ -3155,7 +3154,7 @@ mod tests {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
// +5 mirrors a single tableau-to-foundation move; well below
|
// +5 mirrors a single tableau-to-foundation move; well below
|
||||||
// the 50-point threshold so the floater path stays dormant.
|
// the 50-point threshold so the floater path stays dormant.
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.score = 5;
|
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(5);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
count_with::<ScoreFloater>(&mut app),
|
count_with::<ScoreFloater>(&mut app),
|
||||||
@@ -3231,7 +3230,7 @@ mod tests {
|
|||||||
..Settings::default()
|
..Settings::default()
|
||||||
}));
|
}));
|
||||||
// +100 would normally create both a ScorePulse and a ScoreFloater.
|
// +100 would normally create both a ScorePulse and a ScoreFloater.
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.score = 100;
|
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
count_with::<ScorePulse>(&mut app),
|
count_with::<ScorePulse>(&mut app),
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ fn award_xp_on_win(
|
|||||||
mut progress: ResMut<ProgressResource>,
|
mut progress: ResMut<ProgressResource>,
|
||||||
) {
|
) {
|
||||||
for ev in wins.read() {
|
for ev in wins.read() {
|
||||||
let used_undo = game.0.undo_count > 0;
|
let used_undo = game.0.undo_count() > 0;
|
||||||
let amount = xp_for_win(ev.time_seconds, used_undo);
|
let amount = xp_for_win(ev.time_seconds, used_undo);
|
||||||
let prev_level = progress.0.add_xp(amount);
|
let prev_level = progress.0.add_xp(amount);
|
||||||
xp_awarded.write(XpAwardedEvent { amount });
|
xp_awarded.write(XpAwardedEvent { amount });
|
||||||
@@ -151,7 +151,7 @@ mod tests {
|
|||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<GameStateResource>()
|
.resource_mut::<GameStateResource>()
|
||||||
.0
|
.0
|
||||||
.undo_count = 1;
|
.force_test_undos(1);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ fn evaluate_weekly_goals(
|
|||||||
for ev in events.drain(..) {
|
for ev in events.drain(..) {
|
||||||
let ctx = WeeklyGoalContext {
|
let ctx = WeeklyGoalContext {
|
||||||
time_seconds: ev.time_seconds,
|
time_seconds: ev.time_seconds,
|
||||||
used_undo: game.0.undo_count > 0,
|
used_undo: game.0.undo_count() > 0,
|
||||||
draw_mode: game.0.draw_mode(),
|
draw_mode: game.0.draw_mode(),
|
||||||
};
|
};
|
||||||
for def in WEEKLY_GOALS {
|
for def in WEEKLY_GOALS {
|
||||||
@@ -177,7 +177,7 @@ mod tests {
|
|||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<GameStateResource>()
|
.resource_mut::<GameStateResource>()
|
||||||
.0
|
.0
|
||||||
.undo_count = 1;
|
.force_test_undos(1);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
|
|||||||
@@ -472,14 +472,14 @@ fn cache_win_data(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let used_undo = game.0.undo_count > 0;
|
let used_undo = game.0.undo_count() > 0;
|
||||||
pending.score = ev.score;
|
pending.score = ev.score;
|
||||||
pending.time_seconds = ev.time_seconds;
|
pending.time_seconds = ev.time_seconds;
|
||||||
pending.xp = 0; // reset; XP event follows
|
pending.xp = 0; // reset; XP event follows
|
||||||
pending.xp_detail = build_xp_detail(ev.time_seconds, used_undo);
|
pending.xp_detail = build_xp_detail(ev.time_seconds, used_undo);
|
||||||
pending.new_record = is_new_record;
|
pending.new_record = is_new_record;
|
||||||
pending.challenge_level = challenge_level;
|
pending.challenge_level = challenge_level;
|
||||||
pending.undo_count = game.0.undo_count;
|
pending.undo_count = game.0.undo_count();
|
||||||
pending.mode = game.0.mode;
|
pending.mode = game.0.mode;
|
||||||
|
|
||||||
if is_new_record {
|
if is_new_record {
|
||||||
@@ -1587,7 +1587,7 @@ mod tests {
|
|||||||
{
|
{
|
||||||
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
||||||
game.0 = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Zen);
|
game.0 = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Zen);
|
||||||
game.0.undo_count = 2;
|
game.0.force_test_undos(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ impl ReplayPlayer {
|
|||||||
StateSnapshot {
|
StateSnapshot {
|
||||||
step_idx: self.step_idx,
|
step_idx: self.step_idx,
|
||||||
total_steps: self.moves.len(),
|
total_steps: self.moves.len(),
|
||||||
score: self.game.score,
|
score: self.game.score(),
|
||||||
move_count: self.game.move_count(),
|
move_count: self.game.move_count(),
|
||||||
is_won: self.game.is_won(),
|
is_won: self.game.is_won(),
|
||||||
stock: self
|
stock: self
|
||||||
@@ -487,12 +487,12 @@ impl SolitaireGame {
|
|||||||
!stock_empty || !waste_empty || !self.game.possible_instructions().is_empty()
|
!stock_empty || !waste_empty || !self.game.possible_instructions().is_empty()
|
||||||
};
|
};
|
||||||
GameSnapshot {
|
GameSnapshot {
|
||||||
score: self.game.score,
|
score: self.game.score(),
|
||||||
move_count: self.game.move_count(),
|
move_count: self.game.move_count(),
|
||||||
is_won: self.game.is_won(),
|
is_won: self.game.is_won(),
|
||||||
is_auto_completable: self.game.is_auto_completable(),
|
is_auto_completable: self.game.is_auto_completable(),
|
||||||
has_moves,
|
has_moves,
|
||||||
undo_count: self.game.undo_count,
|
undo_count: self.game.undo_count(),
|
||||||
undo_stack_len: self.game.undo_stack_len(),
|
undo_stack_len: self.game.undo_stack_len(),
|
||||||
stock: self
|
stock: self
|
||||||
.game
|
.game
|
||||||
@@ -1059,7 +1059,7 @@ mod tests {
|
|||||||
draw_mode,
|
draw_mode,
|
||||||
mode: GameMode::Classic,
|
mode: GameMode::Classic,
|
||||||
time_seconds: 120,
|
time_seconds: 120,
|
||||||
final_score: game.game.score,
|
final_score: game.game.score(),
|
||||||
recorded_at,
|
recorded_at,
|
||||||
moves: exported_moves,
|
moves: exported_moves,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user