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 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;
}
+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.
/// Returns `true` when a move was successfully applied.
fn apply_one_move(game: &mut GameState, move_idx: usize) -> bool {
if game.is_won {
if game.is_won() {
return false;
}
let instructions = game.possible_instructions();
@@ -218,10 +218,10 @@ proptest! {
// Snapshot the state before the move.
let before_ids = all_cards(&game);
let before_move_count = game.move_count;
let before_move_count = game.move_count();
// 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
}
@@ -236,7 +236,7 @@ proptest! {
"pile layout after undo differs from the pre-move snapshot",
);
prop_assert_eq!(
game.move_count,
game.move_count(),
before_move_count,
"move_count after undo must equal the pre-move value",
);
+1
View File
@@ -39,6 +39,7 @@ keyring-core = { workspace = true }
jni = { workspace = true }
[dev-dependencies]
solitaire_core = { workspace = true, features = ["test-support"] }
solitaire_server = { path = "../solitaire_server" }
solitaire_sync = { 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
// a first move. An already-won state therefore returns no recommendation.
if initial.is_won {
if initial.is_won() {
return SolveOutcome {
result: SolverResult::Unwinnable,
first_move: None,
@@ -101,7 +101,7 @@ fn solve_game_state(initial: &GameState, config: &SolverConfig) -> SolveOutcome
}
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,
solve_moves_budget: config.move_budget,
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> {
let data = fs::read(path).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`
/// because a completed game should not be resumed.
pub fn save_game_state_to(path: &Path, gs: &GameState) -> io::Result<()> {
if gs.is_won {
if gs.is_won() {
return Ok(());
}
if let Some(parent) = path.parent() {
@@ -386,8 +386,8 @@ mod tests {
let loaded = load_game_state_from(&path).expect("load");
assert_eq!(loaded.seed, gs.seed);
assert_eq!(loaded.draw_mode, gs.draw_mode);
assert!(!loaded.is_won);
assert_eq!(loaded.draw_mode(), gs.draw_mode());
assert!(!loaded.is_won());
}
#[test]
@@ -411,7 +411,7 @@ mod tests {
let _ = fs::remove_file(&path);
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");
assert!(
!path.exists(),
+2 -2
View File
@@ -819,7 +819,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.draw_mode = solitaire_core::DrawMode::DrawThree;
.set_test_draw_mode(solitaire_core::DrawMode::DrawThree);
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -868,7 +868,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.draw_mode = solitaire_core::DrawMode::DrawThree;
.set_test_draw_mode(solitaire_core::DrawMode::DrawThree);
app.world_mut().write_message(GameWonEvent {
score: 500,
+6 -6
View File
@@ -76,14 +76,14 @@ fn detect_auto_complete(
}
changed.clear();
if game.0.is_won {
if game.0.is_won() {
state.active = false;
return;
}
if game.0.is_auto_completable && !state.active {
if game.0.is_auto_completable() && !state.active {
state.active = true;
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
// (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
@@ -209,7 +209,7 @@ mod tests {
Tableau::Tableau1,
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 = (
KlondikePile::Tableau(Tableau::Tableau1),
KlondikePile::Foundation(Foundation::Foundation1),
@@ -228,7 +228,7 @@ mod tests {
fn detect_activates_when_auto_completable() {
let mut app = headless_app();
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().write_message(StateChangedEvent);
app.update();
@@ -263,7 +263,7 @@ mod tests {
let mut app = headless_app();
// Inject a won game state — active should not be set.
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().write_message(StateChangedEvent);
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
// and its rank/suit peek behind the incoming card.
let waste_buffer_id: Option<Card> = {
let visible = match game.draw_mode {
let visible = match game.draw_mode() {
DrawMode::DrawOne => 1_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
// shows up to 3 fanned in X (matching the standard Klondike presentation).
let render_start = if is_waste {
let visible = match game.draw_mode {
let visible = match game.draw_mode() {
DrawMode::DrawOne => 1_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 rendered_len = cards[render_start..].len();
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
// x=0 to prevent a flash during the draw tween. When len ≤
// 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.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 visible_start = pile_len.saturating_sub(3);
let slot = index.saturating_sub(visible_start) as f32;
+1 -1
View File
@@ -408,7 +408,7 @@ fn start_deal_anim(
return;
}
// Only animate a fresh deal (no moves made yet).
if game.0.move_count != 0 {
if game.0.move_count() != 0 {
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 prompt_worthy = saved
.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 {
(
GameState::new(seed_from_system_time(), DrawMode::DrawOne),
@@ -302,7 +302,7 @@ fn tick_elapsed_time(
*skip_next_delta = false;
return;
}
let is_won = game.0.is_won;
let is_won = game.0.is_won();
advance_elapsed(
&mut game.0.elapsed_seconds,
&mut accumulator,
@@ -424,7 +424,7 @@ fn handle_new_game(
for ev in new_game.read() {
// 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.
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
// duplicates) or if the event itself was already confirmed by 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.
let draw_mode = settings
.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);
// Solver-backed retry: when the player has opted in to
@@ -823,7 +823,7 @@ fn handle_draw(
if stock.is_empty() {
Vec::new()
} else {
let draw_count = match game.0.draw_mode {
let draw_count = match game.0.draw_mode() {
DrawMode::DrawOne => 1_usize,
DrawMode::DrawThree => 3_usize,
};
@@ -865,7 +865,7 @@ fn handle_move(
path: Option<Res<GameStatePath>>,
) {
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.
// 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);
@@ -910,7 +910,7 @@ fn handle_move(
foundation_done.write(FoundationCompletedEvent { slot, suit });
}
changed.write(StateChangedEvent);
if !was_won && game.0.is_won {
if !was_won && game.0.is_won() {
won.write(GameWonEvent {
score: game.0.score,
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 replay = Replay::new(
game.0.seed,
game.0.draw_mode,
game.0.draw_mode(),
game.0.mode,
ev.time_seconds,
ev.score,
@@ -1093,13 +1093,13 @@ fn check_no_moves(
// Despawn game-over overlay whenever moves become available again or game is won.
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 {
commands.entity(entity).despawn();
}
}
if game.0.is_won {
if game.0.is_won() {
return;
}
@@ -1248,7 +1248,7 @@ fn auto_save_game_state(
// or there's a pending restore the player hasn't answered — saving
// the fresh-deal placeholder we seeded GameStateResource with at
// 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;
}
@@ -1596,7 +1596,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.move_count = 1;
.set_test_move_count(1);
// Re-arm the timer past the threshold every frame and pump
// updates until the save fires. Caps at 16 iterations — a
@@ -1845,7 +1845,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.move_count = 5;
.set_test_move_count(5);
app.world_mut().write_message(NewGameRequestEvent {
seed: None,
mode: None,
@@ -1869,7 +1869,7 @@ mod tests {
let mut app = test_app_with_input(42);
// move_count stays at 0 (fresh game).
assert_eq!(
app.world().resource::<GameStateResource>().0.move_count,
app.world().resource::<GameStateResource>().0.move_count(),
0,
"test assumes a fresh game with no moves"
);
@@ -2362,7 +2362,7 @@ mod tests {
app.update();
// 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]
@@ -2436,7 +2436,7 @@ mod tests {
// The chosen seed is non-deterministic (system time),
// but the new game must have been started cleanly:
// 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!(
app.world()
.resource::<GameStateResource>()
@@ -2496,7 +2496,7 @@ mod tests {
);
// New game completed: a fresh deal carries 0 moves.
assert_eq!(
app.world().resource::<GameStateResource>().0.move_count,
app.world().resource::<GameStateResource>().0.move_count(),
0,
"completed new game must be in fresh-deal state",
);
+11 -11
View File
@@ -1154,7 +1154,7 @@ fn handle_hint_button(
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()));
return;
}
@@ -2106,10 +2106,10 @@ fn update_won_previously(
let Ok(mut text) = q.single_mut() else {
return;
};
let won_before = !game.0.is_won
let won_before = !game.0.is_won()
&& history.as_ref().is_some_and(|h| {
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 {
@@ -2279,11 +2279,11 @@ fn update_hud(
};
}
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() {
**t = match g.mode {
GameMode::Classic => match g.draw_mode {
GameMode::Classic => match g.draw_mode() {
DrawMode::DrawOne => String::new(),
DrawMode::DrawThree => "Draw 3".to_string(),
},
@@ -2296,7 +2296,7 @@ fn update_hud(
// --- Daily challenge constraint (with time-low colour warning) ---
if let Ok((mut t, mut color)) = challenge_q.single_mut() {
if g.is_won {
if g.is_won() {
**t = String::new();
} else if let Some(dc) = daily.as_deref() {
**t = challenge_hud_text(dc);
@@ -2334,7 +2334,7 @@ fn update_hud(
// --- Draw-cycle indicator (Draw-Three mode only) ---
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.
String::new()
} else {
@@ -2774,7 +2774,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.move_count = 42;
.set_test_move_count(42);
app.update();
assert_eq!(read_hud_text::<HudMoves>(&mut app), "Moves: 42");
}
@@ -2962,7 +2962,7 @@ mod tests {
max_time_secs: Some(300),
});
// 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();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
}
@@ -3012,7 +3012,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.move_count += 1;
.set_test_move_count(1);
app.update();
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "AUTO");
}
@@ -3024,7 +3024,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.move_count += 1;
.set_test_move_count(1);
app.update();
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 };
if g.0.is_won {
if g.0.is_won() {
info_toast.write(InfoToastEvent(
"Game won! Press N for a new game".to_string(),
));
@@ -1153,7 +1153,7 @@ fn card_position(
y_offset -= layout.card_size.y * step;
}
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
// card_plugin::card_positions(). Hit-testing must use the same offsets
// 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() {
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 {
toast.write(InfoToastEvent("No game to forfeit".to_string()));
return;
@@ -1025,7 +1025,7 @@ mod tests {
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
app.init_resource::<ButtonInput<KeyCode>>();
let mut game = GameState::new(1, DrawMode::DrawOne);
game.is_won = true;
game.set_test_won(true);
app.insert_resource(GameStateResource(game));
app.update();
+2 -2
View File
@@ -63,7 +63,7 @@ impl PendingHintTask {
/// Spawn a new solver task for `state` with `config`. Drops any
/// previously in-flight task first (cancel-on-replace).
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 outcome = try_solve_from_state(&state, &config);
match outcome.result {
@@ -156,7 +156,7 @@ pub fn poll_pending_hint_task(
pending.inner = None;
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;
}
+7 -7
View File
@@ -534,7 +534,7 @@ fn update_stats_on_win(
let prev_streak = stats.0.win_streak_current;
stats
.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
// lifetime totals tracked by `update_on_win`. TimeAttack is a
// 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>,
) {
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;
stats.0.record_abandoned();
persist(&path, &stats.0, "abandoned game");
@@ -614,7 +614,7 @@ fn handle_forfeit(
mut auto_complete: Option<ResMut<AutoCompleteState>>,
) {
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;
stats.0.record_abandoned();
persist(&path, &stats.0, "forfeit");
@@ -1327,7 +1327,7 @@ mod tests {
app.world_mut()
.resource_mut::<crate::resources::GameStateResource>()
.0
.draw_mode = solitaire_core::DrawMode::DrawThree;
.set_test_draw_mode(solitaire_core::DrawMode::DrawThree);
app.world_mut().write_message(GameWonEvent {
score: 500,
@@ -1373,7 +1373,7 @@ mod tests {
app.world_mut()
.resource_mut::<crate::resources::GameStateResource>()
.0
.move_count = 3;
.set_test_move_count(3);
app.world_mut().write_message(NewGameRequestEvent {
seed: Some(999),
@@ -1699,7 +1699,7 @@ mod tests {
app.world_mut()
.resource_mut::<crate::resources::GameStateResource>()
.0
.move_count = 1;
.set_test_move_count(1);
app.world_mut().write_message(ForfeitEvent);
app.update();
@@ -1725,7 +1725,7 @@ mod tests {
app.world_mut()
.resource_mut::<crate::resources::GameStateResource>()
.0
.move_count = 1;
.set_test_move_count(1);
app.world_mut().write_message(ForfeitEvent);
app.update();
+1 -1
View File
@@ -331,7 +331,7 @@ fn push_replay_on_win(
}
let replay = Replay::new(
game.0.seed,
game.0.draw_mode,
game.0.draw_mode(),
game.0.mode,
ev.time_seconds,
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
// countdown whenever gameplay is blocked by a modal, the pause flag, or a
// 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;
}
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 {
time_seconds: ev.time_seconds,
used_undo: game.0.undo_count > 0,
draw_mode: game.0.draw_mode,
draw_mode: game.0.draw_mode(),
};
for def in WEEKLY_GOALS {
if !def.matches(&ctx) {
+10 -10
View File
@@ -194,8 +194,8 @@ impl ReplayPlayer {
step_idx: self.step_idx,
total_steps: self.moves.len(),
score: self.game.score,
move_count: self.game.move_count,
is_won: self.game.is_won,
move_count: self.game.move_count(),
is_won: self.game.is_won(),
stock: self
.game
.stock_cards()
@@ -359,7 +359,7 @@ fn pile_name(pile: KlondikePile) -> String {
}
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> {
@@ -450,7 +450,7 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb
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()
&& missing_card_ids.is_empty()
@@ -496,9 +496,9 @@ impl SolitaireGame {
};
GameSnapshot {
score: self.game.score,
move_count: self.game.move_count,
is_won: self.game.is_won,
is_auto_completable: self.game.is_auto_completable,
move_count: self.game.move_count(),
is_won: self.game.is_won(),
is_auto_completable: self.game.is_auto_completable(),
has_moves,
undo_count: self.game.undo_count,
undo_stack_len: self.game.undo_stack_len(),
@@ -582,7 +582,7 @@ impl SolitaireGame {
fn replay_moves_native(&self) -> Result<Vec<ReplayMove>, String> {
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();
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();
DebugSnapshot {
seed: self.game.seed,
draw_mode: self.game.draw_mode,
draw_mode: self.game.draw_mode(),
mode: self.game.mode,
state: self.snap(),
legal_moves,
@@ -822,7 +822,7 @@ impl SolitaireGame {
/// waste by calling `draw()` so the next step can try again. Returns the
/// post-move snapshot, or `null` when no progress is possible.
pub fn auto_complete_step(&mut self) -> JsValue {
if !self.game.is_auto_completable {
if !self.game.is_auto_completable() {
return JsValue::NULL;
}
if let Some((from, to)) = self.game.next_auto_complete_move() {