refactor(core): derive draw_mode/is_won/move_count/is_auto_completable from session
Remove the draw_mode, move_count, is_won, and is_auto_completable fields from GameState; they are now &self methods deriving from the underlying card_game session (draw_mode from session config, move_count from history length, is_won/is_auto_completable from check_win/check_auto_complete). Tests previously fabricated these via direct field writes, which is no longer possible. Add gated test-support overrides on TestPileState (won/auto_completable/move_count) plus setters set_test_won, set_test_auto_completable, set_test_move_count, and set_test_draw_mode (re-deals the seed). All compiled out in production builds. Fix the field->method ripple across solitaire_data, solitaire_wasm, and solitaire_engine. Add a test-support dev-dependency to solitaire_data for the won-game storage test. cargo test --workspace and cargo clippy --workspace -- -D warnings pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,8 +8,8 @@ use crate::klondike_adapter::{
|
||||
};
|
||||
use card_game::{Card, Game as _, Session, SessionConfig};
|
||||
use klondike::{
|
||||
DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig, KlondikeInstruction,
|
||||
KlondikePile, KlondikePileStack, SkipCards, Tableau, TableauStack,
|
||||
DrawStockConfig, DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig,
|
||||
KlondikeInstruction, KlondikePile, KlondikePileStack, SkipCards, Tableau, TableauStack,
|
||||
};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
@@ -150,27 +150,28 @@ pub struct TestPileState {
|
||||
pub tableau: std::collections::HashMap<Tableau, Vec<(Card, bool)>>,
|
||||
/// Per-foundation overrides. Missing keys fall back to the session.
|
||||
pub foundation: std::collections::HashMap<Foundation, Vec<Card>>,
|
||||
/// Override for the derived `move_count()`. `None` means "use session
|
||||
/// history length".
|
||||
pub move_count: Option<u32>,
|
||||
/// Override for the derived `is_won()`. `None` means "use session win
|
||||
/// state".
|
||||
pub won: Option<bool>,
|
||||
/// Override for the derived `is_auto_completable()`. `None` means "derive
|
||||
/// from session state".
|
||||
pub auto_completable: Option<bool>,
|
||||
}
|
||||
|
||||
/// Full state of an in-progress Klondike Solitaire game.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GameState {
|
||||
/// Whether the player draws one or three cards from the stock per turn.
|
||||
pub draw_mode: DrawMode,
|
||||
/// Top-level mode (Classic / Zen).
|
||||
pub mode: GameMode,
|
||||
/// Current game score. Can be negative (undo penalties subtract from score).
|
||||
pub score: i32,
|
||||
/// Total moves made this game, including draws and stock recycles.
|
||||
pub move_count: u32,
|
||||
/// Seconds elapsed since the game started, used for time-bonus scoring.
|
||||
pub elapsed_seconds: u64,
|
||||
/// RNG seed used to deal this game. Same seed always produces the same layout.
|
||||
pub seed: u64,
|
||||
/// True once all 52 cards are on the foundations. No further moves are accepted.
|
||||
pub is_won: bool,
|
||||
/// True when the game can be completed without further input.
|
||||
pub is_auto_completable: bool,
|
||||
/// 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.
|
||||
@@ -195,14 +196,14 @@ pub struct GameState {
|
||||
|
||||
impl PartialEq for GameState {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.draw_mode == other.draw_mode
|
||||
self.draw_mode() == other.draw_mode()
|
||||
&& self.mode == other.mode
|
||||
&& self.score == other.score
|
||||
&& self.move_count == other.move_count
|
||||
&& self.move_count() == other.move_count()
|
||||
&& self.elapsed_seconds == other.elapsed_seconds
|
||||
&& self.seed == other.seed
|
||||
&& self.is_won == other.is_won
|
||||
&& self.is_auto_completable == other.is_auto_completable
|
||||
&& self.is_won() == other.is_won()
|
||||
&& self.is_auto_completable() == other.is_auto_completable()
|
||||
&& self.undo_count == other.undo_count
|
||||
&& self.recycle_count == other.recycle_count
|
||||
&& self.take_from_foundation == other.take_from_foundation
|
||||
@@ -225,7 +226,7 @@ impl Eq for GameState {}
|
||||
impl Serialize for GameState {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
PersistedGameState {
|
||||
draw_mode: self.draw_mode,
|
||||
draw_mode: self.draw_mode(),
|
||||
mode: self.mode,
|
||||
score: self.score,
|
||||
elapsed_seconds: self.elapsed_seconds,
|
||||
@@ -256,14 +257,10 @@ impl<'de> Deserialize<'de> for GameState {
|
||||
}
|
||||
|
||||
let mut game = Self {
|
||||
draw_mode: persisted.draw_mode,
|
||||
mode: persisted.mode,
|
||||
score: persisted.score,
|
||||
move_count: 0,
|
||||
elapsed_seconds: persisted.elapsed_seconds,
|
||||
seed: persisted.seed,
|
||||
is_won: false,
|
||||
is_auto_completable: false,
|
||||
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.
|
||||
@@ -282,7 +279,7 @@ impl<'de> Deserialize<'de> for GameState {
|
||||
test_pile_state: None,
|
||||
};
|
||||
|
||||
let replay_config = Self::replay_config(game.draw_mode);
|
||||
let replay_config = Self::replay_config(persisted.draw_mode);
|
||||
for any in persisted.saved_moves {
|
||||
// AnyInstruction::V4 arrives directly from upstream serde (schema v4).
|
||||
// AnyInstruction::V3 was serialised with u8 indices (schema v3) and is
|
||||
@@ -318,9 +315,6 @@ impl<'de> Deserialize<'de> for GameState {
|
||||
}
|
||||
}
|
||||
|
||||
game.move_count = Self::u32_from_len(game.session.history().len());
|
||||
game.is_won = game.check_win();
|
||||
game.is_auto_completable = !game.is_won && game.check_auto_complete();
|
||||
Ok(game)
|
||||
}
|
||||
}
|
||||
@@ -334,14 +328,10 @@ impl GameState {
|
||||
/// Creates a new game with an explicit `GameMode`.
|
||||
pub fn new_with_mode(seed: u64, draw_mode: DrawMode, mode: GameMode) -> Self {
|
||||
Self {
|
||||
draw_mode,
|
||||
mode,
|
||||
score: 0,
|
||||
move_count: 0,
|
||||
elapsed_seconds: 0,
|
||||
seed,
|
||||
is_won: false,
|
||||
is_auto_completable: false,
|
||||
undo_count: 0,
|
||||
recycle_count: 0,
|
||||
take_from_foundation: true,
|
||||
@@ -353,6 +343,51 @@ impl GameState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the player draws one or three cards from the stock per turn.
|
||||
/// Derived from the underlying session config (set once at deal time).
|
||||
pub fn draw_mode(&self) -> DrawMode {
|
||||
match self.session.config().inner.draw_stock {
|
||||
DrawStockConfig::DrawOne => DrawMode::DrawOne,
|
||||
DrawStockConfig::DrawThree => DrawMode::DrawThree,
|
||||
}
|
||||
}
|
||||
|
||||
/// Total moves made this game (draws, recycles, and card moves), derived
|
||||
/// from the session's instruction history length.
|
||||
pub fn move_count(&self) -> u32 {
|
||||
#[cfg(feature = "test-support")]
|
||||
if let Some(ref state) = self.test_pile_state
|
||||
&& let Some(count) = state.move_count
|
||||
{
|
||||
return count;
|
||||
}
|
||||
Self::u32_from_len(self.session.history().len())
|
||||
}
|
||||
|
||||
/// True once all 52 cards are on the foundations. No further moves are
|
||||
/// accepted. Derived from the session win state.
|
||||
pub fn is_won(&self) -> bool {
|
||||
#[cfg(feature = "test-support")]
|
||||
if let Some(ref state) = self.test_pile_state
|
||||
&& let Some(won) = state.won
|
||||
{
|
||||
return won;
|
||||
}
|
||||
self.check_win()
|
||||
}
|
||||
|
||||
/// True when the game can be completed without further player input
|
||||
/// (and is not already won). Derived from the session state.
|
||||
pub fn is_auto_completable(&self) -> bool {
|
||||
#[cfg(feature = "test-support")]
|
||||
if let Some(ref state) = self.test_pile_state
|
||||
&& let Some(auto) = state.auto_completable
|
||||
{
|
||||
return auto;
|
||||
}
|
||||
!self.check_win() && self.check_auto_complete()
|
||||
}
|
||||
|
||||
fn new_session(seed: u64, draw_mode: DrawMode) -> Session<Klondike> {
|
||||
Session::new(Klondike::with_seed(seed), Self::session_config(draw_mode))
|
||||
}
|
||||
@@ -375,7 +410,7 @@ impl GameState {
|
||||
}
|
||||
|
||||
fn validation_config(&self) -> KlondikeConfig {
|
||||
KlondikeAdapter::config_for(self.draw_mode, self.take_from_foundation)
|
||||
KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation)
|
||||
}
|
||||
|
||||
/// Collects the session instruction history as upstream types for schema v4
|
||||
@@ -529,6 +564,44 @@ impl GameState {
|
||||
self.test_pile_state = None;
|
||||
}
|
||||
|
||||
/// Test-support helper: re-deal the current seed under a different draw
|
||||
/// mode. `draw_mode()` is otherwise fixed at deal time, so tests that need
|
||||
/// a specific mode use this instead of mutating a field.
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn set_test_draw_mode(&mut self, draw_mode: DrawMode) {
|
||||
self.session = Self::new_session(self.seed, draw_mode);
|
||||
}
|
||||
|
||||
/// Test-support helper: override the value returned by [`Self::is_won`]
|
||||
/// without driving the session to a genuine win.
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn set_test_won(&mut self, won: bool) {
|
||||
let state = self
|
||||
.test_pile_state
|
||||
.get_or_insert_with(TestPileState::default);
|
||||
state.won = Some(won);
|
||||
}
|
||||
|
||||
/// Test-support helper: override the value returned by
|
||||
/// [`Self::is_auto_completable`].
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn set_test_auto_completable(&mut self, auto_completable: bool) {
|
||||
let state = self
|
||||
.test_pile_state
|
||||
.get_or_insert_with(TestPileState::default);
|
||||
state.auto_completable = Some(auto_completable);
|
||||
}
|
||||
|
||||
/// Test-support helper: override the value returned by
|
||||
/// [`Self::move_count`] without applying real moves.
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn set_test_move_count(&mut self, move_count: u32) {
|
||||
let state = self
|
||||
.test_pile_state
|
||||
.get_or_insert_with(TestPileState::default);
|
||||
state.move_count = Some(move_count);
|
||||
}
|
||||
|
||||
/// Test-support helper: override face-down stock cards returned by
|
||||
/// [`Self::stock_cards`].
|
||||
#[cfg(feature = "test-support")]
|
||||
@@ -623,7 +696,7 @@ impl GameState {
|
||||
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.draw_mode() == DrawMode::DrawThree,
|
||||
self.mode,
|
||||
);
|
||||
(penalty, true)
|
||||
@@ -800,7 +873,7 @@ impl GameState {
|
||||
|
||||
/// Draw cards from stock to waste. When stock is empty, recycles waste back to stock.
|
||||
pub fn draw(&mut self) -> Result<(), MoveError> {
|
||||
if self.is_won {
|
||||
if self.is_won() {
|
||||
return Err(MoveError::GameAlreadyWon);
|
||||
}
|
||||
|
||||
@@ -823,7 +896,6 @@ impl GameState {
|
||||
self.recycle_count = self.recycle_count.saturating_add(1);
|
||||
}
|
||||
self.score = (self.score + score_delta).max(0);
|
||||
self.move_count = Self::u32_from_len(self.session.history().len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -834,7 +906,7 @@ impl GameState {
|
||||
to: KlondikePile,
|
||||
count: usize,
|
||||
) -> Result<(), MoveError> {
|
||||
if self.is_won {
|
||||
if self.is_won() {
|
||||
return Err(MoveError::GameAlreadyWon);
|
||||
}
|
||||
if from == to {
|
||||
@@ -869,15 +941,12 @@ impl GameState {
|
||||
|
||||
self.session.process_instruction(instruction);
|
||||
self.score = (self.score + score_delta).max(0);
|
||||
self.move_count = Self::u32_from_len(self.session.history().len());
|
||||
self.is_won = self.check_win();
|
||||
self.is_auto_completable = !self.is_won && self.check_auto_complete();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restore the most recent undo snapshot and apply the undo score penalty (-15).
|
||||
pub fn undo(&mut self) -> Result<(), MoveError> {
|
||||
if self.is_won {
|
||||
if self.is_won() {
|
||||
return Err(MoveError::GameAlreadyWon);
|
||||
}
|
||||
if self.mode == GameMode::Challenge {
|
||||
@@ -905,9 +974,6 @@ impl GameState {
|
||||
// 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.is_won = self.check_win();
|
||||
self.is_auto_completable = !self.is_won && self.check_auto_complete();
|
||||
self.undo_count = self.undo_count.saturating_add(1);
|
||||
Ok(())
|
||||
}
|
||||
@@ -925,7 +991,7 @@ impl GameState {
|
||||
|
||||
/// Returns all currently valid `(from, to, count)` moves.
|
||||
pub fn possible_instructions(&self) -> Vec<(KlondikePile, KlondikePile, usize)> {
|
||||
if self.is_won {
|
||||
if self.is_won() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
@@ -941,7 +1007,7 @@ impl GameState {
|
||||
|
||||
/// Returns `true` when `move_cards(from, to, count)` would currently succeed.
|
||||
pub fn can_move_cards(&self, from: &KlondikePile, to: &KlondikePile, count: usize) -> bool {
|
||||
if self.is_won || from == to {
|
||||
if self.is_won() || from == to {
|
||||
return false;
|
||||
}
|
||||
let from_pile = self.pile(*from);
|
||||
@@ -984,7 +1050,7 @@ impl GameState {
|
||||
|
||||
/// Returns the next `(from, to)` move that advances auto-complete, or `None` if absent.
|
||||
pub fn next_auto_complete_move(&self) -> Option<(KlondikePile, KlondikePile)> {
|
||||
if !self.is_auto_completable || self.is_won {
|
||||
if !self.is_auto_completable() || self.is_won() {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user