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:
funman300
2026-06-10 09:24:03 -07:00
parent 1438fd6265
commit 056459619b
20 changed files with 187 additions and 120 deletions
+108 -42
View File
@@ -8,8 +8,8 @@ use crate::klondike_adapter::{
}; };
use card_game::{Card, Game as _, Session, SessionConfig}; use card_game::{Card, Game as _, Session, SessionConfig};
use klondike::{ use klondike::{
DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig, KlondikeInstruction, DrawStockConfig, DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig,
KlondikePile, KlondikePileStack, SkipCards, Tableau, TableauStack, KlondikeInstruction, KlondikePile, KlondikePileStack, SkipCards, Tableau, TableauStack,
}; };
use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde::{Deserialize, Deserializer, Serialize, Serializer};
@@ -150,27 +150,28 @@ pub struct TestPileState {
pub tableau: std::collections::HashMap<Tableau, Vec<(Card, bool)>>, pub tableau: std::collections::HashMap<Tableau, Vec<(Card, bool)>>,
/// Per-foundation overrides. Missing keys fall back to the session. /// Per-foundation overrides. Missing keys fall back to the session.
pub foundation: std::collections::HashMap<Foundation, Vec<Card>>, 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. /// Full state of an in-progress Klondike Solitaire game.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct GameState { 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). /// Top-level mode (Classic / Zen).
pub mode: GameMode, pub mode: GameMode,
/// Current game score. Can be negative (undo penalties subtract from score). /// Current game score. Can be negative (undo penalties subtract from score).
pub score: i32, 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. /// 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,
/// 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. /// Number of times `undo()` has been successfully invoked this game.
pub undo_count: u32, pub undo_count: u32,
/// Number of times the waste pile has been recycled back to stock this game. /// 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 { 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
@@ -225,7 +226,7 @@ impl Eq for GameState {}
impl Serialize for GameState { impl Serialize for GameState {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
PersistedGameState { PersistedGameState {
draw_mode: self.draw_mode, draw_mode: self.draw_mode(),
mode: self.mode, mode: self.mode,
score: self.score, score: self.score,
elapsed_seconds: self.elapsed_seconds, elapsed_seconds: self.elapsed_seconds,
@@ -256,14 +257,10 @@ impl<'de> Deserialize<'de> for GameState {
} }
let mut game = Self { let mut game = Self {
draw_mode: persisted.draw_mode,
mode: persisted.mode, mode: persisted.mode,
score: persisted.score, score: persisted.score,
move_count: 0,
elapsed_seconds: persisted.elapsed_seconds, elapsed_seconds: persisted.elapsed_seconds,
seed: persisted.seed, seed: persisted.seed,
is_won: false,
is_auto_completable: false,
undo_count: persisted.undo_count, undo_count: persisted.undo_count,
// Rebuilt from the replay loop below; persisted value may be stale // Rebuilt from the replay loop below; persisted value may be stale
// due to the pre-Phase-3 undo drift bug. // due to the pre-Phase-3 undo drift bug.
@@ -282,7 +279,7 @@ impl<'de> Deserialize<'de> for GameState {
test_pile_state: None, 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 { 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
@@ -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) Ok(game)
} }
} }
@@ -334,14 +328,10 @@ impl GameState {
/// Creates a new game with an explicit `GameMode`. /// Creates a new game with an explicit `GameMode`.
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 {
draw_mode,
mode, mode,
score: 0, score: 0,
move_count: 0,
elapsed_seconds: 0, elapsed_seconds: 0,
seed, seed,
is_won: false,
is_auto_completable: false,
undo_count: 0, undo_count: 0,
recycle_count: 0, recycle_count: 0,
take_from_foundation: true, 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> { fn new_session(seed: u64, draw_mode: DrawMode) -> Session<Klondike> {
Session::new(Klondike::with_seed(seed), Self::session_config(draw_mode)) Session::new(Klondike::with_seed(seed), Self::session_config(draw_mode))
} }
@@ -375,7 +410,7 @@ impl GameState {
} }
fn validation_config(&self) -> KlondikeConfig { 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 /// Collects the session instruction history as upstream types for schema v4
@@ -529,6 +564,44 @@ impl GameState {
self.test_pile_state = None; 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 /// Test-support helper: override face-down stock cards returned by
/// [`Self::stock_cards`]. /// [`Self::stock_cards`].
#[cfg(feature = "test-support")] #[cfg(feature = "test-support")]
@@ -623,7 +696,7 @@ impl GameState {
let next_count = self.recycle_count.saturating_add(1); let next_count = self.recycle_count.saturating_add(1);
let penalty = KlondikeAdapter::score_for_recycle_with_mode( let penalty = KlondikeAdapter::score_for_recycle_with_mode(
next_count, next_count,
self.draw_mode == DrawMode::DrawThree, self.draw_mode() == DrawMode::DrawThree,
self.mode, self.mode,
); );
(penalty, true) (penalty, true)
@@ -800,7 +873,7 @@ impl GameState {
/// Draw cards from stock to waste. When stock is empty, recycles waste back to stock. /// Draw cards from stock to waste. When stock is empty, recycles waste back to stock.
pub fn draw(&mut self) -> Result<(), MoveError> { pub fn draw(&mut self) -> Result<(), MoveError> {
if self.is_won { if self.is_won() {
return Err(MoveError::GameAlreadyWon); return Err(MoveError::GameAlreadyWon);
} }
@@ -823,7 +896,6 @@ impl GameState {
self.recycle_count = self.recycle_count.saturating_add(1); self.recycle_count = self.recycle_count.saturating_add(1);
} }
self.score = (self.score + score_delta).max(0); self.score = (self.score + score_delta).max(0);
self.move_count = Self::u32_from_len(self.session.history().len());
Ok(()) Ok(())
} }
@@ -834,7 +906,7 @@ impl GameState {
to: KlondikePile, to: KlondikePile,
count: usize, count: usize,
) -> Result<(), MoveError> { ) -> Result<(), MoveError> {
if self.is_won { if self.is_won() {
return Err(MoveError::GameAlreadyWon); return Err(MoveError::GameAlreadyWon);
} }
if from == to { if from == to {
@@ -869,15 +941,12 @@ impl GameState {
self.session.process_instruction(instruction); self.session.process_instruction(instruction);
self.score = (self.score + score_delta).max(0); 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(()) Ok(())
} }
/// Restore the most recent undo snapshot and apply the undo score penalty (-15). /// Restore the most recent undo snapshot and apply the undo score penalty (-15).
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);
} }
if self.mode == GameMode::Challenge { if self.mode == GameMode::Challenge {
@@ -905,9 +974,6 @@ impl GameState {
// This correctly reverses any recycle or move penalty that was applied // This correctly reverses any recycle or move penalty that was applied
// before adding the 15 undo penalty. // before adding the 15 undo penalty.
self.score = KlondikeAdapter::apply_undo_score(pre_move_score, self.mode); 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); self.undo_count = self.undo_count.saturating_add(1);
Ok(()) Ok(())
} }
@@ -925,7 +991,7 @@ impl GameState {
/// Returns all currently valid `(from, to, count)` moves. /// Returns all currently valid `(from, to, count)` moves.
pub fn possible_instructions(&self) -> Vec<(KlondikePile, KlondikePile, usize)> { pub fn possible_instructions(&self) -> Vec<(KlondikePile, KlondikePile, usize)> {
if self.is_won { if self.is_won() {
return Vec::new(); return Vec::new();
} }
@@ -941,7 +1007,7 @@ impl GameState {
/// Returns `true` when `move_cards(from, to, count)` would currently succeed. /// Returns `true` when `move_cards(from, to, count)` would currently succeed.
pub fn can_move_cards(&self, from: &KlondikePile, to: &KlondikePile, count: usize) -> bool { 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; return false;
} }
let from_pile = self.pile(*from); 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. /// Returns the next `(from, to)` move that advances auto-complete, or `None` if absent.
pub fn next_auto_complete_move(&self) -> Option<(KlondikePile, KlondikePile)> { 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; return None;
} }
+4 -4
View File
@@ -89,7 +89,7 @@ fn apply_random_actions(game: &mut GameState, actions: &[(bool, usize)]) {
/// available), using `move_idx` to select among the legal options. /// available), using `move_idx` to select among the legal options.
/// Returns `true` when a move was successfully applied. /// Returns `true` when a move was successfully applied.
fn apply_one_move(game: &mut GameState, move_idx: usize) -> bool { fn apply_one_move(game: &mut GameState, move_idx: usize) -> bool {
if game.is_won { if game.is_won() {
return false; return false;
} }
let instructions = game.possible_instructions(); let instructions = game.possible_instructions();
@@ -218,10 +218,10 @@ proptest! {
// Snapshot the state before the move. // Snapshot the state before the move.
let before_ids = all_cards(&game); let before_ids = all_cards(&game);
let before_move_count = game.move_count; let before_move_count = game.move_count();
// Apply one move. // Apply one move.
if !apply_one_move(&mut game, move_idx) || game.is_won { if !apply_one_move(&mut game, move_idx) || game.is_won() {
return Ok(()); // nothing to undo return Ok(()); // nothing to undo
} }
@@ -236,7 +236,7 @@ proptest! {
"pile layout after undo differs from the pre-move snapshot", "pile layout after undo differs from the pre-move snapshot",
); );
prop_assert_eq!( prop_assert_eq!(
game.move_count, game.move_count(),
before_move_count, before_move_count,
"move_count after undo must equal the pre-move value", "move_count after undo must equal the pre-move value",
); );
+1
View File
@@ -39,6 +39,7 @@ keyring-core = { workspace = true }
jni = { workspace = true } jni = { workspace = true }
[dev-dependencies] [dev-dependencies]
solitaire_core = { workspace = true, features = ["test-support"] }
solitaire_server = { path = "../solitaire_server" } solitaire_server = { path = "../solitaire_server" }
solitaire_sync = { workspace = true } solitaire_sync = { workspace = true }
axum = { workspace = true } axum = { workspace = true }
+2 -2
View File
@@ -93,7 +93,7 @@ fn solve_game_state(initial: &GameState, config: &SolverConfig) -> SolveOutcome
// Preserve the historical payload contract: winnable verdicts always carry // Preserve the historical payload contract: winnable verdicts always carry
// a first move. An already-won state therefore returns no recommendation. // a first move. An already-won state therefore returns no recommendation.
if initial.is_won { if initial.is_won() {
return SolveOutcome { return SolveOutcome {
result: SolverResult::Unwinnable, result: SolverResult::Unwinnable,
first_move: None, first_move: None,
@@ -101,7 +101,7 @@ fn solve_game_state(initial: &GameState, config: &SolverConfig) -> SolveOutcome
} }
let solver_config = SessionConfig { let solver_config = SessionConfig {
inner: KlondikeAdapter::config_for(initial.draw_mode, initial.take_from_foundation), inner: KlondikeAdapter::config_for(initial.draw_mode(), initial.take_from_foundation),
undo_penalty: 0, undo_penalty: 0,
solve_moves_budget: config.move_budget, solve_moves_budget: config.move_budget,
solve_states_budget: config.state_budget as u64, solve_states_budget: config.state_budget as u64,
+5 -5
View File
@@ -85,13 +85,13 @@ pub fn game_state_file_path() -> Option<PathBuf> {
pub fn load_game_state_from(path: &Path) -> Option<GameState> { pub fn load_game_state_from(path: &Path) -> Option<GameState> {
let data = fs::read(path).ok()?; let data = fs::read(path).ok()?;
let gs: GameState = serde_json::from_slice(&data).ok()?; let gs: GameState = serde_json::from_slice(&data).ok()?;
if gs.is_won { None } else { Some(gs) } if gs.is_won() { None } else { Some(gs) }
} }
/// Save an in-progress `GameState` atomically. Skips the write if `gs.is_won` /// Save an in-progress `GameState` atomically. Skips the write if `gs.is_won`
/// because a completed game should not be resumed. /// because a completed game should not be resumed.
pub fn save_game_state_to(path: &Path, gs: &GameState) -> io::Result<()> { pub fn save_game_state_to(path: &Path, gs: &GameState) -> io::Result<()> {
if gs.is_won { if gs.is_won() {
return Ok(()); return Ok(());
} }
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
@@ -386,8 +386,8 @@ mod tests {
let loaded = load_game_state_from(&path).expect("load"); let loaded = load_game_state_from(&path).expect("load");
assert_eq!(loaded.seed, gs.seed); assert_eq!(loaded.seed, gs.seed);
assert_eq!(loaded.draw_mode, gs.draw_mode); assert_eq!(loaded.draw_mode(), gs.draw_mode());
assert!(!loaded.is_won); assert!(!loaded.is_won());
} }
#[test] #[test]
@@ -411,7 +411,7 @@ mod tests {
let _ = fs::remove_file(&path); let _ = fs::remove_file(&path);
let mut gs = GameState::new(99, DrawMode::DrawOne); let mut gs = GameState::new(99, DrawMode::DrawOne);
gs.is_won = true; gs.set_test_won(true);
save_game_state_to(&path, &gs).expect("save should be no-op, not error"); save_game_state_to(&path, &gs).expect("save should be no-op, not error");
assert!( assert!(
!path.exists(), !path.exists(),
+2 -2
View File
@@ -819,7 +819,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<GameStateResource>() .resource_mut::<GameStateResource>()
.0 .0
.draw_mode = solitaire_core::DrawMode::DrawThree; .set_test_draw_mode(solitaire_core::DrawMode::DrawThree);
app.world_mut().write_message(GameWonEvent { app.world_mut().write_message(GameWonEvent {
score: 500, score: 500,
@@ -868,7 +868,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<GameStateResource>() .resource_mut::<GameStateResource>()
.0 .0
.draw_mode = solitaire_core::DrawMode::DrawThree; .set_test_draw_mode(solitaire_core::DrawMode::DrawThree);
app.world_mut().write_message(GameWonEvent { app.world_mut().write_message(GameWonEvent {
score: 500, score: 500,
+6 -6
View File
@@ -76,14 +76,14 @@ fn detect_auto_complete(
} }
changed.clear(); changed.clear();
if game.0.is_won { if game.0.is_won() {
state.active = false; state.active = false;
return; return;
} }
if game.0.is_auto_completable && !state.active { if game.0.is_auto_completable() && !state.active {
state.active = true; state.active = true;
state.cooldown = AUTO_COMPLETE_INITIAL_DELAY; state.cooldown = AUTO_COMPLETE_INITIAL_DELAY;
} else if !game.0.is_auto_completable && state.active { } else if !game.0.is_auto_completable() && state.active {
// `is_auto_completable` only becomes false after an explicit undo // `is_auto_completable` only becomes false after an explicit undo
// (which puts a card back on the tableau or re-fills the stock/waste) // (which puts a card back on the tableau or re-fills the stock/waste)
// or a new-game reset — never as a transient gap during a normal // or a new-game reset — never as a transient gap during a normal
@@ -209,7 +209,7 @@ mod tests {
Tableau::Tableau1, Tableau::Tableau1,
vec![solitaire_core::card::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)], vec![solitaire_core::card::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
); );
g.is_auto_completable = true; g.set_test_auto_completable(true);
let expected = ( let expected = (
KlondikePile::Tableau(Tableau::Tableau1), KlondikePile::Tableau(Tableau::Tableau1),
KlondikePile::Foundation(Foundation::Foundation1), KlondikePile::Foundation(Foundation::Foundation1),
@@ -228,7 +228,7 @@ mod tests {
fn detect_activates_when_auto_completable() { fn detect_activates_when_auto_completable() {
let mut app = headless_app(); let mut app = headless_app();
let mut g = GameState::new(42, DrawMode::DrawOne); let mut g = GameState::new(42, DrawMode::DrawOne);
g.is_auto_completable = true; g.set_test_auto_completable(true);
app.world_mut().resource_mut::<GameStateResource>().0 = g; app.world_mut().resource_mut::<GameStateResource>().0 = g;
app.world_mut().write_message(StateChangedEvent); app.world_mut().write_message(StateChangedEvent);
app.update(); app.update();
@@ -263,7 +263,7 @@ mod tests {
let mut app = headless_app(); let mut app = headless_app();
// Inject a won game state — active should not be set. // Inject a won game state — active should not be set.
let (mut gs, _) = seeded_state_with_auto_move(); let (mut gs, _) = seeded_state_with_auto_move();
gs.is_won = true; gs.set_test_won(true);
app.world_mut().resource_mut::<GameStateResource>().0 = gs; app.world_mut().resource_mut::<GameStateResource>().0 = gs;
app.world_mut().write_message(StateChangedEvent); app.world_mut().write_message(StateChangedEvent);
app.update(); app.update();
+3 -3
View File
@@ -734,7 +734,7 @@ fn sync_cards(
// Without this, the buffer sits at waste_base uncovered during the animation // Without this, the buffer sits at waste_base uncovered during the animation
// and its rank/suit peek behind the incoming card. // and its rank/suit peek behind the incoming card.
let waste_buffer_id: Option<Card> = { let waste_buffer_id: Option<Card> = {
let visible = match game.draw_mode { let visible = match game.draw_mode() {
DrawMode::DrawOne => 1_usize, DrawMode::DrawOne => 1_usize,
DrawMode::DrawThree => 3_usize, DrawMode::DrawThree => 3_usize,
}; };
@@ -903,7 +903,7 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<((Card, bool), Vec2,
// while new cards animate in from the stock. Draw-One shows 1; Draw-Three // while new cards animate in from the stock. Draw-One shows 1; Draw-Three
// shows up to 3 fanned in X (matching the standard Klondike presentation). // shows up to 3 fanned in X (matching the standard Klondike presentation).
let render_start = if is_waste { let render_start = if is_waste {
let visible = match game.draw_mode { let visible = match game.draw_mode() {
DrawMode::DrawOne => 1_usize, DrawMode::DrawOne => 1_usize,
DrawMode::DrawThree => 3_usize, DrawMode::DrawThree => 3_usize,
}; };
@@ -918,7 +918,7 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<((Card, bool), Vec2,
let mut y_offset = 0.0_f32; let mut y_offset = 0.0_f32;
let rendered_len = cards[render_start..].len(); let rendered_len = cards[render_start..].len();
for (slot, (card, face_up)) in cards[render_start..].iter().enumerate() { for (slot, (card, face_up)) in cards[render_start..].iter().enumerate() {
let x_offset = if is_waste && matches!(game.draw_mode, DrawMode::DrawThree) { let x_offset = if is_waste && matches!(game.draw_mode(), DrawMode::DrawThree) {
// When len > visible, slot 0 is a hidden buffer card kept at // When len > visible, slot 0 is a hidden buffer card kept at
// x=0 to prevent a flash during the draw tween. When len ≤ // x=0 to prevent a flash during the draw tween. When len ≤
// visible (small pile), every card is visible and should fan // visible (small pile), every card is visible and should fan
+1 -1
View File
@@ -437,7 +437,7 @@ fn tableau_or_stack_pos(
base.x, base.x,
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32), base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
) )
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode == DrawMode::DrawThree { } else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawMode::DrawThree {
let pile_len = game.waste_cards().len(); let pile_len = game.waste_cards().len();
let visible_start = pile_len.saturating_sub(3); let visible_start = pile_len.saturating_sub(3);
let slot = index.saturating_sub(visible_start) as f32; let slot = index.saturating_sub(visible_start) as f32;
+1 -1
View File
@@ -408,7 +408,7 @@ fn start_deal_anim(
return; return;
} }
// Only animate a fresh deal (no moves made yet). // Only animate a fresh deal (no moves made yet).
if game.0.move_count != 0 { if game.0.move_count() != 0 {
return; return;
} }
let Some(layout) = layout else { return }; let Some(layout) = layout else { return };
+17 -17
View File
@@ -154,7 +154,7 @@ impl Plugin for GamePlugin {
let saved = path.as_deref().and_then(load_game_state_from); let saved = path.as_deref().and_then(load_game_state_from);
let prompt_worthy = saved let prompt_worthy = saved
.as_ref() .as_ref()
.is_some_and(|g| g.move_count > 0 && !g.is_won); .is_some_and(|g| g.move_count() > 0 && !g.is_won());
let (initial_state, pending_restore) = if prompt_worthy { let (initial_state, pending_restore) = if prompt_worthy {
( (
GameState::new(seed_from_system_time(), DrawMode::DrawOne), GameState::new(seed_from_system_time(), DrawMode::DrawOne),
@@ -302,7 +302,7 @@ fn tick_elapsed_time(
*skip_next_delta = false; *skip_next_delta = false;
return; return;
} }
let is_won = game.0.is_won; let is_won = game.0.is_won();
advance_elapsed( advance_elapsed(
&mut game.0.elapsed_seconds, &mut game.0.elapsed_seconds,
&mut accumulator, &mut accumulator,
@@ -424,7 +424,7 @@ fn handle_new_game(
for ev in new_game.read() { for ev in new_game.read() {
// If an active game is in progress, intercept and show a confirm dialog. // If an active game is in progress, intercept and show a confirm dialog.
// A game is "active" when moves have been made and it is not yet won. // A game is "active" when moves have been made and it is not yet won.
let needs_confirm = game.0.move_count > 0 && !game.0.is_won; let needs_confirm = game.0.move_count() > 0 && !game.0.is_won();
// Skip confirmation if a ConfirmNewGameScreen already exists (prevents // Skip confirmation if a ConfirmNewGameScreen already exists (prevents
// duplicates) or if the event itself was already confirmed by the // duplicates) or if the event itself was already confirmed by the
// player pressing Y on the modal — without the `confirmed` check the // player pressing Y on the modal — without the `confirmed` check the
@@ -464,7 +464,7 @@ fn handle_new_game(
// where SettingsPlugin is not installed. // where SettingsPlugin is not installed.
let draw_mode = settings let draw_mode = settings
.as_ref() .as_ref()
.map_or_else(|| game.0.draw_mode, |s| s.0.draw_mode); .map_or_else(|| game.0.draw_mode(), |s| s.0.draw_mode);
let mode = ev.mode.unwrap_or(game.0.mode); let mode = ev.mode.unwrap_or(game.0.mode);
// Solver-backed retry: when the player has opted in to // Solver-backed retry: when the player has opted in to
@@ -823,7 +823,7 @@ fn handle_draw(
if stock.is_empty() { if stock.is_empty() {
Vec::new() Vec::new()
} else { } else {
let draw_count = match game.0.draw_mode { let draw_count = match game.0.draw_mode() {
DrawMode::DrawOne => 1_usize, DrawMode::DrawOne => 1_usize,
DrawMode::DrawThree => 3_usize, DrawMode::DrawThree => 3_usize,
}; };
@@ -865,7 +865,7 @@ fn handle_move(
path: Option<Res<GameStatePath>>, path: Option<Res<GameStatePath>>,
) { ) {
for ev in moves.read() { for ev in moves.read() {
let was_won = game.0.is_won; let was_won = game.0.is_won();
// Identify the card that will be exposed (and may flip face-up) by the move. // Identify the card that will be exposed (and may flip face-up) by the move.
// It's the card just below the bottom of the moving stack in the source pile. // It's the card just below the bottom of the moving stack in the source pile.
let source_cards = pile_cards(&game.0, &ev.from); let source_cards = pile_cards(&game.0, &ev.from);
@@ -910,7 +910,7 @@ fn handle_move(
foundation_done.write(FoundationCompletedEvent { slot, suit }); foundation_done.write(FoundationCompletedEvent { slot, suit });
} }
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,
@@ -986,7 +986,7 @@ pub fn record_replay_on_win(
let win_move_index = recording.moves.len().checked_sub(1); let win_move_index = recording.moves.len().checked_sub(1);
let replay = Replay::new( let replay = Replay::new(
game.0.seed, game.0.seed,
game.0.draw_mode, game.0.draw_mode(),
game.0.mode, game.0.mode,
ev.time_seconds, ev.time_seconds,
ev.score, ev.score,
@@ -1093,13 +1093,13 @@ fn check_no_moves(
// Despawn game-over overlay whenever moves become available again or game is won. // Despawn game-over overlay whenever moves become available again or game is won.
let moves_ok = has_legal_moves(&game.0); let moves_ok = has_legal_moves(&game.0);
if moves_ok || game.0.is_won { if moves_ok || game.0.is_won() {
for entity in &game_over_screens { for entity in &game_over_screens {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} }
} }
if game.0.is_won { if game.0.is_won() {
return; return;
} }
@@ -1248,7 +1248,7 @@ fn auto_save_game_state(
// or there's a pending restore the player hasn't answered — saving // or there's a pending restore the player hasn't answered — saving
// the fresh-deal placeholder we seeded GameStateResource with at // the fresh-deal placeholder we seeded GameStateResource with at
// startup would clobber the real saved game on disk. // startup would clobber the real saved game on disk.
if paused.is_some_and(|p| p.0) || game.0.is_won || game.0.move_count == 0 || pending.0.is_some() if paused.is_some_and(|p| p.0) || game.0.is_won() || game.0.move_count() == 0 || pending.0.is_some()
{ {
return; return;
} }
@@ -1596,7 +1596,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<GameStateResource>() .resource_mut::<GameStateResource>()
.0 .0
.move_count = 1; .set_test_move_count(1);
// Re-arm the timer past the threshold every frame and pump // Re-arm the timer past the threshold every frame and pump
// updates until the save fires. Caps at 16 iterations — a // updates until the save fires. Caps at 16 iterations — a
@@ -1845,7 +1845,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<GameStateResource>() .resource_mut::<GameStateResource>()
.0 .0
.move_count = 5; .set_test_move_count(5);
app.world_mut().write_message(NewGameRequestEvent { app.world_mut().write_message(NewGameRequestEvent {
seed: None, seed: None,
mode: None, mode: None,
@@ -1869,7 +1869,7 @@ mod tests {
let mut app = test_app_with_input(42); let mut app = test_app_with_input(42);
// move_count stays at 0 (fresh game). // move_count stays at 0 (fresh game).
assert_eq!( assert_eq!(
app.world().resource::<GameStateResource>().0.move_count, app.world().resource::<GameStateResource>().0.move_count(),
0, 0,
"test assumes a fresh game with no moves" "test assumes a fresh game with no moves"
); );
@@ -2362,7 +2362,7 @@ mod tests {
app.update(); app.update();
// Game state was reseeded — move_count is 0 on the new game. // Game state was reseeded — move_count is 0 on the new game.
assert_eq!(app.world().resource::<GameStateResource>().0.move_count, 0); assert_eq!(app.world().resource::<GameStateResource>().0.move_count(), 0);
} }
#[test] #[test]
@@ -2436,7 +2436,7 @@ mod tests {
// The chosen seed is non-deterministic (system time), // The chosen seed is non-deterministic (system time),
// but the new game must have been started cleanly: // but the new game must have been started cleanly:
// move_count back to 0, undo stack empty. // move_count back to 0, undo stack empty.
assert_eq!(app.world().resource::<GameStateResource>().0.move_count, 0); assert_eq!(app.world().resource::<GameStateResource>().0.move_count(), 0);
assert_eq!( assert_eq!(
app.world() app.world()
.resource::<GameStateResource>() .resource::<GameStateResource>()
@@ -2496,7 +2496,7 @@ mod tests {
); );
// New game completed: a fresh deal carries 0 moves. // New game completed: a fresh deal carries 0 moves.
assert_eq!( assert_eq!(
app.world().resource::<GameStateResource>().0.move_count, app.world().resource::<GameStateResource>().0.move_count(),
0, 0,
"completed new game must be in fresh-deal state", "completed new game must be in fresh-deal state",
); );
+11 -11
View File
@@ -1154,7 +1154,7 @@ fn handle_hint_button(
return; return;
} }
let Some(ref g) = game else { return }; let Some(ref g) = game else { return };
if g.0.is_won { if g.0.is_won() {
info_toast.write(InfoToastEvent(HINT_WON_MSG.to_string())); info_toast.write(InfoToastEvent(HINT_WON_MSG.to_string()));
return; return;
} }
@@ -2106,10 +2106,10 @@ fn update_won_previously(
let Ok(mut text) = q.single_mut() else { let Ok(mut text) = q.single_mut() else {
return; return;
}; };
let won_before = !game.0.is_won let won_before = !game.0.is_won()
&& history.as_ref().is_some_and(|h| { && history.as_ref().is_some_and(|h| {
h.0.replays.iter().any(|r| { h.0.replays.iter().any(|r| {
r.seed == game.0.seed && r.draw_mode == game.0.draw_mode && r.mode == game.0.mode r.seed == game.0.seed && r.draw_mode == game.0.draw_mode() && r.mode == game.0.mode
}) })
}); });
let next = if won_before { let next = if won_before {
@@ -2279,11 +2279,11 @@ fn update_hud(
}; };
} }
if let Ok(mut t) = moves_q.single_mut() { if let Ok(mut t) = moves_q.single_mut() {
**t = format!("Moves: {}", g.move_count); **t = format!("Moves: {}", g.move_count());
} }
if let Ok(mut t) = mode_q.single_mut() { if let Ok(mut t) = mode_q.single_mut() {
**t = match g.mode { **t = match g.mode {
GameMode::Classic => match g.draw_mode { GameMode::Classic => match g.draw_mode() {
DrawMode::DrawOne => String::new(), DrawMode::DrawOne => String::new(),
DrawMode::DrawThree => "Draw 3".to_string(), DrawMode::DrawThree => "Draw 3".to_string(),
}, },
@@ -2296,7 +2296,7 @@ fn update_hud(
// --- Daily challenge constraint (with time-low colour warning) --- // --- Daily challenge constraint (with time-low colour warning) ---
if let Ok((mut t, mut color)) = challenge_q.single_mut() { if let Ok((mut t, mut color)) = challenge_q.single_mut() {
if g.is_won { if g.is_won() {
**t = String::new(); **t = String::new();
} else if let Some(dc) = daily.as_deref() { } else if let Some(dc) = daily.as_deref() {
**t = challenge_hud_text(dc); **t = challenge_hud_text(dc);
@@ -2334,7 +2334,7 @@ fn update_hud(
// --- Draw-cycle indicator (Draw-Three mode only) --- // --- Draw-cycle indicator (Draw-Three mode only) ---
if let Ok(mut t) = draw_cycle_q.single_mut() { if let Ok(mut t) = draw_cycle_q.single_mut() {
**t = if g.is_won || g.draw_mode != DrawMode::DrawThree { **t = if g.is_won() || g.draw_mode() != DrawMode::DrawThree {
// Hide when not in Draw-Three or after the game is won. // Hide when not in Draw-Three or after the game is won.
String::new() String::new()
} else { } else {
@@ -2774,7 +2774,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<GameStateResource>() .resource_mut::<GameStateResource>()
.0 .0
.move_count = 42; .set_test_move_count(42);
app.update(); app.update();
assert_eq!(read_hud_text::<HudMoves>(&mut app), "Moves: 42"); assert_eq!(read_hud_text::<HudMoves>(&mut app), "Moves: 42");
} }
@@ -2962,7 +2962,7 @@ mod tests {
max_time_secs: Some(300), max_time_secs: Some(300),
}); });
// Mark the game as won — HudChallenge should be empty. // Mark the game as won — HudChallenge should be empty.
app.world_mut().resource_mut::<GameStateResource>().0.is_won = true; app.world_mut().resource_mut::<GameStateResource>().0.set_test_won(true);
app.update(); app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), ""); assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
} }
@@ -3012,7 +3012,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<GameStateResource>() .resource_mut::<GameStateResource>()
.0 .0
.move_count += 1; .set_test_move_count(1);
app.update(); app.update();
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "AUTO"); assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "AUTO");
} }
@@ -3024,7 +3024,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<GameStateResource>() .resource_mut::<GameStateResource>()
.0 .0
.move_count += 1; .set_test_move_count(1);
app.update(); app.update();
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), ""); assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "");
} }
+2 -2
View File
@@ -305,7 +305,7 @@ fn handle_keyboard_hint(
let Some(ref g) = game else { return }; let Some(ref g) = game else { return };
if g.0.is_won { if g.0.is_won() {
info_toast.write(InfoToastEvent( info_toast.write(InfoToastEvent(
"Game won! Press N for a new game".to_string(), "Game won! Press N for a new game".to_string(),
)); ));
@@ -1153,7 +1153,7 @@ fn card_position(
y_offset -= layout.card_size.y * step; y_offset -= layout.card_size.y * step;
} }
Vec2::new(base.x, base.y + y_offset) Vec2::new(base.x, base.y + y_offset)
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode == DrawMode::DrawThree { } else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawMode::DrawThree {
// In Draw-Three mode the top 3 waste cards are fanned in X to match // In Draw-Three mode the top 3 waste cards are fanned in X to match
// card_plugin::card_positions(). Hit-testing must use the same offsets // card_plugin::card_positions(). Hit-testing must use the same offsets
// so clicking the visually rightmost (top) card actually registers. // so clicking the visually rightmost (top) card actually registers.
+2 -2
View File
@@ -340,7 +340,7 @@ fn handle_forfeit_request(
if !forfeit_screens.is_empty() { if !forfeit_screens.is_empty() {
return; return;
} }
let game_in_progress = game.as_ref().is_some_and(|g| !g.0.is_won); let game_in_progress = game.as_ref().is_some_and(|g| !g.0.is_won());
if !game_in_progress { if !game_in_progress {
toast.write(InfoToastEvent("No game to forfeit".to_string())); toast.write(InfoToastEvent("No game to forfeit".to_string()));
return; return;
@@ -1025,7 +1025,7 @@ mod tests {
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin); app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
app.init_resource::<ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
let mut game = GameState::new(1, DrawMode::DrawOne); let mut game = GameState::new(1, DrawMode::DrawOne);
game.is_won = true; game.set_test_won(true);
app.insert_resource(GameStateResource(game)); app.insert_resource(GameStateResource(game));
app.update(); app.update();
+2 -2
View File
@@ -63,7 +63,7 @@ impl PendingHintTask {
/// Spawn a new solver task for `state` with `config`. Drops any /// Spawn a new solver task for `state` with `config`. Drops any
/// previously in-flight task first (cancel-on-replace). /// previously in-flight task first (cancel-on-replace).
pub fn spawn(&mut self, state: GameState, config: SolverConfig) { pub fn spawn(&mut self, state: GameState, config: SolverConfig) {
let move_count_at_spawn = state.move_count; let move_count_at_spawn = state.move_count();
let handle = AsyncComputeTaskPool::get().spawn(async move { let handle = AsyncComputeTaskPool::get().spawn(async move {
let outcome = try_solve_from_state(&state, &config); let outcome = try_solve_from_state(&state, &config);
match outcome.result { match outcome.result {
@@ -156,7 +156,7 @@ pub fn poll_pending_hint_task(
pending.inner = None; pending.inner = None;
let Some(g) = game else { return }; let Some(g) = game else { return };
if g.0.move_count != move_count_at_spawn { if g.0.move_count() != move_count_at_spawn {
return; return;
} }
+7 -7
View File
@@ -534,7 +534,7 @@ fn update_stats_on_win(
let prev_streak = stats.0.win_streak_current; let prev_streak = stats.0.win_streak_current;
stats stats
.0 .0
.update_on_win(ev.score, ev.time_seconds, &game.0.draw_mode); .update_on_win(ev.score, ev.time_seconds, &game.0.draw_mode());
// Per-mode best score / fastest win — additive on top of the // Per-mode best score / fastest win — additive on top of the
// lifetime totals tracked by `update_on_win`. TimeAttack is a // lifetime totals tracked by `update_on_win`. TimeAttack is a
// no-op inside the helper because it has its own session-level // no-op inside the helper because it has its own session-level
@@ -588,7 +588,7 @@ fn update_stats_on_new_game(
mut toast: MessageWriter<InfoToastEvent>, mut toast: MessageWriter<InfoToastEvent>,
) { ) {
for _ in events.read() { for _ in events.read() {
if game.0.move_count > 0 && !game.0.is_won { if game.0.move_count() > 0 && !game.0.is_won() {
let streak = stats.0.win_streak_current; let streak = stats.0.win_streak_current;
stats.0.record_abandoned(); stats.0.record_abandoned();
persist(&path, &stats.0, "abandoned game"); persist(&path, &stats.0, "abandoned game");
@@ -614,7 +614,7 @@ fn handle_forfeit(
mut auto_complete: Option<ResMut<AutoCompleteState>>, mut auto_complete: Option<ResMut<AutoCompleteState>>,
) { ) {
for _ in events.read() { for _ in events.read() {
if game.0.move_count > 0 && !game.0.is_won { if game.0.move_count() > 0 && !game.0.is_won() {
let streak = stats.0.win_streak_current; let streak = stats.0.win_streak_current;
stats.0.record_abandoned(); stats.0.record_abandoned();
persist(&path, &stats.0, "forfeit"); persist(&path, &stats.0, "forfeit");
@@ -1327,7 +1327,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<crate::resources::GameStateResource>() .resource_mut::<crate::resources::GameStateResource>()
.0 .0
.draw_mode = solitaire_core::DrawMode::DrawThree; .set_test_draw_mode(solitaire_core::DrawMode::DrawThree);
app.world_mut().write_message(GameWonEvent { app.world_mut().write_message(GameWonEvent {
score: 500, score: 500,
@@ -1373,7 +1373,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<crate::resources::GameStateResource>() .resource_mut::<crate::resources::GameStateResource>()
.0 .0
.move_count = 3; .set_test_move_count(3);
app.world_mut().write_message(NewGameRequestEvent { app.world_mut().write_message(NewGameRequestEvent {
seed: Some(999), seed: Some(999),
@@ -1699,7 +1699,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<crate::resources::GameStateResource>() .resource_mut::<crate::resources::GameStateResource>()
.0 .0
.move_count = 1; .set_test_move_count(1);
app.world_mut().write_message(ForfeitEvent); app.world_mut().write_message(ForfeitEvent);
app.update(); app.update();
@@ -1725,7 +1725,7 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<crate::resources::GameStateResource>() .resource_mut::<crate::resources::GameStateResource>()
.0 .0
.move_count = 1; .set_test_move_count(1);
app.world_mut().write_message(ForfeitEvent); app.world_mut().write_message(ForfeitEvent);
app.update(); app.update();
+1 -1
View File
@@ -331,7 +331,7 @@ fn push_replay_on_win(
} }
let replay = Replay::new( let replay = Replay::new(
game.0.seed, game.0.seed,
game.0.draw_mode, game.0.draw_mode(),
game.0.mode, game.0.mode,
ev.time_seconds, ev.time_seconds,
ev.score, ev.score,
+1 -1
View File
@@ -182,7 +182,7 @@ fn advance_time_attack(
// No shared screen-state enum currently covers every overlay. Pause the // No shared screen-state enum currently covers every overlay. Pause the
// countdown whenever gameplay is blocked by a modal, the pause flag, or a // countdown whenever gameplay is blocked by a modal, the pause flag, or a
// just-won board state. // just-won board state.
if paused.is_some_and(|p| p.0) || game.0.is_won || !modal_scrims.is_empty() { if paused.is_some_and(|p| p.0) || game.0.is_won() || !modal_scrims.is_empty() {
return; return;
} }
session.remaining_secs = (session.remaining_secs - time.delta_secs()).max(0.0); session.remaining_secs = (session.remaining_secs - time.delta_secs()).max(0.0);
+1 -1
View File
@@ -83,7 +83,7 @@ fn evaluate_weekly_goals(
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 {
if !def.matches(&ctx) { if !def.matches(&ctx) {
+10 -10
View File
@@ -194,8 +194,8 @@ impl ReplayPlayer {
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
.game .game
.stock_cards() .stock_cards()
@@ -359,7 +359,7 @@ fn pile_name(pile: KlondikePile) -> String {
} }
fn can_stock_click(game: &GameState) -> bool { fn can_stock_click(game: &GameState) -> bool {
!(game.is_won || game.stock_cards().is_empty() && game.waste_cards().is_empty()) !(game.is_won() || game.stock_cards().is_empty() && game.waste_cards().is_empty())
} }
fn legal_moves_for_game(game: &GameState) -> Vec<DebugMove> { fn legal_moves_for_game(game: &GameState) -> Vec<DebugMove> {
@@ -450,7 +450,7 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb
false false
}); });
let soft_lock = !game.is_won && stock.is_empty() && waste.is_empty() && legal_moves.is_empty(); let soft_lock = !game.is_won() && stock.is_empty() && waste.is_empty() && legal_moves.is_empty();
let state_ok = duplicate_card_ids.is_empty() let state_ok = duplicate_card_ids.is_empty()
&& missing_card_ids.is_empty() && missing_card_ids.is_empty()
@@ -496,9 +496,9 @@ impl SolitaireGame {
}; };
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(),
@@ -582,7 +582,7 @@ impl SolitaireGame {
fn replay_moves_native(&self) -> Result<Vec<ReplayMove>, String> { fn replay_moves_native(&self) -> Result<Vec<ReplayMove>, String> {
let mut replay_game = let mut replay_game =
GameState::new_with_mode(self.game.seed, self.game.draw_mode, self.game.mode); GameState::new_with_mode(self.game.seed, self.game.draw_mode(), self.game.mode);
let mut replay_moves = Vec::new(); let mut replay_moves = Vec::new();
for instruction in self.game.instruction_history() { for instruction in self.game.instruction_history() {
@@ -668,7 +668,7 @@ impl SolitaireGame {
let state_json = serde_json::to_string(&self.game).unwrap_or_default(); let state_json = serde_json::to_string(&self.game).unwrap_or_default();
DebugSnapshot { DebugSnapshot {
seed: self.game.seed, seed: self.game.seed,
draw_mode: self.game.draw_mode, draw_mode: self.game.draw_mode(),
mode: self.game.mode, mode: self.game.mode,
state: self.snap(), state: self.snap(),
legal_moves, legal_moves,
@@ -822,7 +822,7 @@ impl SolitaireGame {
/// waste by calling `draw()` so the next step can try again. Returns the /// waste by calling `draw()` so the next step can try again. Returns the
/// post-move snapshot, or `null` when no progress is possible. /// post-move snapshot, or `null` when no progress is possible.
pub fn auto_complete_step(&mut self) -> JsValue { pub fn auto_complete_step(&mut self) -> JsValue {
if !self.game.is_auto_completable { if !self.game.is_auto_completable() {
return JsValue::NULL; return JsValue::NULL;
} }
if let Some((from, to)) = self.game.next_auto_complete_move() { if let Some((from, to)) = self.game.next_auto_complete_move() {