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 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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), "");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user