refactor(core): card_game redundancy cleanup + derive scoring from upstream stats #88
+11
@@ -30,3 +30,14 @@ solitaire_server/e2e/test-results/
|
|||||||
deploy/matomo-secret.yaml
|
deploy/matomo-secret.yaml
|
||||||
deploy/*-secret.yaml
|
deploy/*-secret.yaml
|
||||||
deploy/*-auth-secret.yaml
|
deploy/*-auth-secret.yaml
|
||||||
|
|
||||||
|
# Local agent-tooling artifacts (Codex / claude-flow) — keep out of the repo
|
||||||
|
/.agents/
|
||||||
|
/.codex/
|
||||||
|
/AGENTS.md
|
||||||
|
# claude-flow scratch dirs, anywhere in the tree (e.g. solitaire_engine/src/)
|
||||||
|
.claude-flow/
|
||||||
|
|
||||||
|
# Local token-saving helper scripts (peek/cargoclip/testfail/diffclip/etc.) —
|
||||||
|
# inspection-only Go tools, not committed. Tracked scripts/*.sh and *.md stay.
|
||||||
|
scripts/*.go
|
||||||
|
|||||||
@@ -19,8 +19,8 @@
|
|||||||
//! --per-tier Seeds to emit per tier (default 40)
|
//! --per-tier Seeds to emit per tier (default 40)
|
||||||
//! --help Print this message
|
//! --help Print this message
|
||||||
|
|
||||||
use solitaire_core::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
use solitaire_data::solver::try_solve;
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
// Budget boundaries defining each tier. A seed belongs to the lowest tier
|
// Budget boundaries defining each tier. A seed belongs to the lowest tier
|
||||||
// whose budget proves it Winnable.
|
// whose budget proves it Winnable.
|
||||||
@@ -74,7 +74,7 @@ fn main() {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
let draw_mode = DrawMode::DrawOne;
|
let draw_mode = DrawStockConfig::DrawOne;
|
||||||
let num_tiers = BUDGETS.len();
|
let num_tiers = BUDGETS.len();
|
||||||
let mut buckets: Vec<Vec<u64>> = vec![Vec::with_capacity(per_tier); num_tiers];
|
let mut buckets: Vec<Vec<u64>> = vec![Vec::with_capacity(per_tier); num_tiers];
|
||||||
let mut tried: u64 = 0;
|
let mut tried: u64 = 0;
|
||||||
@@ -99,7 +99,7 @@ fn main() {
|
|||||||
if buckets[i].len() >= per_tier {
|
if buckets[i].len() >= per_tier {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match try_solve(seed, draw_mode, move_budget, state_budget) {
|
match GameState::solve_fresh_deal(seed, draw_mode, move_budget, state_budget) {
|
||||||
Ok(Some(_)) => {
|
Ok(Some(_)) => {
|
||||||
buckets[i].push(seed);
|
buckets[i].push(seed);
|
||||||
eprintln!(
|
eprintln!(
|
||||||
|
|||||||
@@ -17,8 +17,9 @@
|
|||||||
//! --count Number of Winnable seeds to emit (default 75)
|
//! --count Number of Winnable seeds to emit (default 75)
|
||||||
//! --help Print this message
|
//! --help Print this message
|
||||||
|
|
||||||
use solitaire_core::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
use solitaire_data::solver::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, try_solve};
|
use solitaire_core::game_state::GameState;
|
||||||
|
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let mut args = std::env::args().skip(1).peekable();
|
let mut args = std::env::args().skip(1).peekable();
|
||||||
@@ -67,7 +68,7 @@ fn main() {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
let draw_mode = DrawMode::DrawOne;
|
let draw_mode = DrawStockConfig::DrawOne;
|
||||||
let mut found: Vec<u64> = Vec::with_capacity(count);
|
let mut found: Vec<u64> = Vec::with_capacity(count);
|
||||||
let mut tried: u64 = 0;
|
let mut tried: u64 = 0;
|
||||||
let mut seed = start;
|
let mut seed = start;
|
||||||
@@ -77,7 +78,7 @@ fn main() {
|
|||||||
while found.len() < count {
|
while found.len() < count {
|
||||||
tried += 1;
|
tried += 1;
|
||||||
if matches!(
|
if matches!(
|
||||||
try_solve(
|
GameState::solve_fresh_deal(
|
||||||
seed,
|
seed,
|
||||||
draw_mode,
|
draw_mode,
|
||||||
DEFAULT_SOLVE_MOVES_BUDGET,
|
DEFAULT_SOLVE_MOVES_BUDGET,
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
pub use card_game::{Card, Deck, Rank, Suit};
|
|
||||||
|
|
||||||
/// Maps a [`Card`] to a stable `0..=51` numeric identity, independent of the
|
|
||||||
/// upstream `card_game::Card` bit-packing.
|
|
||||||
///
|
|
||||||
/// Encoding: `suit_index * 13 + (rank as u32 - 1)`, where `suit_index` is
|
|
||||||
/// Clubs=0, Diamonds=1, Hearts=2, Spades=3 and `rank` is 1 (Ace) ..= 13 (King).
|
|
||||||
/// The deck id is intentionally ignored so the id depends only on the visible
|
|
||||||
/// face.
|
|
||||||
///
|
|
||||||
/// This is the single source of truth shared by `CardEntity` numeric tracking,
|
|
||||||
/// deterministic per-card animation jitter, and the WASM replay layer — those
|
|
||||||
/// must agree byte-for-byte so replay snapshots are identical across the
|
|
||||||
/// desktop and browser builds.
|
|
||||||
pub fn card_to_id(card: &Card) -> u32 {
|
|
||||||
let suit_index: u32 = match card.suit() {
|
|
||||||
Suit::Clubs => 0,
|
|
||||||
Suit::Diamonds => 1,
|
|
||||||
Suit::Hearts => 2,
|
|
||||||
Suit::Spades => 3,
|
|
||||||
};
|
|
||||||
suit_index * 13 + (card.rank() as u32 - 1)
|
|
||||||
}
|
|
||||||
+312
-245
@@ -1,12 +1,11 @@
|
|||||||
use crate::error::MoveError;
|
use crate::error::MoveError;
|
||||||
use crate::klondike_adapter::{
|
use crate::klondike_adapter::{
|
||||||
DrawMode, KlondikeAdapter, SavedInstruction,
|
KlondikeAdapter, SavedInstruction,
|
||||||
compute_time_bonus as scoring_time_bonus,
|
|
||||||
foundation_from_slot as adapter_foundation_from_slot,
|
foundation_from_slot as adapter_foundation_from_slot,
|
||||||
skip_cards_from_count as adapter_skip_cards_from_count,
|
skip_cards_from_count as adapter_skip_cards_from_count,
|
||||||
tableau_from_index as adapter_tableau_from_index,
|
tableau_from_index as adapter_tableau_from_index,
|
||||||
};
|
};
|
||||||
use card_game::{Card, Game as _, Session, SessionConfig};
|
use card_game::{Card, Game as _, Session, SessionConfig, SolveError};
|
||||||
use klondike::{
|
use klondike::{
|
||||||
DrawStockConfig, DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig,
|
DrawStockConfig, DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig,
|
||||||
KlondikeInstruction, KlondikePile, KlondikePileStack, SkipCards, Tableau, TableauStack,
|
KlondikeInstruction, KlondikePile, KlondikePileStack, SkipCards, Tableau, TableauStack,
|
||||||
@@ -22,10 +21,30 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
|||||||
/// - v2: `Foundation(u8)` slot keys; claimed suit derived from the bottom card.
|
/// - v2: `Foundation(u8)` slot keys; claimed suit derived from the bottom card.
|
||||||
/// - v3: session-backed save files using local `SavedInstruction` mirror types
|
/// - v3: session-backed save files using local `SavedInstruction` mirror types
|
||||||
/// with u8 indices for enum variants.
|
/// with u8 indices for enum variants.
|
||||||
/// - v4 (current): `saved_moves` uses upstream `KlondikeInstruction` serde with
|
/// - v4: `saved_moves` uses upstream `KlondikeInstruction` serde with named enum
|
||||||
/// named enum variants (e.g. `"Foundation1"` instead of `0`). v3 files are
|
/// variants (e.g. `"Foundation1"` instead of `0`). v3 files are auto-migrated
|
||||||
/// auto-migrated on load via `AnyInstruction` transparent deserialization.
|
/// on load via `AnyInstruction` transparent deserialization.
|
||||||
pub const GAME_STATE_SCHEMA_VERSION: u32 = 4;
|
/// - v5 (current): `score`, `undo_count`, and `recycle_count` are no longer
|
||||||
|
/// persisted. They are derived from the upstream `card_game`/`klondike` session
|
||||||
|
/// stats, which are rebuilt by replaying `saved_moves` on load. Older files that
|
||||||
|
/// still carry those keys load fine — the extra fields are ignored.
|
||||||
|
pub const GAME_STATE_SCHEMA_VERSION: u32 = 5;
|
||||||
|
|
||||||
|
/// Default move budget for a solvability check. Matches the winnable-deal retry
|
||||||
|
/// loop in the engine.
|
||||||
|
pub const DEFAULT_SOLVE_MOVES_BUDGET: u64 = 100_000;
|
||||||
|
/// Default unique-state budget for a solvability check.
|
||||||
|
pub const DEFAULT_SOLVE_STATES_BUDGET: u64 = 200_000;
|
||||||
|
|
||||||
|
/// Outcome of a solvability check ([`GameState::solve_first_move`]):
|
||||||
|
///
|
||||||
|
/// * `Ok(Some(instruction))` — winnable; `instruction` is the first useful move
|
||||||
|
/// on a winning path (used by the hint system).
|
||||||
|
/// * `Ok(None)` — provably unwinnable (search exhausted with no solution, or the
|
||||||
|
/// game is already won so no next move exists).
|
||||||
|
/// * `Err(SolveError)` — inconclusive; the move/state budget was exceeded before
|
||||||
|
/// a verdict was reached.
|
||||||
|
pub type SolveOutcome = Result<Option<KlondikeInstruction>, SolveError>;
|
||||||
|
|
||||||
/// Default value for `GameState::schema_version` when deserialising older
|
/// Default value for `GameState::schema_version` when deserialising older
|
||||||
/// save files that pre-date the field.
|
/// save files that pre-date the field.
|
||||||
@@ -82,13 +101,10 @@ pub enum GameMode {
|
|||||||
/// `KlondikeInstruction` serde, which produces named enum variants.
|
/// `KlondikeInstruction` serde, which produces named enum variants.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
struct PersistedGameState {
|
struct PersistedGameState {
|
||||||
pub draw_mode: DrawMode,
|
pub draw_mode: DrawStockConfig,
|
||||||
pub mode: GameMode,
|
pub mode: GameMode,
|
||||||
pub score: i32,
|
|
||||||
pub elapsed_seconds: u64,
|
pub elapsed_seconds: u64,
|
||||||
pub seed: u64,
|
pub seed: u64,
|
||||||
pub undo_count: u32,
|
|
||||||
pub recycle_count: u32,
|
|
||||||
pub take_from_foundation: bool,
|
pub take_from_foundation: bool,
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
pub saved_moves: Vec<KlondikeInstruction>,
|
pub saved_moves: Vec<KlondikeInstruction>,
|
||||||
@@ -110,20 +126,19 @@ enum AnyInstruction {
|
|||||||
V3(SavedInstruction),
|
V3(SavedInstruction),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Input struct that accepts both schema v3 and v4 `saved_moves` formats.
|
/// Input struct that accepts schema v3, v4, and v5 `saved_moves` formats.
|
||||||
///
|
///
|
||||||
/// `recycle_count` is intentionally absent: the value is rebuilt from the
|
/// `score`, `undo_count`, and `recycle_count` are intentionally absent: all
|
||||||
/// instruction replay so that stale counts (from the pre-Phase-3 undo drift
|
/// three are rebuilt by replaying the instruction history through the upstream
|
||||||
/// bug) are corrected on load. Serde ignores the field in the JSON.
|
/// session stats. Older save files (v3/v4) still carry those keys; serde ignores
|
||||||
|
/// them.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
struct PersistedGameStateIn {
|
struct PersistedGameStateIn {
|
||||||
pub draw_mode: DrawMode,
|
pub draw_mode: DrawStockConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mode: GameMode,
|
pub mode: GameMode,
|
||||||
pub score: i32,
|
|
||||||
pub elapsed_seconds: u64,
|
pub elapsed_seconds: u64,
|
||||||
pub seed: u64,
|
pub seed: u64,
|
||||||
pub undo_count: u32,
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub take_from_foundation: bool,
|
pub take_from_foundation: bool,
|
||||||
#[serde(default = "schema_v1")]
|
#[serde(default = "schema_v1")]
|
||||||
@@ -162,33 +177,25 @@ pub struct TestPileState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Full state of an in-progress Klondike Solitaire game.
|
/// Full state of an in-progress Klondike Solitaire game.
|
||||||
|
///
|
||||||
|
/// Score, undo count, and recycle count are **not** stored here. They are
|
||||||
|
/// derived on demand from the upstream `card_game`/`klondike` session stats via
|
||||||
|
/// [`GameState::score`], [`GameState::undo_count`], and
|
||||||
|
/// [`GameState::recycle_count`]. The session is the single source of truth; the
|
||||||
|
/// −15 undo penalty is configured on the session ([`Self::session_config`]) and
|
||||||
|
/// applied by the upstream score formula.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct GameState {
|
pub struct GameState {
|
||||||
/// Top-level mode (Classic / Zen).
|
/// Top-level mode (Classic / Zen).
|
||||||
pub mode: GameMode,
|
pub mode: GameMode,
|
||||||
/// Current game score. Can be negative (undo penalties subtract from score).
|
|
||||||
pub score: i32,
|
|
||||||
/// Seconds elapsed since the game started, used for time-bonus scoring.
|
/// Seconds elapsed since the game started, used for time-bonus scoring.
|
||||||
pub elapsed_seconds: u64,
|
pub elapsed_seconds: u64,
|
||||||
/// RNG seed used to deal this game. Same seed always produces the same layout.
|
/// RNG seed used to deal this game. Same seed always produces the same layout.
|
||||||
pub seed: u64,
|
pub seed: u64,
|
||||||
/// Number of times `undo()` has been successfully invoked this game.
|
|
||||||
pub undo_count: u32,
|
|
||||||
/// Number of times the waste pile has been recycled back to stock this game.
|
|
||||||
pub recycle_count: u32,
|
|
||||||
/// When `true`, the player may move the top card of a foundation pile back
|
/// When `true`, the player may move the top card of a foundation pile back
|
||||||
/// onto a compatible tableau column.
|
/// onto a compatible tableau column.
|
||||||
pub take_from_foundation: bool,
|
pub take_from_foundation: bool,
|
||||||
pub(crate) session: Session<Klondike>,
|
pub(crate) session: Session<Klondike>,
|
||||||
/// Score recorded immediately before each instruction was applied.
|
|
||||||
/// Parallel to `session.history()` during live play; used by `undo()` to
|
|
||||||
/// correctly restore the pre-move score before applying the undo penalty.
|
|
||||||
/// Empty after a load (can't be reconstructed from history alone).
|
|
||||||
score_history: Vec<i32>,
|
|
||||||
/// Whether each entry in `session.history()` was a stock recycle.
|
|
||||||
/// Parallel to `session.history()`; rebuilt from replay on load so that
|
|
||||||
/// `undo()` correctly decrements `recycle_count` even across save/load cycles.
|
|
||||||
is_recycle_history: Vec<bool>,
|
|
||||||
#[cfg(feature = "test-support")]
|
#[cfg(feature = "test-support")]
|
||||||
/// Test pile overrides. Always `None` in production runtime code.
|
/// Test pile overrides. Always `None` in production runtime code.
|
||||||
pub test_pile_state: Option<TestPileState>,
|
pub test_pile_state: Option<TestPileState>,
|
||||||
@@ -198,14 +205,14 @@ impl PartialEq for GameState {
|
|||||||
fn eq(&self, other: &Self) -> bool {
|
fn eq(&self, other: &Self) -> bool {
|
||||||
self.draw_mode() == other.draw_mode()
|
self.draw_mode() == other.draw_mode()
|
||||||
&& self.mode == other.mode
|
&& self.mode == other.mode
|
||||||
&& self.score == other.score
|
&& self.score() == other.score()
|
||||||
&& self.move_count() == other.move_count()
|
&& self.move_count() == other.move_count()
|
||||||
&& self.elapsed_seconds == other.elapsed_seconds
|
&& self.elapsed_seconds == other.elapsed_seconds
|
||||||
&& self.seed == other.seed
|
&& self.seed == other.seed
|
||||||
&& self.is_won() == other.is_won()
|
&& self.is_won() == other.is_won()
|
||||||
&& self.is_auto_completable() == other.is_auto_completable()
|
&& self.is_auto_completable() == other.is_auto_completable()
|
||||||
&& self.undo_count == other.undo_count
|
&& self.undo_count() == other.undo_count()
|
||||||
&& self.recycle_count == other.recycle_count
|
&& self.recycle_count() == other.recycle_count()
|
||||||
&& self.take_from_foundation == other.take_from_foundation
|
&& self.take_from_foundation == other.take_from_foundation
|
||||||
&& self.stock_cards() == other.stock_cards()
|
&& self.stock_cards() == other.stock_cards()
|
||||||
&& self.waste_cards() == other.waste_cards()
|
&& self.waste_cards() == other.waste_cards()
|
||||||
@@ -228,11 +235,8 @@ impl Serialize for GameState {
|
|||||||
PersistedGameState {
|
PersistedGameState {
|
||||||
draw_mode: self.draw_mode(),
|
draw_mode: self.draw_mode(),
|
||||||
mode: self.mode,
|
mode: self.mode,
|
||||||
score: self.score,
|
|
||||||
elapsed_seconds: self.elapsed_seconds,
|
elapsed_seconds: self.elapsed_seconds,
|
||||||
seed: self.seed,
|
seed: self.seed,
|
||||||
undo_count: self.undo_count,
|
|
||||||
recycle_count: self.recycle_count,
|
|
||||||
take_from_foundation: self.take_from_foundation,
|
take_from_foundation: self.take_from_foundation,
|
||||||
schema_version: GAME_STATE_SCHEMA_VERSION,
|
schema_version: GAME_STATE_SCHEMA_VERSION,
|
||||||
saved_moves: self.saved_moves(),
|
saved_moves: self.saved_moves(),
|
||||||
@@ -245,10 +249,10 @@ impl<'de> Deserialize<'de> for GameState {
|
|||||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
let persisted = PersistedGameStateIn::deserialize(deserializer)?;
|
let persisted = PersistedGameStateIn::deserialize(deserializer)?;
|
||||||
|
|
||||||
// Accept v3 (legacy u8-index format, auto-migrated) and v4 (current,
|
// Accept v3 (legacy u8-index format, auto-migrated), v4 (upstream
|
||||||
// upstream named-variant serde). Reject everything else.
|
// named-variant serde), and v5 (current, derived stats). Reject the rest.
|
||||||
match persisted.schema_version {
|
match persisted.schema_version {
|
||||||
3 | 4 => {}
|
3..=5 => {}
|
||||||
v => {
|
v => {
|
||||||
return Err(serde::de::Error::custom(format!(
|
return Err(serde::de::Error::custom(format!(
|
||||||
"unsupported GameState schema version {v}"
|
"unsupported GameState schema version {v}"
|
||||||
@@ -258,30 +262,22 @@ impl<'de> Deserialize<'de> for GameState {
|
|||||||
|
|
||||||
let mut game = Self {
|
let mut game = Self {
|
||||||
mode: persisted.mode,
|
mode: persisted.mode,
|
||||||
score: persisted.score,
|
|
||||||
elapsed_seconds: persisted.elapsed_seconds,
|
elapsed_seconds: persisted.elapsed_seconds,
|
||||||
seed: persisted.seed,
|
seed: persisted.seed,
|
||||||
undo_count: persisted.undo_count,
|
|
||||||
// Rebuilt from the replay loop below; persisted value may be stale
|
|
||||||
// due to the pre-Phase-3 undo drift bug.
|
|
||||||
recycle_count: 0,
|
|
||||||
take_from_foundation: persisted.take_from_foundation,
|
take_from_foundation: persisted.take_from_foundation,
|
||||||
session: Self::new_session(persisted.seed, persisted.draw_mode),
|
session: Self::new_session(persisted.seed, persisted.draw_mode),
|
||||||
// score_history cannot be faithfully rebuilt from the instruction
|
|
||||||
// history because live-play undo penalties are not recorded in
|
|
||||||
// saved_moves. Leave empty; undo() falls back to old behaviour for
|
|
||||||
// any move made before this load (see undo() for details).
|
|
||||||
score_history: Vec::new(),
|
|
||||||
// is_recycle_history IS rebuilt: recycle detection only needs the
|
|
||||||
// pre-instruction session state, which is available during replay.
|
|
||||||
is_recycle_history: Vec::new(),
|
|
||||||
#[cfg(feature = "test-support")]
|
#[cfg(feature = "test-support")]
|
||||||
test_pile_state: None,
|
test_pile_state: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Replay the saved instruction history. The upstream session tracks
|
||||||
|
// score components and recycle_count as it processes each move, so the
|
||||||
|
// derived stats are correct once replay completes. `undo_count()` resets
|
||||||
|
// to 0 across save/load because undone moves are not part of the saved
|
||||||
|
// forward history.
|
||||||
let replay_config = Self::replay_config(persisted.draw_mode);
|
let replay_config = Self::replay_config(persisted.draw_mode);
|
||||||
for any in persisted.saved_moves {
|
for any in persisted.saved_moves {
|
||||||
// AnyInstruction::V4 arrives directly from upstream serde (schema v4).
|
// AnyInstruction::V4 arrives directly from upstream serde (schema v4+).
|
||||||
// AnyInstruction::V3 was serialised with u8 indices (schema v3) and is
|
// AnyInstruction::V3 was serialised with u8 indices (schema v3) and is
|
||||||
// converted here via the existing TryFrom impl.
|
// converted here via the existing TryFrom impl.
|
||||||
let instruction = match any {
|
let instruction = match any {
|
||||||
@@ -291,12 +287,6 @@ impl<'de> Deserialize<'de> for GameState {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Detect recycle BEFORE processing so that the pre-instruction
|
|
||||||
// session state (face-down stock) is still available.
|
|
||||||
let is_recycle = matches!(instruction, KlondikeInstruction::RotateStock)
|
|
||||||
&& game.stock_cards().is_empty()
|
|
||||||
&& !game.waste_cards().is_empty();
|
|
||||||
|
|
||||||
if !game
|
if !game
|
||||||
.session
|
.session
|
||||||
.state()
|
.state()
|
||||||
@@ -308,11 +298,6 @@ impl<'de> Deserialize<'de> for GameState {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
game.session.process_instruction(instruction);
|
game.session.process_instruction(instruction);
|
||||||
|
|
||||||
game.is_recycle_history.push(is_recycle);
|
|
||||||
if is_recycle {
|
|
||||||
game.recycle_count = game.recycle_count.saturating_add(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(game)
|
Ok(game)
|
||||||
@@ -321,23 +306,18 @@ impl<'de> Deserialize<'de> for GameState {
|
|||||||
|
|
||||||
impl GameState {
|
impl GameState {
|
||||||
/// Creates a new Classic-mode game dealt from the given seed and draw mode.
|
/// Creates a new Classic-mode game dealt from the given seed and draw mode.
|
||||||
pub fn new(seed: u64, draw_mode: DrawMode) -> Self {
|
pub fn new(seed: u64, draw_mode: DrawStockConfig) -> Self {
|
||||||
Self::new_with_mode(seed, draw_mode, GameMode::Classic)
|
Self::new_with_mode(seed, draw_mode, GameMode::Classic)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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: DrawStockConfig, mode: GameMode) -> Self {
|
||||||
Self {
|
Self {
|
||||||
mode,
|
mode,
|
||||||
score: 0,
|
|
||||||
elapsed_seconds: 0,
|
elapsed_seconds: 0,
|
||||||
seed,
|
seed,
|
||||||
undo_count: 0,
|
|
||||||
recycle_count: 0,
|
|
||||||
take_from_foundation: true,
|
take_from_foundation: true,
|
||||||
session: Self::new_session(seed, draw_mode),
|
session: Self::new_session(seed, draw_mode),
|
||||||
score_history: Vec::new(),
|
|
||||||
is_recycle_history: Vec::new(),
|
|
||||||
#[cfg(feature = "test-support")]
|
#[cfg(feature = "test-support")]
|
||||||
test_pile_state: None,
|
test_pile_state: None,
|
||||||
}
|
}
|
||||||
@@ -345,11 +325,45 @@ impl GameState {
|
|||||||
|
|
||||||
/// Whether the player draws one or three cards from the stock per turn.
|
/// Whether the player draws one or three cards from the stock per turn.
|
||||||
/// Derived from the underlying session config (set once at deal time).
|
/// Derived from the underlying session config (set once at deal time).
|
||||||
pub fn draw_mode(&self) -> DrawMode {
|
pub fn draw_mode(&self) -> DrawStockConfig {
|
||||||
match self.session.config().inner.draw_stock {
|
self.session.config().inner.draw_stock
|
||||||
DrawStockConfig::DrawOne => DrawMode::DrawOne,
|
}
|
||||||
DrawStockConfig::DrawThree => DrawMode::DrawThree,
|
|
||||||
|
/// Current game score, derived from the upstream session stats.
|
||||||
|
///
|
||||||
|
/// The upstream score is a linear sum of move-type counts (foundation/
|
||||||
|
/// tableau/flip deltas) plus `undos * undo_penalty` (−15 each). Floored at 0
|
||||||
|
/// so the displayed score is never negative. Returns 0 in [`GameMode::Zen`],
|
||||||
|
/// where scoring is suppressed entirely.
|
||||||
|
///
|
||||||
|
/// Note: the win-time bonus (`compute_time_bonus`) is layered on by the
|
||||||
|
/// engine's win-summary, not included here — this is the in-play base score.
|
||||||
|
pub fn score(&self) -> i32 {
|
||||||
|
if self.mode == GameMode::Zen {
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
self.session
|
||||||
|
.state()
|
||||||
|
.score(self.session.stats(), self.session.config())
|
||||||
|
.max(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of times `undo()` has been successfully invoked this game, read
|
||||||
|
/// from the upstream session stats.
|
||||||
|
///
|
||||||
|
/// Resets to 0 across a save/load cycle: only the forward instruction
|
||||||
|
/// history is persisted, so undone moves leave no trace to replay.
|
||||||
|
pub fn undo_count(&self) -> u32 {
|
||||||
|
self.session.stats().undos()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of times the waste pile has been recycled back to stock this game,
|
||||||
|
/// read from the upstream session stats.
|
||||||
|
///
|
||||||
|
/// This is a **cumulative** count — the upstream stat is not rolled back when
|
||||||
|
/// a recycle is undone, so it reflects total recycles ever performed.
|
||||||
|
pub fn recycle_count(&self) -> u32 {
|
||||||
|
self.session.stats().stats().recycle_count()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Total moves made this game (draws, recycles, and card moves), derived
|
/// Total moves made this game (draws, recycles, and card moves), derived
|
||||||
@@ -388,19 +402,21 @@ impl GameState {
|
|||||||
!self.check_win() && self.check_auto_complete()
|
!self.check_win() && self.check_auto_complete()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new_session(seed: u64, draw_mode: DrawMode) -> Session<Klondike> {
|
fn new_session(seed: u64, draw_mode: DrawStockConfig) -> Session<Klondike> {
|
||||||
Session::new(Klondike::with_seed(seed), Self::session_config(draw_mode))
|
Session::new(Klondike::with_seed(seed), Self::session_config(draw_mode))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn session_config(draw_mode: DrawMode) -> SessionConfig<KlondikeConfig> {
|
fn session_config(draw_mode: DrawStockConfig) -> SessionConfig<KlondikeConfig> {
|
||||||
SessionConfig {
|
SessionConfig {
|
||||||
inner: Self::replay_config(draw_mode),
|
inner: Self::replay_config(draw_mode),
|
||||||
undo_penalty: 0,
|
// The −15 WXP undo penalty is now applied by the upstream score
|
||||||
|
// formula (`undos * undo_penalty`) rather than by hand in `undo()`.
|
||||||
|
undo_penalty: -15,
|
||||||
..SessionConfig::default()
|
..SessionConfig::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn replay_config(draw_mode: DrawMode) -> KlondikeConfig {
|
fn replay_config(draw_mode: DrawStockConfig) -> KlondikeConfig {
|
||||||
// Always allow foundation returns during replay, regardless of the
|
// Always allow foundation returns during replay, regardless of the
|
||||||
// player's current `take_from_foundation` setting. A move recorded
|
// player's current `take_from_foundation` setting. A move recorded
|
||||||
// when the rule was enabled must replay correctly even if the player
|
// when the rule was enabled must replay correctly even if the player
|
||||||
@@ -568,7 +584,7 @@ impl GameState {
|
|||||||
/// mode. `draw_mode()` is otherwise fixed at deal time, so tests that need
|
/// mode. `draw_mode()` is otherwise fixed at deal time, so tests that need
|
||||||
/// a specific mode use this instead of mutating a field.
|
/// a specific mode use this instead of mutating a field.
|
||||||
#[cfg(feature = "test-support")]
|
#[cfg(feature = "test-support")]
|
||||||
pub fn set_test_draw_mode(&mut self, draw_mode: DrawMode) {
|
pub fn set_test_draw_mode(&mut self, draw_mode: DrawStockConfig) {
|
||||||
self.session = Self::new_session(self.seed, draw_mode);
|
self.session = Self::new_session(self.seed, draw_mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -602,6 +618,67 @@ impl GameState {
|
|||||||
state.move_count = Some(move_count);
|
state.move_count = Some(move_count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test-support helper: perform `n` real undos so [`Self::undo_count`]
|
||||||
|
/// reports `n`. Each iteration draws a card then immediately undoes it,
|
||||||
|
/// leaving the board unchanged but advancing the upstream `undos` counter.
|
||||||
|
///
|
||||||
|
/// Since `score`/`undo_count`/`recycle_count` are now derived from the
|
||||||
|
/// session stats rather than stored fields, tests drive the real session to
|
||||||
|
/// reach a desired stat instead of assigning the value directly.
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
|
pub fn force_test_undos(&mut self, n: u32) {
|
||||||
|
for _ in 0..n {
|
||||||
|
if self.draw().is_ok() {
|
||||||
|
let _ = self.undo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test-support helper: perform `n` real stock recycles so
|
||||||
|
/// [`Self::recycle_count`] reports `n`. Draws until the stock empties, then
|
||||||
|
/// draws once more to recycle, repeated `n` times.
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
|
pub fn force_test_recycles(&mut self, n: u32) {
|
||||||
|
for _ in 0..n {
|
||||||
|
let mut guard = 0;
|
||||||
|
while !self.stock_cards().is_empty() && guard < 200 {
|
||||||
|
guard += 1;
|
||||||
|
if self.draw().is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Stock now empty (waste full) — this draw recycles waste → stock.
|
||||||
|
let _ = self.draw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test-support helper: drive real moves until [`Self::score`] reaches at
|
||||||
|
/// least `target`, returning the resulting score. Prefers foundation moves
|
||||||
|
/// (+10 each) and falls back to the solver-priority move, so a modest target
|
||||||
|
/// is reached within a handful of moves on a typical deal.
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
|
pub fn force_test_score(&mut self, target: i32) -> i32 {
|
||||||
|
let mut guard = 0;
|
||||||
|
while self.score() < target && !self.is_won() && guard < 4000 {
|
||||||
|
guard += 1;
|
||||||
|
let instructions = self.possible_instructions();
|
||||||
|
let next = instructions
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.find(|i| matches!(i, KlondikeInstruction::DstFoundation(_)))
|
||||||
|
.or_else(|| instructions.into_iter().next());
|
||||||
|
match next {
|
||||||
|
Some(instruction) => {
|
||||||
|
if self.apply_instruction(instruction).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.score()
|
||||||
|
}
|
||||||
|
|
||||||
/// Test-support helper: override face-down stock cards returned by
|
/// Test-support helper: override face-down stock cards returned by
|
||||||
/// [`Self::stock_cards`].
|
/// [`Self::stock_cards`].
|
||||||
#[cfg(feature = "test-support")]
|
#[cfg(feature = "test-support")]
|
||||||
@@ -673,79 +750,6 @@ impl GameState {
|
|||||||
.ok_or_else(|| MoveError::RuleViolation("invalid tableau card count".into()))
|
.ok_or_else(|| MoveError::RuleViolation("invalid tableau card count".into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn will_flip_tableau_source(&self, from: KlondikePile, count: usize) -> bool {
|
|
||||||
let KlondikePile::Tableau(_) = from else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let pile = self.pile(from);
|
|
||||||
if pile.is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
pile.len() > count && !pile[pile.len() - count - 1].1
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns `(score_delta, is_recycle)` for `instruction` given the *current*
|
|
||||||
/// game state. Must be called **before** the instruction is applied to the
|
|
||||||
/// session; the helper reads pre-instruction pile state from `self`.
|
|
||||||
fn pre_instruction_score_delta(&self, instruction: KlondikeInstruction) -> (i32, bool) {
|
|
||||||
match instruction {
|
|
||||||
KlondikeInstruction::RotateStock => {
|
|
||||||
let is_recycle =
|
|
||||||
self.stock_cards().is_empty() && !self.waste_cards().is_empty();
|
|
||||||
if is_recycle {
|
|
||||||
let next_count = self.recycle_count.saturating_add(1);
|
|
||||||
let penalty = KlondikeAdapter::score_for_recycle_with_mode(
|
|
||||||
next_count,
|
|
||||||
self.draw_mode() == DrawMode::DrawThree,
|
|
||||||
self.mode,
|
|
||||||
);
|
|
||||||
(penalty, true)
|
|
||||||
} else {
|
|
||||||
(0, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KlondikeInstruction::DstFoundation(dst_foundation) => {
|
|
||||||
let from = dst_foundation.src;
|
|
||||||
let to = KlondikePile::Foundation(dst_foundation.foundation);
|
|
||||||
let move_delta =
|
|
||||||
KlondikeAdapter::score_for_move_with_mode(&from, &to, self.mode);
|
|
||||||
// DstFoundation always moves exactly 1 card.
|
|
||||||
let flip_bonus = if self.will_flip_tableau_source(from, 1) {
|
|
||||||
KlondikeAdapter::score_for_flip_with_mode(self.mode)
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
(move_delta + flip_bonus, false)
|
|
||||||
}
|
|
||||||
KlondikeInstruction::DstTableau(dst_tableau) => {
|
|
||||||
let (from, count) = match dst_tableau.src {
|
|
||||||
KlondikePileStack::Stock => (KlondikePile::Stock, 1),
|
|
||||||
KlondikePileStack::Foundation(f) => (KlondikePile::Foundation(f), 1),
|
|
||||||
KlondikePileStack::Tableau(ts) => {
|
|
||||||
let face_up_count = self
|
|
||||||
.session
|
|
||||||
.state()
|
|
||||||
.state()
|
|
||||||
.state()
|
|
||||||
.tableau_face_up_cards(ts.tableau)
|
|
||||||
.len();
|
|
||||||
let count = face_up_count.saturating_sub(ts.skip_cards as usize);
|
|
||||||
(KlondikePile::Tableau(ts.tableau), count)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let to = KlondikePile::Tableau(dst_tableau.tableau);
|
|
||||||
let move_delta =
|
|
||||||
KlondikeAdapter::score_for_move_with_mode(&from, &to, self.mode);
|
|
||||||
let flip_bonus = if self.will_flip_tableau_source(from, count) {
|
|
||||||
KlondikeAdapter::score_for_flip_with_mode(self.mode)
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
(move_delta + flip_bonus, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn instruction_for_move(
|
fn instruction_for_move(
|
||||||
&self,
|
&self,
|
||||||
from: KlondikePile,
|
from: KlondikePile,
|
||||||
@@ -900,19 +904,10 @@ impl GameState {
|
|||||||
return Err(MoveError::StockEmpty);
|
return Err(MoveError::StockEmpty);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (score_delta, is_recycle) =
|
// The session tracks score components and recycle_count as it processes
|
||||||
self.pre_instruction_score_delta(KlondikeInstruction::RotateStock);
|
// the instruction; no local bookkeeping required.
|
||||||
|
|
||||||
self.score_history.push(self.score);
|
|
||||||
self.is_recycle_history.push(is_recycle);
|
|
||||||
|
|
||||||
self.session
|
self.session
|
||||||
.process_instruction(KlondikeInstruction::RotateStock);
|
.process_instruction(KlondikeInstruction::RotateStock);
|
||||||
|
|
||||||
if is_recycle {
|
|
||||||
self.recycle_count = self.recycle_count.saturating_add(1);
|
|
||||||
}
|
|
||||||
self.score = (self.score + score_delta).max(0);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -950,8 +945,9 @@ impl GameState {
|
|||||||
/// instruction form — solver hints, auto-complete, replay, and the property
|
/// instruction form — solver hints, auto-complete, replay, and the property
|
||||||
/// tests. User drag-and-drop enters through [`Self::move_cards`], which is a
|
/// tests. User drag-and-drop enters through [`Self::move_cards`], which is a
|
||||||
/// thin adapter that converts pile coordinates to an instruction and
|
/// thin adapter that converts pile coordinates to an instruction and
|
||||||
/// delegates here, so the move bookkeeping (rule validation, score history,
|
/// delegates here, so the move bookkeeping (rule validation, the undo
|
||||||
/// recycle accounting, undo snapshot) lives in exactly one place.
|
/// snapshot, and the session's score/recycle stats) lives in exactly one
|
||||||
|
/// place.
|
||||||
///
|
///
|
||||||
/// Returns [`MoveError::RuleViolation`] if the instruction is illegal in the
|
/// Returns [`MoveError::RuleViolation`] if the instruction is illegal in the
|
||||||
/// current position, or [`MoveError::GameAlreadyWon`] once the game is over.
|
/// current position, or [`MoveError::GameAlreadyWon`] once the game is over.
|
||||||
@@ -973,21 +969,17 @@ impl GameState {
|
|||||||
return Err(MoveError::RuleViolation("move violates rules".into()));
|
return Err(MoveError::RuleViolation("move violates rules".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let (score_delta, is_recycle) = self.pre_instruction_score_delta(instruction);
|
// The session records the move snapshot and updates score/recycle stats.
|
||||||
|
|
||||||
self.score_history.push(self.score);
|
|
||||||
self.is_recycle_history.push(is_recycle);
|
|
||||||
|
|
||||||
self.session.process_instruction(instruction);
|
self.session.process_instruction(instruction);
|
||||||
|
|
||||||
if is_recycle {
|
|
||||||
self.recycle_count = self.recycle_count.saturating_add(1);
|
|
||||||
}
|
|
||||||
self.score = (self.score + score_delta).max(0);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore the most recent undo snapshot and apply the undo score penalty (-15).
|
/// Restore the most recent undo snapshot.
|
||||||
|
///
|
||||||
|
/// The −15 undo penalty is applied by the upstream score formula
|
||||||
|
/// (`undos * undo_penalty`), and the session increments its `undos` counter,
|
||||||
|
/// so this method only has to delegate to [`Session::undo`] after the mode
|
||||||
|
/// guards. See [`Self::score`] / [`Self::undo_count`] for the derived values.
|
||||||
pub fn undo(&mut self) -> Result<(), MoveError> {
|
pub fn undo(&mut self) -> Result<(), MoveError> {
|
||||||
if self.is_won() {
|
if self.is_won() {
|
||||||
return Err(MoveError::GameAlreadyWon);
|
return Err(MoveError::GameAlreadyWon);
|
||||||
@@ -1001,23 +993,7 @@ impl GameState {
|
|||||||
return Err(MoveError::UndoStackEmpty);
|
return Err(MoveError::UndoStackEmpty);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pop the pre-instruction score for the move being undone. Falls back
|
|
||||||
// to self.score (= old behaviour) when score_history is empty, which
|
|
||||||
// happens for moves made before a save/load cycle because undo
|
|
||||||
// penalties aren't reflected in the saved instruction history.
|
|
||||||
let pre_move_score = self.score_history.pop().unwrap_or(self.score);
|
|
||||||
let was_recycle = self.is_recycle_history.pop().unwrap_or(false);
|
|
||||||
|
|
||||||
self.session.undo();
|
self.session.undo();
|
||||||
|
|
||||||
if was_recycle {
|
|
||||||
self.recycle_count = self.recycle_count.saturating_sub(1);
|
|
||||||
}
|
|
||||||
// Apply the undo penalty to the pre-move score, not the post-move score.
|
|
||||||
// This correctly reverses any recycle or move penalty that was applied
|
|
||||||
// before adding the −15 undo penalty.
|
|
||||||
self.score = KlondikeAdapter::apply_undo_score(pre_move_score, self.mode);
|
|
||||||
self.undo_count = self.undo_count.saturating_add(1);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1118,11 +1094,6 @@ impl GameState {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0).
|
|
||||||
pub fn compute_time_bonus(&self) -> i32 {
|
|
||||||
scoring_time_bonus(self.elapsed_seconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read-only access to the underlying [`card_game::Session`] for this deal.
|
/// Read-only access to the underlying [`card_game::Session`] for this deal.
|
||||||
///
|
///
|
||||||
/// Exposes `session.history()` (deterministic replay) and `session.solve()`
|
/// Exposes `session.history()` (deterministic replay) and `session.solve()`
|
||||||
@@ -1132,6 +1103,56 @@ impl GameState {
|
|||||||
pub fn session(&self) -> &Session<Klondike> {
|
pub fn session(&self) -> &Session<Klondike> {
|
||||||
&self.session
|
&self.session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Solvability of the current position: the first useful move on a winning
|
||||||
|
/// path, `Ok(None)` if unwinnable (or already won), or `Err` if the solver
|
||||||
|
/// hit its budget before reaching a verdict. See [`SolveOutcome`].
|
||||||
|
///
|
||||||
|
/// Delegates the search to upstream [`card_game::Session::solve`] on a
|
||||||
|
/// solve-budgeted copy of the current board, then extracts the first
|
||||||
|
/// non-useless instruction from the returned solution. Backs the hint system
|
||||||
|
/// and the Play-by-seed verdict badge.
|
||||||
|
pub fn solve_first_move(&self, moves_budget: u64, states_budget: u64) -> SolveOutcome {
|
||||||
|
// An already-won game has no "next move"; report it as unwinnable so the
|
||||||
|
// winnable contract (`Some(_)` ⇒ a real move exists) holds.
|
||||||
|
if self.is_won() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = SessionConfig {
|
||||||
|
inner: KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation),
|
||||||
|
undo_penalty: 0,
|
||||||
|
solve_moves_budget: moves_budget,
|
||||||
|
solve_states_budget: states_budget,
|
||||||
|
};
|
||||||
|
let session = Session::new(self.session.state().state().clone(), config);
|
||||||
|
|
||||||
|
session.solve().map(|solution| {
|
||||||
|
solution.and_then(|solution| {
|
||||||
|
solution
|
||||||
|
.raw_solution()
|
||||||
|
.iter()
|
||||||
|
.map(|snapshot| *snapshot.instruction())
|
||||||
|
.find(|instruction| !instruction.is_useless())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Solvability of a fresh Classic-mode deal from `seed` + `draw_mode`.
|
||||||
|
///
|
||||||
|
/// Fresh-deal solving models standard Klondike rules, so the non-standard
|
||||||
|
/// take-from-foundation house rule stays disabled. Backs the
|
||||||
|
/// "Winnable deals only" retry loop.
|
||||||
|
pub fn solve_fresh_deal(
|
||||||
|
seed: u64,
|
||||||
|
draw_mode: DrawStockConfig,
|
||||||
|
moves_budget: u64,
|
||||||
|
states_budget: u64,
|
||||||
|
) -> SolveOutcome {
|
||||||
|
let mut game = Self::new(seed, draw_mode);
|
||||||
|
game.take_from_foundation = false;
|
||||||
|
game.solve_first_move(moves_budget, states_budget)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1153,7 +1174,7 @@ mod tests {
|
|||||||
const MAX_STEPS: usize = 160;
|
const MAX_STEPS: usize = 160;
|
||||||
|
|
||||||
for seed in 1..=MAX_SEED {
|
for seed in 1..=MAX_SEED {
|
||||||
let mut game = GameState::new(seed, DrawMode::DrawOne);
|
let mut game = GameState::new(seed, DrawStockConfig::DrawOne);
|
||||||
game.take_from_foundation = true;
|
game.take_from_foundation = true;
|
||||||
|
|
||||||
for _ in 0..MAX_STEPS {
|
for _ in 0..MAX_STEPS {
|
||||||
@@ -1202,7 +1223,7 @@ mod tests {
|
|||||||
/// iteration limit (shouldn't happen in practice).
|
/// iteration limit (shouldn't happen in practice).
|
||||||
fn game_at_first_recycle() -> Option<GameState> {
|
fn game_at_first_recycle() -> Option<GameState> {
|
||||||
for seed in 1..=256_u64 {
|
for seed in 1..=256_u64 {
|
||||||
let mut game = GameState::new(seed, DrawMode::DrawOne);
|
let mut game = GameState::new(seed, DrawStockConfig::DrawOne);
|
||||||
for _ in 0..200 {
|
for _ in 0..200 {
|
||||||
if game.stock_cards().is_empty() && !game.waste_cards().is_empty() {
|
if game.stock_cards().is_empty() && !game.waste_cards().is_empty() {
|
||||||
// This draw will recycle.
|
// This draw will recycle.
|
||||||
@@ -1216,56 +1237,40 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recycle_count_decrements_when_recycle_is_undone() {
|
fn recycle_count_is_cumulative_and_not_rolled_back_on_undo() {
|
||||||
|
// Upstream `KlondikeStats::recycle_count` counts every recycle ever
|
||||||
|
// performed; it is intentionally NOT decremented when a recycle is
|
||||||
|
// undone (the session restores the board but leaves the stat). This is
|
||||||
|
// the post-migration semantics: a cumulative count, not a net count.
|
||||||
let mut game = game_at_first_recycle().expect("could not reach recycle");
|
let mut game = game_at_first_recycle().expect("could not reach recycle");
|
||||||
let count_after_recycle = game.recycle_count;
|
assert_eq!(game.recycle_count(), 1, "first recycle should give count=1");
|
||||||
assert_eq!(count_after_recycle, 1, "first recycle should give count=1");
|
|
||||||
game.undo().expect("undo should succeed");
|
game.undo().expect("undo should succeed");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
game.recycle_count, 0,
|
game.recycle_count(),
|
||||||
"recycle_count must decrement back to 0 after undoing the recycle",
|
1,
|
||||||
|
"recycle_count is cumulative: undoing a recycle does not roll it back",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn score_recycle_penalty_is_reversed_on_undo() {
|
fn undo_applies_minus_15_penalty_via_upstream_score() {
|
||||||
// Reach the second recycle (count=2, Draw-1) so there is a −100 penalty.
|
// A foundation move scores +10 upstream; undoing it nets the move score
|
||||||
let mut game = game_at_first_recycle().expect("could not reach first recycle");
|
// back to 0 and adds the −15 undo penalty, which `score()` floors at 0.
|
||||||
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
// Draw until stock is empty again so we can do a second recycle.
|
// Find and play any scoring move, then undo it.
|
||||||
let mut second_recycle_done = false;
|
let scoring_move = game
|
||||||
for _ in 0..200 {
|
.possible_instructions()
|
||||||
if game.stock_cards().is_empty() && !game.waste_cards().is_empty() {
|
.into_iter()
|
||||||
let score_before_second_recycle = game.score;
|
.find(|i| matches!(i, KlondikeInstruction::DstFoundation(_)));
|
||||||
game.draw().expect("second recycle should succeed");
|
if let Some(instruction) = scoring_move {
|
||||||
assert_eq!(game.recycle_count, 2);
|
game.apply_instruction(instruction)
|
||||||
|
.expect("scoring move should apply");
|
||||||
// The second recycle in Draw-1 mode costs −100.
|
assert!(game.score() > 0, "a foundation move should raise the score");
|
||||||
let expected_after = (score_before_second_recycle - 100).max(0);
|
game.undo().expect("undo should succeed");
|
||||||
assert_eq!(
|
assert_eq!(game.undo_count(), 1, "undo increments the upstream counter");
|
||||||
game.score, expected_after,
|
// base score returns to 0, minus 15 undo penalty, floored at 0.
|
||||||
"second Draw-1 recycle must apply −100 penalty",
|
assert_eq!(game.score(), 0, "score floors at 0 after the undo penalty");
|
||||||
);
|
|
||||||
|
|
||||||
// Undo: score should recover to (score_before_second_recycle − 15).max(0),
|
|
||||||
// NOT to (score_after_recycle − 15).max(0).
|
|
||||||
game.undo().expect("undo of second recycle should succeed");
|
|
||||||
let expected_after_undo = (score_before_second_recycle - 15).max(0);
|
|
||||||
assert_eq!(
|
|
||||||
game.score, expected_after_undo,
|
|
||||||
"undoing a penalised recycle must reverse the recycle penalty \
|
|
||||||
before applying the −15 undo penalty",
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
game.recycle_count, 1,
|
|
||||||
"recycle_count must also be decremented on undo",
|
|
||||||
);
|
|
||||||
second_recycle_done = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let _ = game.draw();
|
|
||||||
}
|
}
|
||||||
assert!(second_recycle_done, "could not reach second recycle in test");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1298,4 +1303,66 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(game.move_cards(from, to, 1).is_err());
|
assert!(game.move_cards(from, to, 1).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Solvability check (solve_first_move / solve_fresh_deal) ──────────────
|
||||||
|
|
||||||
|
/// `SolveError` has no `PartialEq`, so compare the winnable verdict and the
|
||||||
|
/// extracted first move (both `Eq`) rather than the whole `Result`.
|
||||||
|
fn verdict_key(outcome: &SolveOutcome) -> (bool, Option<KlondikeInstruction>) {
|
||||||
|
(outcome.is_err(), outcome.clone().ok().flatten())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn solve_fresh_deal_is_deterministic() {
|
||||||
|
let a = GameState::solve_fresh_deal(
|
||||||
|
7,
|
||||||
|
DrawStockConfig::DrawOne,
|
||||||
|
DEFAULT_SOLVE_MOVES_BUDGET,
|
||||||
|
DEFAULT_SOLVE_STATES_BUDGET,
|
||||||
|
);
|
||||||
|
let b = GameState::solve_fresh_deal(
|
||||||
|
7,
|
||||||
|
DrawStockConfig::DrawOne,
|
||||||
|
DEFAULT_SOLVE_MOVES_BUDGET,
|
||||||
|
DEFAULT_SOLVE_STATES_BUDGET,
|
||||||
|
);
|
||||||
|
assert_eq!(verdict_key(&a), verdict_key(&b));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn winnable_verdict_carries_a_first_move() {
|
||||||
|
// Contract: a first move is present iff the verdict is winnable.
|
||||||
|
let outcome = GameState::solve_fresh_deal(7, DrawStockConfig::DrawOne, 5_000, 5_000);
|
||||||
|
let winnable = matches!(outcome, Ok(Some(_)));
|
||||||
|
let has_move = outcome.ok().flatten().is_some();
|
||||||
|
assert_eq!(winnable, has_move);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn solve_first_move_uses_live_game_state() {
|
||||||
|
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
game.draw().expect("draw must succeed");
|
||||||
|
|
||||||
|
let outcome = game.solve_first_move(5_000, 5_000);
|
||||||
|
let winnable = matches!(outcome, Ok(Some(_)));
|
||||||
|
let has_move = outcome.ok().flatten().is_some();
|
||||||
|
assert_eq!(winnable, has_move);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_state_budget_is_inconclusive() {
|
||||||
|
let outcome = GameState::solve_fresh_deal(7, DrawStockConfig::DrawOne, 5_000, 0);
|
||||||
|
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn budget_is_passed_through_not_clamped() {
|
||||||
|
// This seed is Inconclusive at 1k states but Winnable at 5k — proving the
|
||||||
|
// budget reaches the solver unchanged.
|
||||||
|
let easy = GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 1_000, 1_000);
|
||||||
|
let medium =
|
||||||
|
GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 5_000, 5_000);
|
||||||
|
assert!(easy.is_err());
|
||||||
|
assert!(matches!(medium, Ok(Some(_))));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,17 +16,6 @@ use klondike::{
|
|||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::game_state::GameMode;
|
|
||||||
|
|
||||||
/// Whether cards are drawn one at a time or three at a time from the stock.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub enum DrawMode {
|
|
||||||
/// Draw one card from stock per turn.
|
|
||||||
DrawOne,
|
|
||||||
/// Draw three cards from stock per turn; only the top is playable.
|
|
||||||
DrawThree,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate.
|
/// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate.
|
||||||
///
|
///
|
||||||
/// This type is intentionally zero-sized: it does not carry mutable runtime
|
/// This type is intentionally zero-sized: it does not carry mutable runtime
|
||||||
@@ -37,12 +26,9 @@ pub struct KlondikeAdapter;
|
|||||||
|
|
||||||
impl KlondikeAdapter {
|
impl KlondikeAdapter {
|
||||||
/// Build a [`KlondikeConfig`] from draw mode and foundation house-rule setting.
|
/// Build a [`KlondikeConfig`] from draw mode and foundation house-rule setting.
|
||||||
pub fn config_for(draw_mode: DrawMode, take_from_foundation: bool) -> KlondikeConfig {
|
pub fn config_for(draw_mode: DrawStockConfig, take_from_foundation: bool) -> KlondikeConfig {
|
||||||
KlondikeConfig {
|
KlondikeConfig {
|
||||||
draw_stock: match draw_mode {
|
draw_stock: draw_mode,
|
||||||
DrawMode::DrawOne => DrawStockConfig::DrawOne,
|
|
||||||
DrawMode::DrawThree => DrawStockConfig::DrawThree,
|
|
||||||
},
|
|
||||||
move_from_foundation: if take_from_foundation {
|
move_from_foundation: if take_from_foundation {
|
||||||
MoveFromFoundationConfig::Allowed
|
MoveFromFoundationConfig::Allowed
|
||||||
} else {
|
} else {
|
||||||
@@ -51,116 +37,6 @@ impl KlondikeAdapter {
|
|||||||
scoring: ScoringConfig::DEFAULT,
|
scoring: ScoringConfig::DEFAULT,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Scoring helpers ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Score delta for a card move.
|
|
||||||
///
|
|
||||||
/// Reads from [`ScoringConfig`] (WXP Standard values):
|
|
||||||
/// - Any pile → Foundation: +10
|
|
||||||
/// - Waste → Tableau: +5
|
|
||||||
/// - Foundation → Tableau: −15
|
|
||||||
/// - All other moves: 0
|
|
||||||
pub fn score_for_move(from: &KlondikePile, to: &KlondikePile) -> i32 {
|
|
||||||
let sc = ScoringConfig::DEFAULT;
|
|
||||||
match (from, to) {
|
|
||||||
(_, KlondikePile::Foundation(_)) => sc.move_to_foundation,
|
|
||||||
(KlondikePile::Stock, KlondikePile::Tableau(_)) => sc.move_to_tableau,
|
|
||||||
(KlondikePile::Foundation(_), KlondikePile::Tableau(_)) => sc.move_from_foundation,
|
|
||||||
_ => 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Score delta for exposing a face-down tableau card: +5.
|
|
||||||
pub fn score_for_flip() -> i32 {
|
|
||||||
ScoringConfig::DEFAULT.flip_up_bonus
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Score delta for undo: −15.
|
|
||||||
///
|
|
||||||
/// This is a Ferrous product policy — `card_game::SessionConfig::undo_penalty`
|
|
||||||
/// defaults to 0; the solver overrides it to 0 explicitly. The −15 WXP penalty
|
|
||||||
/// is applied here by `GameState` on every undo.
|
|
||||||
pub fn score_for_undo() -> i32 {
|
|
||||||
-15
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Score delta for recycling waste → stock.
|
|
||||||
///
|
|
||||||
/// [`ScoringConfig::recycle`] is a flat delta (default 0 = always free).
|
|
||||||
/// WXP allows a fixed number of free recycles before charging a penalty,
|
|
||||||
/// which the upstream library cannot express with a single delta:
|
|
||||||
///
|
|
||||||
/// | Mode | Free recycles | Penalty per extra recycle |
|
|
||||||
/// |---|---|---|
|
|
||||||
/// | Draw-1 | 1 | −100 |
|
|
||||||
/// | Draw-3 | 3 | −20 |
|
|
||||||
///
|
|
||||||
/// **Design note:** recycling is *never* blocked — only penalised.
|
|
||||||
/// This is intentional: Draw-1 can be played indefinitely with the score
|
|
||||||
/// dropping toward zero after the first free recycle. A hard cap would
|
|
||||||
/// create unwinnable positions when the solver cannot find a path without
|
|
||||||
/// additional recycling. Zen mode suppresses the penalty entirely.
|
|
||||||
///
|
|
||||||
/// `recycle_count` must be the new total **after** this recycle.
|
|
||||||
pub fn score_for_recycle(recycle_count: u32, is_draw_three: bool) -> i32 {
|
|
||||||
if is_draw_three {
|
|
||||||
if recycle_count > 3 { -20 } else { 0 }
|
|
||||||
} else if recycle_count > 1 {
|
|
||||||
-100
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Score delta for a card move, accounting for game mode.
|
|
||||||
///
|
|
||||||
/// Returns 0 in [`GameMode::Zen`] (all scoring suppressed).
|
|
||||||
pub fn score_for_move_with_mode(from: &KlondikePile, to: &KlondikePile, mode: GameMode) -> i32 {
|
|
||||||
if mode == GameMode::Zen {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
Self::score_for_move(from, to)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Score delta for exposing a face-down card, accounting for game mode.
|
|
||||||
///
|
|
||||||
/// Returns 0 in [`GameMode::Zen`].
|
|
||||||
pub fn score_for_flip_with_mode(mode: GameMode) -> i32 {
|
|
||||||
if mode == GameMode::Zen {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
Self::score_for_flip()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compute the new score after an undo, accounting for game mode.
|
|
||||||
///
|
|
||||||
/// In [`GameMode::Zen`] the score is always 0. Otherwise applies the
|
|
||||||
/// −15 undo penalty and clamps to 0 via [`Self::score_for_undo`].
|
|
||||||
pub fn apply_undo_score(snapshot_score: i32, mode: GameMode) -> i32 {
|
|
||||||
if mode == GameMode::Zen {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
(snapshot_score + Self::score_for_undo()).max(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Score delta for recycling, accounting for game mode.
|
|
||||||
///
|
|
||||||
/// Returns 0 in [`GameMode::Zen`].
|
|
||||||
pub fn score_for_recycle_with_mode(
|
|
||||||
recycle_count: u32,
|
|
||||||
is_draw_three: bool,
|
|
||||||
mode: GameMode,
|
|
||||||
) -> i32 {
|
|
||||||
if mode == GameMode::Zen {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
Self::score_for_recycle(recycle_count, is_draw_three)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert a zero-based tableau index (0..=6) into [`Tableau`].
|
/// Convert a zero-based tableau index (0..=6) into [`Tableau`].
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
pub mod achievement;
|
pub mod achievement;
|
||||||
pub mod card;
|
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod game_state;
|
pub mod game_state;
|
||||||
pub mod klondike_adapter;
|
pub mod klondike_adapter;
|
||||||
@@ -12,9 +11,12 @@ pub mod klondike_adapter;
|
|||||||
// re-exported — they are only used internally (in `klondike_adapter.rs` and
|
// re-exported — they are only used internally (in `klondike_adapter.rs` and
|
||||||
// when decoding instructions to piles in `instruction_to_piles`) and do not
|
// when decoding instructions to piles in `instruction_to_piles`) and do not
|
||||||
// appear in any public method signature.
|
// appear in any public method signature.
|
||||||
pub use card_game::{Card, Session};
|
pub use card_game::{Card, Deck, Rank, Session, SolveError, Suit};
|
||||||
pub use klondike::{Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau};
|
pub use klondike::{DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau};
|
||||||
pub use klondike_adapter::DrawMode;
|
|
||||||
|
// Solvability check API (delegates to `card_game::Session::solve`); replaces the
|
||||||
|
// former `solitaire_data::solver` wrapper module.
|
||||||
|
pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod proptest_tests;
|
mod proptest_tests;
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
use card_game::{Card, Game};
|
use card_game::{Card, Game};
|
||||||
use klondike::{Foundation, KlondikePile, KlondikeInstruction, SkipCards, Tableau};
|
use klondike::{DrawStockConfig, Foundation, KlondikePile, KlondikeInstruction, SkipCards, Tableau};
|
||||||
use proptest::prelude::*;
|
use proptest::prelude::*;
|
||||||
|
|
||||||
use crate::game_state::GameState;
|
use crate::game_state::GameState;
|
||||||
use crate::klondike_adapter::DrawMode;
|
|
||||||
use crate::klondike_adapter::{
|
use crate::klondike_adapter::{
|
||||||
InvalidSavedInstruction, SavedDstFoundation, SavedDstTableau, SavedFoundation,
|
InvalidSavedInstruction, SavedDstFoundation, SavedDstTableau, SavedFoundation,
|
||||||
SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, SavedSkipCards, SavedTableau,
|
SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, SavedSkipCards, SavedTableau,
|
||||||
@@ -52,8 +51,8 @@ fn all_cards(game: &GameState) -> Vec<Card> {
|
|||||||
cards
|
cards
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_mode_strategy() -> impl Strategy<Value = DrawMode> {
|
fn draw_mode_strategy() -> impl Strategy<Value = DrawStockConfig> {
|
||||||
prop_oneof![Just(DrawMode::DrawOne), Just(DrawMode::DrawThree)]
|
prop_oneof![Just(DrawStockConfig::DrawOne), Just(DrawStockConfig::DrawThree)]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a sequence of random actions to a game, silently ignoring errors.
|
/// Apply a sequence of random actions to a game, silently ignoring errors.
|
||||||
|
|||||||
@@ -99,12 +99,6 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod solver;
|
|
||||||
pub use solver::{
|
|
||||||
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome, try_solve,
|
|
||||||
try_solve_from_state,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
pub use stats::{StatsExt, StatsSnapshot};
|
pub use stats::{StatsExt, StatsSnapshot};
|
||||||
|
|
||||||
@@ -124,8 +118,8 @@ pub use achievements::{
|
|||||||
|
|
||||||
pub mod progress;
|
pub mod progress;
|
||||||
pub use progress::{
|
pub use progress::{
|
||||||
PlayerProgress, daily_seed_for, level_for_xp, load_progress_from, progress_file_path,
|
PlayerProgress, XpBreakdown, daily_seed_for, level_for_xp, load_progress_from,
|
||||||
save_progress_to, xp_for_win,
|
progress_file_path, save_progress_to, xp_breakdown, xp_for_win,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod weekly;
|
pub mod weekly;
|
||||||
@@ -172,8 +166,11 @@ pub use replay::{
|
|||||||
ReplayHistory, ReplayMove, append_replay_to_history, load_replay_history_from,
|
ReplayHistory, ReplayMove, append_replay_to_history, load_replay_history_from,
|
||||||
migrate_legacy_latest_replay, replay_history_path, save_replay_history_to,
|
migrate_legacy_latest_replay, replay_history_path, save_replay_history_to,
|
||||||
};
|
};
|
||||||
|
// `latest_replay_path` is still consumed by the engine's one-shot legacy
|
||||||
|
// migration; `load_latest_replay_from`/`save_latest_replay_to` had no callers
|
||||||
|
// outside `replay.rs` and were dropped from the public surface.
|
||||||
#[allow(deprecated)]
|
#[allow(deprecated)]
|
||||||
pub use replay::{latest_replay_path, load_latest_replay_from, save_latest_replay_to};
|
pub use replay::latest_replay_path;
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod matomo_client;
|
pub mod matomo_client;
|
||||||
|
|||||||
@@ -25,12 +25,34 @@ pub fn daily_seed_for(date: NaiveDate) -> u64 {
|
|||||||
y * 10_000 + m * 100 + d
|
y * 10_000 + m * 100 + d
|
||||||
}
|
}
|
||||||
|
|
||||||
/// XP awarded for winning a game.
|
/// Component breakdown of the XP awarded for a win.
|
||||||
|
///
|
||||||
|
/// This is the single source of truth for win-XP scoring: [`xp_for_win`] sums
|
||||||
|
/// it for the total, and UI that displays the individual lines (the win-summary
|
||||||
|
/// modal) reads the parts from here so the breakdown can never drift from the
|
||||||
|
/// total.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct XpBreakdown {
|
||||||
|
/// Flat base XP granted for any win.
|
||||||
|
pub base: u64,
|
||||||
|
/// Scaled fast-win bonus (10..=50 for sub-2-minute wins, else 0).
|
||||||
|
pub speed_bonus: u64,
|
||||||
|
/// Bonus for winning without using undo (25, else 0).
|
||||||
|
pub no_undo_bonus: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl XpBreakdown {
|
||||||
|
/// Total XP awarded: `base + speed_bonus + no_undo_bonus`.
|
||||||
|
pub fn total(self) -> u64 {
|
||||||
|
self.base + self.speed_bonus + self.no_undo_bonus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Component breakdown of the XP awarded for a win.
|
||||||
///
|
///
|
||||||
/// Base 50 + scaled fast-win bonus (10..=50 for sub-2-minute wins) + 25 if
|
/// Base 50 + scaled fast-win bonus (10..=50 for sub-2-minute wins) + 25 if
|
||||||
/// the player did not use undo.
|
/// the player did not use undo.
|
||||||
pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
|
pub fn xp_breakdown(time_seconds: u64, used_undo: bool) -> XpBreakdown {
|
||||||
let base: u64 = 50;
|
|
||||||
let speed_bonus: u64 = if time_seconds >= 120 {
|
let speed_bonus: u64 = if time_seconds >= 120 {
|
||||||
0
|
0
|
||||||
} else {
|
} else {
|
||||||
@@ -39,8 +61,16 @@ pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
|
|||||||
let scaled = 50_u64.saturating_sub(time_seconds.saturating_mul(40) / 120);
|
let scaled = 50_u64.saturating_sub(time_seconds.saturating_mul(40) / 120);
|
||||||
scaled.max(10)
|
scaled.max(10)
|
||||||
};
|
};
|
||||||
let no_undo_bonus: u64 = if used_undo { 0 } else { 25 };
|
XpBreakdown {
|
||||||
base + speed_bonus + no_undo_bonus
|
base: 50,
|
||||||
|
speed_bonus,
|
||||||
|
no_undo_bonus: if used_undo { 0 } else { 25 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// XP awarded for winning a game. See [`xp_breakdown`] for the components.
|
||||||
|
pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
|
||||||
|
xp_breakdown(time_seconds, used_undo).total()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Platform-specific default path for `progress.json`.
|
/// Platform-specific default path for `progress.json`.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use solitaire_core::{DrawMode, game_state::GameMode};
|
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||||
use solitaire_core::klondike_adapter::SavedKlondikePile;
|
use solitaire_core::klondike_adapter::SavedKlondikePile;
|
||||||
|
|
||||||
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
||||||
@@ -124,7 +124,7 @@ pub struct Replay {
|
|||||||
/// `GameState::new_with_mode(seed, draw_mode, mode)`.
|
/// `GameState::new_with_mode(seed, draw_mode, mode)`.
|
||||||
pub seed: u64,
|
pub seed: u64,
|
||||||
/// Draw mode the recorded game was played in.
|
/// Draw mode the recorded game was played in.
|
||||||
pub draw_mode: DrawMode,
|
pub draw_mode: DrawStockConfig,
|
||||||
/// Game mode the recorded game was played in.
|
/// Game mode the recorded game was played in.
|
||||||
pub mode: GameMode,
|
pub mode: GameMode,
|
||||||
/// Total wall-clock seconds the win took. Used for the Stats UI
|
/// Total wall-clock seconds the win took. Used for the Stats UI
|
||||||
@@ -180,7 +180,7 @@ impl Replay {
|
|||||||
/// latter directly when the upload task resolves.
|
/// latter directly when the upload task resolves.
|
||||||
pub fn new(
|
pub fn new(
|
||||||
seed: u64,
|
seed: u64,
|
||||||
draw_mode: DrawMode,
|
draw_mode: DrawStockConfig,
|
||||||
mode: GameMode,
|
mode: GameMode,
|
||||||
time_seconds: u64,
|
time_seconds: u64,
|
||||||
final_score: i32,
|
final_score: i32,
|
||||||
@@ -453,7 +453,7 @@ mod tests {
|
|||||||
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
|
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
|
||||||
Replay::new(
|
Replay::new(
|
||||||
12345,
|
12345,
|
||||||
DrawMode::DrawThree,
|
DrawStockConfig::DrawThree,
|
||||||
GameMode::Classic,
|
GameMode::Classic,
|
||||||
134,
|
134,
|
||||||
5_120,
|
5_120,
|
||||||
@@ -596,7 +596,7 @@ mod tests {
|
|||||||
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
|
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
|
||||||
Replay::new(
|
Replay::new(
|
||||||
id as u64,
|
id as u64,
|
||||||
DrawMode::DrawOne,
|
DrawStockConfig::DrawOne,
|
||||||
GameMode::Classic,
|
GameMode::Classic,
|
||||||
60,
|
60,
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use std::io;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use solitaire_core::{DrawMode, game_state::DifficultyLevel};
|
use solitaire_core::{DrawStockConfig, game_state::DifficultyLevel};
|
||||||
|
|
||||||
const SETTINGS_FILE_NAME: &str = "settings.json";
|
const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ pub struct WindowGeometry {
|
|||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
/// Draw mode selected for new games.
|
/// Draw mode selected for new games.
|
||||||
#[serde(default = "default_draw_mode")]
|
#[serde(default = "default_draw_mode")]
|
||||||
pub draw_mode: DrawMode,
|
pub draw_mode: DrawStockConfig,
|
||||||
/// Linear SFX volume in `[0.0, 1.0]`. Applied to kira's SFX channel gain.
|
/// Linear SFX volume in `[0.0, 1.0]`. Applied to kira's SFX channel gain.
|
||||||
#[serde(default = "default_sfx_volume")]
|
#[serde(default = "default_sfx_volume")]
|
||||||
pub sfx_volume: f32,
|
pub sfx_volume: f32,
|
||||||
@@ -200,7 +200,7 @@ pub struct Settings {
|
|||||||
#[serde(default = "default_time_bonus_multiplier")]
|
#[serde(default = "default_time_bonus_multiplier")]
|
||||||
pub time_bonus_multiplier: f32,
|
pub time_bonus_multiplier: f32,
|
||||||
/// When `true`, the engine rejects new-game deals the
|
/// When `true`, the engine rejects new-game deals the
|
||||||
/// [`solitaire_data::solver`] cannot prove winnable, retrying
|
/// the solver cannot prove winnable, retrying
|
||||||
/// fresh seeds up to [`SOLVER_DEAL_RETRY_CAP`] attempts before
|
/// fresh seeds up to [`SOLVER_DEAL_RETRY_CAP`] attempts before
|
||||||
/// giving up and using the last tried seed. Off by default —
|
/// giving up and using the last tried seed. Off by default —
|
||||||
/// the solver adds a few hundred milliseconds of latency on the
|
/// the solver adds a few hundred milliseconds of latency on the
|
||||||
@@ -288,8 +288,8 @@ pub struct Settings {
|
|||||||
pub touch_input_mode: TouchInputMode,
|
pub touch_input_mode: TouchInputMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_draw_mode() -> DrawMode {
|
fn default_draw_mode() -> DrawStockConfig {
|
||||||
DrawMode::DrawOne
|
DrawStockConfig::DrawOne
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_sfx_volume() -> f32 {
|
fn default_sfx_volume() -> f32 {
|
||||||
@@ -392,7 +392,7 @@ pub const SOLVER_DEAL_RETRY_CAP: u32 = 50;
|
|||||||
impl Default for Settings {
|
impl Default for Settings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
draw_mode: DrawMode::DrawOne,
|
draw_mode: DrawStockConfig::DrawOne,
|
||||||
sfx_volume: default_sfx_volume(),
|
sfx_volume: default_sfx_volume(),
|
||||||
music_volume: default_music_volume(),
|
music_volume: default_music_volume(),
|
||||||
animation_speed: AnimSpeed::Normal,
|
animation_speed: AnimSpeed::Normal,
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
//! Klondike solvability check using upstream `card_game::Session::solve()`.
|
|
||||||
//!
|
|
||||||
//! Backs the **Settings → Gameplay → "Winnable deals only"** toggle, the
|
|
||||||
//! Play-by-seed verdict badge, and the hint system (which wants the first
|
|
||||||
//! move on a winning path). All search is delegated to `card_game`; this
|
|
||||||
//! module only adapts the inputs (a seed or a live [`GameState`]) and extracts
|
|
||||||
//! the first move from the returned solution.
|
|
||||||
|
|
||||||
use card_game::{Session, SessionConfig, SolveError};
|
|
||||||
use klondike::KlondikeInstruction;
|
|
||||||
use solitaire_core::DrawMode;
|
|
||||||
use solitaire_core::game_state::GameState;
|
|
||||||
use solitaire_core::klondike_adapter::KlondikeAdapter;
|
|
||||||
|
|
||||||
/// Default move budget for a solve. Matches the winnable-deal retry loop.
|
|
||||||
pub const DEFAULT_SOLVE_MOVES_BUDGET: u64 = 100_000;
|
|
||||||
/// Default unique-state budget for a solve.
|
|
||||||
pub const DEFAULT_SOLVE_STATES_BUDGET: u64 = 200_000;
|
|
||||||
|
|
||||||
/// Outcome of a solvability check:
|
|
||||||
///
|
|
||||||
/// * `Ok(Some(instruction))` — winnable; `instruction` is the first move on a
|
|
||||||
/// winning path (used by the hint system).
|
|
||||||
/// * `Ok(None)` — provably unwinnable (search exhausted with no solution, or
|
|
||||||
/// the game is already won so no next move exists).
|
|
||||||
/// * `Err(SolveError)` — inconclusive; the move/state budget was exceeded
|
|
||||||
/// before a verdict was reached.
|
|
||||||
pub type SolveOutcome = Result<Option<KlondikeInstruction>, SolveError>;
|
|
||||||
|
|
||||||
/// Solves a fresh Classic-mode game dealt from `seed` + `draw_mode`.
|
|
||||||
///
|
|
||||||
/// Fresh-deal solving models standard Klondike rules, so the non-standard
|
|
||||||
/// take-from-foundation house rule stays disabled here.
|
|
||||||
pub fn try_solve(
|
|
||||||
seed: u64,
|
|
||||||
draw_mode: DrawMode,
|
|
||||||
moves_budget: u64,
|
|
||||||
states_budget: u64,
|
|
||||||
) -> SolveOutcome {
|
|
||||||
let mut game = GameState::new(seed, draw_mode);
|
|
||||||
game.take_from_foundation = false;
|
|
||||||
try_solve_from_state(&game, moves_budget, states_budget)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Solves from an existing in-progress [`GameState`], returning the first move
|
|
||||||
/// on a winning path when one exists.
|
|
||||||
pub fn try_solve_from_state(
|
|
||||||
state: &GameState,
|
|
||||||
moves_budget: u64,
|
|
||||||
states_budget: u64,
|
|
||||||
) -> SolveOutcome {
|
|
||||||
// An already-won game has no "next move"; report it as unwinnable so the
|
|
||||||
// winnable contract (`Some(_)` ⇒ a real move exists) holds.
|
|
||||||
if state.is_won() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let config = SessionConfig {
|
|
||||||
inner: KlondikeAdapter::config_for(state.draw_mode(), state.take_from_foundation),
|
|
||||||
undo_penalty: 0,
|
|
||||||
solve_moves_budget: moves_budget,
|
|
||||||
solve_states_budget: states_budget,
|
|
||||||
};
|
|
||||||
let session = Session::new(state.session().state().state().clone(), config);
|
|
||||||
|
|
||||||
session.solve().map(|solution| {
|
|
||||||
solution.and_then(|solution| {
|
|
||||||
solution
|
|
||||||
.raw_solution()
|
|
||||||
.iter()
|
|
||||||
.map(|snapshot| *snapshot.instruction())
|
|
||||||
.find(|instruction| !instruction.is_useless())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// `SolveError` has no `PartialEq`, so compare the winnable verdict and the
|
|
||||||
/// extracted first move (both `Eq`) rather than the whole `Result`.
|
|
||||||
fn verdict_key(outcome: &SolveOutcome) -> (bool, Option<KlondikeInstruction>) {
|
|
||||||
(outcome.is_err(), outcome.clone().ok().flatten())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn try_solve_is_deterministic() {
|
|
||||||
let a = try_solve(7, DrawMode::DrawOne, DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET);
|
|
||||||
let b = try_solve(7, DrawMode::DrawOne, DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET);
|
|
||||||
assert_eq!(verdict_key(&a), verdict_key(&b));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn winnable_verdict_carries_a_first_move() {
|
|
||||||
// Contract: a first move is present iff the verdict is winnable.
|
|
||||||
let outcome = try_solve(7, DrawMode::DrawOne, 5_000, 5_000);
|
|
||||||
let winnable = matches!(outcome, Ok(Some(_)));
|
|
||||||
let has_move = outcome.ok().flatten().is_some();
|
|
||||||
assert_eq!(winnable, has_move);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn try_solve_from_state_uses_live_game_state() {
|
|
||||||
let mut game = GameState::new(42, DrawMode::DrawOne);
|
|
||||||
game.draw().expect("draw must succeed");
|
|
||||||
|
|
||||||
let outcome = try_solve_from_state(&game, 5_000, 5_000);
|
|
||||||
let winnable = matches!(outcome, Ok(Some(_)));
|
|
||||||
let has_move = outcome.ok().flatten().is_some();
|
|
||||||
assert_eq!(winnable, has_move);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn zero_state_budget_is_inconclusive() {
|
|
||||||
let outcome = try_solve(7, DrawMode::DrawOne, 5_000, 0);
|
|
||||||
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn budget_is_passed_through_not_clamped() {
|
|
||||||
// This seed is Inconclusive at 1k states but Winnable at 5k — proving
|
|
||||||
// the budget reaches the solver unchanged.
|
|
||||||
let easy = try_solve(0xD1FF_0000_0000_0012, DrawMode::DrawOne, 1_000, 1_000);
|
|
||||||
let medium = try_solve(0xD1FF_0000_0000_0012, DrawMode::DrawOne, 5_000, 5_000);
|
|
||||||
assert!(easy.is_err());
|
|
||||||
assert!(matches!(medium, Ok(Some(_))));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn budget_above_five_thousand_is_not_clamped() {
|
|
||||||
let below_cap = try_solve(0xD1FF_0000_0000_00DE, DrawMode::DrawOne, 5_000, 5_000);
|
|
||||||
let above_cap = try_solve(0xD1FF_0000_0000_00DE, DrawMode::DrawOne, 50_000, 50_000);
|
|
||||||
assert!(below_cap.is_err(), "seed must be Inconclusive at 5 000 states");
|
|
||||||
assert!(
|
|
||||||
matches!(above_cap, Ok(Some(_))),
|
|
||||||
"seed must be Winnable at 50 000 states — re-introducing the 5k cap would break this"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+26
-26
@@ -2,10 +2,10 @@
|
|||||||
//!
|
//!
|
||||||
//! [`StatsSnapshot`] is defined in `solitaire_sync` and re-exported here.
|
//! [`StatsSnapshot`] is defined in `solitaire_sync` and re-exported here.
|
||||||
//! This module adds the [`StatsExt`] extension trait, which supplies the
|
//! This module adds the [`StatsExt`] extension trait, which supplies the
|
||||||
//! `update_on_win` method that depends on [`DrawMode`] from `solitaire_core`.
|
//! `update_on_win` method that depends on [`DrawStockConfig`] from `solitaire_core`.
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use solitaire_core::{DrawMode, game_state::GameMode};
|
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||||
|
|
||||||
pub use solitaire_sync::StatsSnapshot;
|
pub use solitaire_sync::StatsSnapshot;
|
||||||
|
|
||||||
@@ -18,9 +18,9 @@ pub trait StatsExt {
|
|||||||
///
|
///
|
||||||
/// Tracks lifetime totals only — per-mode best scores and times are
|
/// Tracks lifetime totals only — per-mode best scores and times are
|
||||||
/// updated separately via [`StatsExt::update_per_mode_bests`] so the
|
/// updated separately via [`StatsExt::update_per_mode_bests`] so the
|
||||||
/// long-standing call sites that only know about [`DrawMode`] keep
|
/// long-standing call sites that only know about [`DrawStockConfig`] keep
|
||||||
/// compiling.
|
/// compiling.
|
||||||
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode);
|
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawStockConfig);
|
||||||
|
|
||||||
/// Updates the per-mode best score and fastest-win-time fields for the
|
/// Updates the per-mode best score and fastest-win-time fields for the
|
||||||
/// given [`GameMode`]. Call alongside [`StatsExt::update_on_win`] from
|
/// given [`GameMode`]. Call alongside [`StatsExt::update_on_win`] from
|
||||||
@@ -37,7 +37,7 @@ pub trait StatsExt {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl StatsExt for StatsSnapshot {
|
impl StatsExt for StatsSnapshot {
|
||||||
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode) {
|
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawStockConfig) {
|
||||||
let prev_wins = self.games_won;
|
let prev_wins = self.games_won;
|
||||||
self.games_played += 1;
|
self.games_played += 1;
|
||||||
self.games_won += 1;
|
self.games_won += 1;
|
||||||
@@ -64,8 +64,8 @@ impl StatsExt for StatsSnapshot {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match draw_mode {
|
match draw_mode {
|
||||||
DrawMode::DrawOne => self.draw_one_wins += 1,
|
DrawStockConfig::DrawOne => self.draw_one_wins += 1,
|
||||||
DrawMode::DrawThree => self.draw_three_wins += 1,
|
DrawStockConfig::DrawThree => self.draw_three_wins += 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
self.last_modified = Utc::now();
|
self.last_modified = Utc::now();
|
||||||
@@ -135,7 +135,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn first_win_sets_all_fields() {
|
fn first_win_sets_all_fields() {
|
||||||
let mut s = StatsSnapshot::default();
|
let mut s = StatsSnapshot::default();
|
||||||
s.update_on_win(1500, 120, &DrawMode::DrawOne);
|
s.update_on_win(1500, 120, &DrawStockConfig::DrawOne);
|
||||||
assert_eq!(s.games_played, 1);
|
assert_eq!(s.games_played, 1);
|
||||||
assert_eq!(s.games_won, 1);
|
assert_eq!(s.games_won, 1);
|
||||||
assert_eq!(s.win_streak_current, 1);
|
assert_eq!(s.win_streak_current, 1);
|
||||||
@@ -152,7 +152,7 @@ mod tests {
|
|||||||
fn streak_tracks_across_wins() {
|
fn streak_tracks_across_wins() {
|
||||||
let mut s = StatsSnapshot::default();
|
let mut s = StatsSnapshot::default();
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
|
||||||
}
|
}
|
||||||
assert_eq!(s.win_streak_current, 3);
|
assert_eq!(s.win_streak_current, 3);
|
||||||
assert_eq!(s.win_streak_best, 3);
|
assert_eq!(s.win_streak_best, 3);
|
||||||
@@ -161,8 +161,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn record_abandoned_resets_streak_and_increments_played() {
|
fn record_abandoned_resets_streak_and_increments_played() {
|
||||||
let mut s = StatsSnapshot::default();
|
let mut s = StatsSnapshot::default();
|
||||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
|
||||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
|
||||||
assert_eq!(s.win_streak_current, 2);
|
assert_eq!(s.win_streak_current, 2);
|
||||||
s.record_abandoned();
|
s.record_abandoned();
|
||||||
assert_eq!(s.games_played, 3);
|
assert_eq!(s.games_played, 3);
|
||||||
@@ -174,35 +174,35 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn fastest_win_takes_minimum() {
|
fn fastest_win_takes_minimum() {
|
||||||
let mut s = StatsSnapshot::default();
|
let mut s = StatsSnapshot::default();
|
||||||
s.update_on_win(100, 300, &DrawMode::DrawOne);
|
s.update_on_win(100, 300, &DrawStockConfig::DrawOne);
|
||||||
s.update_on_win(100, 120, &DrawMode::DrawOne);
|
s.update_on_win(100, 120, &DrawStockConfig::DrawOne);
|
||||||
s.update_on_win(100, 500, &DrawMode::DrawOne);
|
s.update_on_win(100, 500, &DrawStockConfig::DrawOne);
|
||||||
assert_eq!(s.fastest_win_seconds, 120);
|
assert_eq!(s.fastest_win_seconds, 120);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn avg_time_is_correct_rolling_average() {
|
fn avg_time_is_correct_rolling_average() {
|
||||||
let mut s = StatsSnapshot::default();
|
let mut s = StatsSnapshot::default();
|
||||||
s.update_on_win(100, 100, &DrawMode::DrawOne);
|
s.update_on_win(100, 100, &DrawStockConfig::DrawOne);
|
||||||
s.update_on_win(100, 200, &DrawMode::DrawOne);
|
s.update_on_win(100, 200, &DrawStockConfig::DrawOne);
|
||||||
s.update_on_win(100, 300, &DrawMode::DrawOne);
|
s.update_on_win(100, 300, &DrawStockConfig::DrawOne);
|
||||||
assert_eq!(s.avg_time_seconds, 200);
|
assert_eq!(s.avg_time_seconds, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn best_score_updates_only_on_higher_score() {
|
fn best_score_updates_only_on_higher_score() {
|
||||||
let mut s = StatsSnapshot::default();
|
let mut s = StatsSnapshot::default();
|
||||||
s.update_on_win(500, 60, &DrawMode::DrawOne);
|
s.update_on_win(500, 60, &DrawStockConfig::DrawOne);
|
||||||
s.update_on_win(300, 60, &DrawMode::DrawOne);
|
s.update_on_win(300, 60, &DrawStockConfig::DrawOne);
|
||||||
assert_eq!(s.best_single_score, 500);
|
assert_eq!(s.best_single_score, 500);
|
||||||
s.update_on_win(800, 60, &DrawMode::DrawOne);
|
s.update_on_win(800, 60, &DrawStockConfig::DrawOne);
|
||||||
assert_eq!(s.best_single_score, 800);
|
assert_eq!(s.best_single_score, 800);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn negative_score_treated_as_zero() {
|
fn negative_score_treated_as_zero() {
|
||||||
let mut s = StatsSnapshot::default();
|
let mut s = StatsSnapshot::default();
|
||||||
s.update_on_win(-50, 60, &DrawMode::DrawOne);
|
s.update_on_win(-50, 60, &DrawStockConfig::DrawOne);
|
||||||
assert_eq!(s.best_single_score, 0);
|
assert_eq!(s.best_single_score, 0);
|
||||||
assert_eq!(s.lifetime_score, 0);
|
assert_eq!(s.lifetime_score, 0);
|
||||||
}
|
}
|
||||||
@@ -210,8 +210,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn draw_three_wins_tracked_separately() {
|
fn draw_three_wins_tracked_separately() {
|
||||||
let mut s = StatsSnapshot::default();
|
let mut s = StatsSnapshot::default();
|
||||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
|
||||||
s.update_on_win(100, 60, &DrawMode::DrawThree);
|
s.update_on_win(100, 60, &DrawStockConfig::DrawThree);
|
||||||
assert_eq!(s.draw_one_wins, 1);
|
assert_eq!(s.draw_one_wins, 1);
|
||||||
assert_eq!(s.draw_three_wins, 1);
|
assert_eq!(s.draw_three_wins, 1);
|
||||||
}
|
}
|
||||||
@@ -221,7 +221,7 @@ mod tests {
|
|||||||
let mut s = StatsSnapshot::default();
|
let mut s = StatsSnapshot::default();
|
||||||
// Build a streak of 5.
|
// Build a streak of 5.
|
||||||
for _ in 0..5 {
|
for _ in 0..5 {
|
||||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
|
||||||
}
|
}
|
||||||
assert_eq!(s.win_streak_best, 5);
|
assert_eq!(s.win_streak_best, 5);
|
||||||
// Lose (abandon), resetting current.
|
// Lose (abandon), resetting current.
|
||||||
@@ -229,7 +229,7 @@ mod tests {
|
|||||||
assert_eq!(s.win_streak_current, 0);
|
assert_eq!(s.win_streak_current, 0);
|
||||||
assert_eq!(s.win_streak_best, 5, "best must survive the loss");
|
assert_eq!(s.win_streak_best, 5, "best must survive the loss");
|
||||||
// Win once — current becomes 1, best must remain 5.
|
// Win once — current becomes 1, best must remain 5.
|
||||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
s.update_on_win(100, 60, &DrawStockConfig::DrawOne);
|
||||||
assert_eq!(s.win_streak_current, 1);
|
assert_eq!(s.win_streak_current, 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
s.win_streak_best, 5,
|
s.win_streak_best, 5,
|
||||||
@@ -243,7 +243,7 @@ mod tests {
|
|||||||
lifetime_score: u64::MAX - 100,
|
lifetime_score: u64::MAX - 100,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
s.update_on_win(200, 60, &DrawMode::DrawOne);
|
s.update_on_win(200, 60, &DrawStockConfig::DrawOne);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
s.lifetime_score,
|
s.lifetime_score,
|
||||||
u64::MAX,
|
u64::MAX,
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ fn cleanup_tmp_files_in(dir: &Path) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::stats::{StatsExt, StatsSnapshot};
|
use crate::stats::{StatsExt, StatsSnapshot};
|
||||||
use solitaire_core::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
fn tmp_path(name: &str) -> PathBuf {
|
fn tmp_path(name: &str) -> PathBuf {
|
||||||
@@ -292,7 +292,7 @@ mod tests {
|
|||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
let mut stats = StatsSnapshot::default();
|
let mut stats = StatsSnapshot::default();
|
||||||
stats.update_on_win(1000, 180, &DrawMode::DrawOne);
|
stats.update_on_win(1000, 180, &DrawStockConfig::DrawOne);
|
||||||
save_stats_to(&path, &stats).expect("save");
|
save_stats_to(&path, &stats).expect("save");
|
||||||
|
|
||||||
let loaded = load_stats_from(&path);
|
let loaded = load_stats_from(&path);
|
||||||
@@ -381,7 +381,7 @@ mod tests {
|
|||||||
let path = gs_path("round_trip");
|
let path = gs_path("round_trip");
|
||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
let gs = GameState::new(12345, DrawMode::DrawOne);
|
let gs = GameState::new(12345, DrawStockConfig::DrawOne);
|
||||||
save_game_state_to(&path, &gs).expect("save");
|
save_game_state_to(&path, &gs).expect("save");
|
||||||
|
|
||||||
let loaded = load_game_state_from(&path).expect("load");
|
let loaded = load_game_state_from(&path).expect("load");
|
||||||
@@ -410,7 +410,7 @@ mod tests {
|
|||||||
let path = gs_path("won_skip");
|
let path = gs_path("won_skip");
|
||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
let mut gs = GameState::new(99, DrawMode::DrawOne);
|
let mut gs = GameState::new(99, DrawStockConfig::DrawOne);
|
||||||
gs.set_test_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!(
|
||||||
@@ -423,7 +423,7 @@ mod tests {
|
|||||||
fn delete_game_state_removes_file() {
|
fn delete_game_state_removes_file() {
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
let path = gs_path("delete");
|
let path = gs_path("delete");
|
||||||
let gs = GameState::new(1, DrawMode::DrawOne);
|
let gs = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
save_game_state_to(&path, &gs).expect("save");
|
save_game_state_to(&path, &gs).expect("save");
|
||||||
assert!(path.exists());
|
assert!(path.exists());
|
||||||
delete_game_state_at(&path).expect("delete");
|
delete_game_state_at(&path).expect("delete");
|
||||||
@@ -441,7 +441,7 @@ mod tests {
|
|||||||
fn save_game_state_is_atomic() {
|
fn save_game_state_is_atomic() {
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
let path = gs_path("atomic");
|
let path = gs_path("atomic");
|
||||||
let gs = GameState::new(55, DrawMode::DrawThree);
|
let gs = GameState::new(55, DrawStockConfig::DrawThree);
|
||||||
save_game_state_to(&path, &gs).expect("save");
|
save_game_state_to(&path, &gs).expect("save");
|
||||||
let tmp = path.with_extension("json.tmp");
|
let tmp = path.with_extension("json.tmp");
|
||||||
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
|
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
|
||||||
@@ -497,22 +497,22 @@ mod tests {
|
|||||||
/// replays all `saved_moves` to reconstruct every pile.
|
/// replays all `saved_moves` to reconstruct every pile.
|
||||||
///
|
///
|
||||||
/// A fresh-game test (zero moves) never exercises that replay path, so this
|
/// A fresh-game test (zero moves) never exercises that replay path, so this
|
||||||
/// test plays several real moves — including an undo — before saving, then
|
/// test plays several real moves — including an undo — before saving.
|
||||||
/// asserts the full pile layout round-trips exactly.
|
|
||||||
///
|
///
|
||||||
/// `GameState::PartialEq` covers stock, waste, all four foundations, all
|
/// Since schema v5 no longer persists `score`/`undo_count`/`recycle_count`
|
||||||
/// seven tableau columns, `score`, `move_count`, `undo_count`, and
|
/// (they are derived from the replayed session stats), round-trip fidelity is
|
||||||
/// `recycle_count`. Any breakage in the upstream serde or replay path
|
/// verified by **re-save idempotency**: reloading the save and serialising it
|
||||||
/// will cause at least one pile to disagree.
|
/// again must reproduce byte-identical JSON. `undo_count` deliberately resets
|
||||||
|
/// to 0 on load because only the forward instruction history is persisted.
|
||||||
#[test]
|
#[test]
|
||||||
fn game_state_v4_mid_game_round_trip() {
|
fn game_state_v5_mid_game_round_trip() {
|
||||||
use solitaire_core::KlondikeInstruction;
|
use solitaire_core::KlondikeInstruction;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
let path = gs_path("v4_mid_game");
|
let path = gs_path("v4_mid_game");
|
||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
let mut gs = GameState::new(42, DrawMode::DrawOne);
|
let mut gs = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
// Draw several times to populate the instruction history with
|
// Draw several times to populate the instruction history with
|
||||||
// RotateStock entries and expose waste cards for further moves.
|
// RotateStock entries and expose waste cards for further moves.
|
||||||
@@ -546,19 +546,40 @@ mod tests {
|
|||||||
|
|
||||||
save_game_state_to(&path, &gs).expect("save");
|
save_game_state_to(&path, &gs).expect("save");
|
||||||
|
|
||||||
// Verify the file contains the v4 schema marker (tolerates pretty-print whitespace).
|
// Verify the file carries the v5 schema marker.
|
||||||
let json = fs::read_to_string(&path).expect("read json");
|
let json = fs::read_to_string(&path).expect("read json");
|
||||||
assert!(
|
assert!(
|
||||||
json.contains("schema_version") && json.contains('4') && !json.contains(": 3"),
|
json.contains("\"schema_version\"") && json.contains('5'),
|
||||||
"saved file must use schema version 4",
|
"saved file must use schema version 5",
|
||||||
);
|
);
|
||||||
|
|
||||||
let loaded = load_game_state_from(&path)
|
let loaded = load_game_state_from(&path)
|
||||||
.expect("a valid in-progress game must load without error");
|
.expect("a valid in-progress game must load without error");
|
||||||
|
|
||||||
|
// The forward instruction history round-trips, so the reconstructed board
|
||||||
|
// re-serialises to byte-identical JSON.
|
||||||
|
let path_reload = gs_path("v5_mid_game_reload");
|
||||||
|
let _ = fs::remove_file(&path_reload);
|
||||||
|
save_game_state_to(&path_reload, &loaded).expect("re-save loaded");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
loaded, gs,
|
fs::read_to_string(&path).expect("read original save"),
|
||||||
"all pile layouts and counters must be identical after schema-v4 round-trip",
|
fs::read_to_string(&path_reload).expect("read re-saved"),
|
||||||
|
"re-saving the loaded game must reproduce the original save exactly",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Derived board reads match the live game (move count + recycle count are
|
||||||
|
// both rebuilt from the replayed forward history).
|
||||||
|
assert_eq!(loaded.move_count(), gs.move_count(), "move_count round-trips");
|
||||||
|
assert_eq!(
|
||||||
|
loaded.recycle_count(),
|
||||||
|
gs.recycle_count(),
|
||||||
|
"recycle_count round-trips",
|
||||||
|
);
|
||||||
|
// undo_count is intentionally not persisted: it resets to 0 on load.
|
||||||
|
assert_eq!(
|
||||||
|
loaded.undo_count(),
|
||||||
|
0,
|
||||||
|
"undo_count resets across save/load under schema v5",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,7 +619,7 @@ mod tests {
|
|||||||
.expect("schema v3 must be accepted and migrated to v4");
|
.expect("schema v3 must be accepted and migrated to v4");
|
||||||
|
|
||||||
// The loaded game should match a fresh game that had one draw applied.
|
// The loaded game should match a fresh game that had one draw applied.
|
||||||
let mut expected = GameState::new(42, DrawMode::DrawOne);
|
let mut expected = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
expected.draw().expect("draw must succeed on a fresh game");
|
expected.draw().expect("draw must succeed on a fresh game");
|
||||||
assert_eq!(loaded, expected, "migrated v3 game state must match equivalent v4 state");
|
assert_eq!(loaded, expected, "migrated v3 game state must match equivalent v4 state");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//! increments matching counters in `PlayerProgress::weekly_goal_progress`.
|
//! increments matching counters in `PlayerProgress::weekly_goal_progress`.
|
||||||
|
|
||||||
use chrono::{Datelike, NaiveDate};
|
use chrono::{Datelike, NaiveDate};
|
||||||
use solitaire_core::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
|
|
||||||
/// XP awarded each time a weekly goal is just completed.
|
/// XP awarded each time a weekly goal is just completed.
|
||||||
pub const WEEKLY_GOAL_XP: u64 = 75;
|
pub const WEEKLY_GOAL_XP: u64 = 75;
|
||||||
@@ -36,7 +36,7 @@ pub struct WeeklyGoalDef {
|
|||||||
pub struct WeeklyGoalContext {
|
pub struct WeeklyGoalContext {
|
||||||
pub time_seconds: u64,
|
pub time_seconds: u64,
|
||||||
pub used_undo: bool,
|
pub used_undo: bool,
|
||||||
pub draw_mode: DrawMode,
|
pub draw_mode: DrawStockConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WeeklyGoalDef {
|
impl WeeklyGoalDef {
|
||||||
@@ -47,7 +47,7 @@ impl WeeklyGoalDef {
|
|||||||
WeeklyGoalKind::WinGame => true,
|
WeeklyGoalKind::WinGame => true,
|
||||||
WeeklyGoalKind::WinWithoutUndo => !ctx.used_undo,
|
WeeklyGoalKind::WinWithoutUndo => !ctx.used_undo,
|
||||||
WeeklyGoalKind::WinUnder { seconds } => ctx.time_seconds < seconds,
|
WeeklyGoalKind::WinUnder { seconds } => ctx.time_seconds < seconds,
|
||||||
WeeklyGoalKind::WinDrawThree => ctx.draw_mode == DrawMode::DrawThree,
|
WeeklyGoalKind::WinDrawThree => ctx.draw_mode == DrawStockConfig::DrawThree,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,7 @@ mod tests {
|
|||||||
WeeklyGoalContext {
|
WeeklyGoalContext {
|
||||||
time_seconds: time,
|
time_seconds: time,
|
||||||
used_undo: undo,
|
used_undo: undo,
|
||||||
draw_mode: DrawMode::DrawOne,
|
draw_mode: DrawStockConfig::DrawOne,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ mod tests {
|
|||||||
WeeklyGoalContext {
|
WeeklyGoalContext {
|
||||||
time_seconds: time,
|
time_seconds: time,
|
||||||
used_undo: false,
|
used_undo: false,
|
||||||
draw_mode: DrawMode::DrawThree,
|
draw_mode: DrawStockConfig::DrawThree,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -176,9 +176,9 @@ fn evaluate_on_win(
|
|||||||
daily_challenge_streak: progress.0.daily_challenge_streak,
|
daily_challenge_streak: progress.0.daily_challenge_streak,
|
||||||
last_win_score: ev.score,
|
last_win_score: ev.score,
|
||||||
last_win_time_seconds: ev.time_seconds,
|
last_win_time_seconds: ev.time_seconds,
|
||||||
last_win_used_undo: game.0.undo_count > 0,
|
last_win_used_undo: game.0.undo_count() > 0,
|
||||||
wall_clock_hour: Some(Local::now().hour()),
|
wall_clock_hour: Some(Local::now().hour()),
|
||||||
last_win_recycle_count: game.0.recycle_count,
|
last_win_recycle_count: game.0.recycle_count(),
|
||||||
last_win_is_zen: game.0.mode == solitaire_core::game_state::GameMode::Zen,
|
last_win_is_zen: game.0.mode == solitaire_core::game_state::GameMode::Zen,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -779,7 +779,7 @@ mod tests {
|
|||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<GameStateResource>()
|
.resource_mut::<GameStateResource>()
|
||||||
.0
|
.0
|
||||||
.undo_count = 1;
|
.force_test_undos(1);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 1000,
|
score: 1000,
|
||||||
@@ -819,7 +819,7 @@ mod tests {
|
|||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<GameStateResource>()
|
.resource_mut::<GameStateResource>()
|
||||||
.0
|
.0
|
||||||
.set_test_draw_mode(solitaire_core::DrawMode::DrawThree);
|
.set_test_draw_mode(solitaire_core::DrawStockConfig::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
|
||||||
.set_test_draw_mode(solitaire_core::DrawMode::DrawThree);
|
.set_test_draw_mode(solitaire_core::DrawStockConfig::DrawThree);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
@@ -1393,7 +1393,7 @@ mod tests {
|
|||||||
|
|
||||||
use crate::replay_playback::ReplayPlaybackState;
|
use crate::replay_playback::ReplayPlaybackState;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use solitaire_core::{DrawMode, game_state::GameMode};
|
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||||
use solitaire_data::{Replay, ReplayMove};
|
use solitaire_data::{Replay, ReplayMove};
|
||||||
|
|
||||||
/// Headless app variant that injects a default `ReplayPlaybackState`
|
/// Headless app variant that injects a default `ReplayPlaybackState`
|
||||||
@@ -1409,7 +1409,7 @@ mod tests {
|
|||||||
fn dummy_replay() -> Replay {
|
fn dummy_replay() -> Replay {
|
||||||
Replay::new(
|
Replay::new(
|
||||||
1,
|
1,
|
||||||
DrawMode::DrawOne,
|
DrawStockConfig::DrawOne,
|
||||||
GameMode::Classic,
|
GameMode::Classic,
|
||||||
10,
|
10,
|
||||||
100,
|
100,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
//! red/black colour split.
|
//! red/black colour split.
|
||||||
|
|
||||||
use bevy::math::UVec2;
|
use bevy::math::UVec2;
|
||||||
use solitaire_core::card::{Rank, Suit};
|
use solitaire_core::{Rank, Suit};
|
||||||
|
|
||||||
/// Target rasterisation size in pixels (2:3 aspect, half the default
|
/// Target rasterisation size in pixels (2:3 aspect, half the default
|
||||||
/// `SvgLoaderSettings` resolution).
|
/// `SvgLoaderSettings` resolution).
|
||||||
|
|||||||
@@ -168,8 +168,8 @@ mod tests {
|
|||||||
use crate::game_plugin::GamePlugin;
|
use crate::game_plugin::GamePlugin;
|
||||||
use crate::table_plugin::TablePlugin;
|
use crate::table_plugin::TablePlugin;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
use solitaire_core::card::{Deck, Rank, Suit};
|
use solitaire_core::{Deck, Rank, Suit};
|
||||||
use solitaire_core::{DrawMode, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
|
||||||
fn headless_app() -> App {
|
fn headless_app() -> App {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
@@ -183,7 +183,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn seeded_state_with_auto_move() -> (GameState, (KlondikePile, KlondikePile)) {
|
fn seeded_state_with_auto_move() -> (GameState, (KlondikePile, KlondikePile)) {
|
||||||
let mut g = GameState::new(1, DrawMode::DrawOne);
|
let mut g = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
g.set_test_stock_cards(Vec::new());
|
g.set_test_stock_cards(Vec::new());
|
||||||
g.set_test_waste_cards(Vec::new());
|
g.set_test_waste_cards(Vec::new());
|
||||||
for foundation in [
|
for foundation in [
|
||||||
@@ -207,7 +207,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
g.set_test_tableau_cards(
|
g.set_test_tableau_cards(
|
||||||
Tableau::Tableau1,
|
Tableau::Tableau1,
|
||||||
vec![solitaire_core::card::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
|
vec![solitaire_core::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
|
||||||
);
|
);
|
||||||
g.set_test_auto_completable(true);
|
g.set_test_auto_completable(true);
|
||||||
let expected = (
|
let expected = (
|
||||||
@@ -227,7 +227,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
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, DrawStockConfig::DrawOne);
|
||||||
g.set_test_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);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ use std::collections::VecDeque;
|
|||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::PrimaryWindow;
|
use bevy::window::PrimaryWindow;
|
||||||
use solitaire_core::card::Card;
|
use solitaire_core::Card;
|
||||||
|
|
||||||
use super::animation::CardAnimation;
|
use super::animation::CardAnimation;
|
||||||
use super::tuning::AnimationTuning;
|
use super::tuning::AnimationTuning;
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ use bevy::prelude::*;
|
|||||||
use bevy::sprite::Anchor;
|
use bevy::sprite::Anchor;
|
||||||
use bevy::window::WindowResized;
|
use bevy::window::WindowResized;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
use solitaire_core::{DrawMode, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
|
||||||
use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration};
|
use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration};
|
||||||
use crate::card_animation::CardAnimation;
|
use crate::card_animation::CardAnimation;
|
||||||
@@ -789,8 +789,8 @@ fn sync_cards(
|
|||||||
// 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,
|
DrawStockConfig::DrawOne => 1_usize,
|
||||||
DrawMode::DrawThree => 3_usize,
|
DrawStockConfig::DrawThree => 3_usize,
|
||||||
};
|
};
|
||||||
let waste_cards = game.waste_cards();
|
let waste_cards = game.waste_cards();
|
||||||
(waste_cards.len() > visible)
|
(waste_cards.len() > visible)
|
||||||
@@ -958,8 +958,8 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<((Card, bool), Vec2,
|
|||||||
// 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,
|
DrawStockConfig::DrawOne => 1_usize,
|
||||||
DrawMode::DrawThree => 3_usize,
|
DrawStockConfig::DrawThree => 3_usize,
|
||||||
};
|
};
|
||||||
// Render one extra card so that the card sliding off the waste
|
// Render one extra card so that the card sliding off the waste
|
||||||
// during a draw animation is still present in the world at z=0
|
// during a draw animation is still present in the world at z=0
|
||||||
@@ -972,7 +972,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(), DrawStockConfig::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
|
||||||
@@ -1487,6 +1487,7 @@ fn update_drag_shadow(
|
|||||||
drag: Res<DragState>,
|
drag: Res<DragState>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
card_entities: Query<(&CardEntity, &Transform)>,
|
card_entities: Query<(&CardEntity, &Transform)>,
|
||||||
|
card_index: Res<CardEntityIndex>,
|
||||||
mut shadow: Local<Option<Entity>>,
|
mut shadow: Local<Option<Entity>>,
|
||||||
) {
|
) {
|
||||||
if drag.is_idle() {
|
if drag.is_idle() {
|
||||||
@@ -1503,9 +1504,9 @@ fn update_drag_shadow(
|
|||||||
|
|
||||||
// Find the world position of the first (top) dragged card.
|
// Find the world position of the first (top) dragged card.
|
||||||
let top_pos = drag.cards.first().and_then(|first_card| {
|
let top_pos = drag.cards.first().and_then(|first_card| {
|
||||||
card_entities
|
card_index
|
||||||
.iter()
|
.get(first_card)
|
||||||
.find(|(marker, _)| marker.card == *first_card)
|
.and_then(|entity| card_entities.get(entity).ok())
|
||||||
.map(|(_, t)| t.translation)
|
.map(|(_, t)| t.translation)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2471,7 +2472,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::game_plugin::GamePlugin;
|
use crate::game_plugin::GamePlugin;
|
||||||
use crate::table_plugin::TablePlugin;
|
use crate::table_plugin::TablePlugin;
|
||||||
use solitaire_core::card::Deck;
|
use solitaire_core::Deck;
|
||||||
|
|
||||||
/// Convenience constructor — all unit tests use Deck1.
|
/// Convenience constructor — all unit tests use Deck1.
|
||||||
fn make_card(suit: Suit, rank: Rank) -> Card {
|
fn make_card(suit: Suit, rank: Rank) -> Card {
|
||||||
@@ -2565,7 +2566,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn card_positions_includes_all_52_cards_at_game_start() {
|
fn card_positions_includes_all_52_cards_at_game_start() {
|
||||||
// At game start waste is empty, so all 52 cards are across stock + tableau.
|
// At game start waste is empty, so all 52 cards are across stock + tableau.
|
||||||
let g = GameState::new(42, solitaire_core::DrawMode::DrawOne);
|
let g = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne);
|
||||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
let positions = card_positions(&g, &layout);
|
let positions = card_positions(&g, &layout);
|
||||||
assert_eq!(positions.len(), 52);
|
assert_eq!(positions.len(), 52);
|
||||||
@@ -2573,8 +2574,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn waste_draw_one_only_renders_top_card() {
|
fn waste_draw_one_only_renders_top_card() {
|
||||||
use solitaire_core::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
let mut g = GameState::new(42, DrawMode::DrawOne);
|
let mut g = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
// Draw 3 cards so the waste pile has 3 cards.
|
// Draw 3 cards so the waste pile has 3 cards.
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
let _ = g.draw();
|
let _ = g.draw();
|
||||||
@@ -2611,8 +2612,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn waste_draw_three_renders_up_to_three_fanned_cards() {
|
fn waste_draw_three_renders_up_to_three_fanned_cards() {
|
||||||
use solitaire_core::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
let mut g = GameState::new(42, DrawMode::DrawThree);
|
let mut g = GameState::new(42, DrawStockConfig::DrawThree);
|
||||||
// 5 draw() calls in Draw-Three mode accumulates multiple waste cards.
|
// 5 draw() calls in Draw-Three mode accumulates multiple waste cards.
|
||||||
for _ in 0..5 {
|
for _ in 0..5 {
|
||||||
let _ = g.draw();
|
let _ = g.draw();
|
||||||
@@ -2665,8 +2666,8 @@ mod tests {
|
|||||||
// Regression: slot.saturating_sub(1) always hid slot-0 even when the
|
// Regression: slot.saturating_sub(1) always hid slot-0 even when the
|
||||||
// pile was too small to have a buffer card, collapsing 2 visible cards
|
// pile was too small to have a buffer card, collapsing 2 visible cards
|
||||||
// onto x=0 instead of fanning them.
|
// onto x=0 instead of fanning them.
|
||||||
use solitaire_core::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
let mut g = GameState::new(42, DrawMode::DrawThree);
|
let mut g = GameState::new(42, DrawStockConfig::DrawThree);
|
||||||
// Draw exactly once — in Draw-Three mode with a full stock this gives
|
// Draw exactly once — in Draw-Three mode with a full stock this gives
|
||||||
// 3 waste cards (still ≤ visible=3, so no hidden buffer needed).
|
// 3 waste cards (still ≤ visible=3, so no hidden buffer needed).
|
||||||
let _ = g.draw();
|
let _ = g.draw();
|
||||||
@@ -2708,8 +2709,8 @@ mod tests {
|
|||||||
/// top card so that hiding it (`Visibility::Hidden`) leaves no visible gap.
|
/// top card so that hiding it (`Visibility::Hidden`) leaves no visible gap.
|
||||||
#[test]
|
#[test]
|
||||||
fn waste_draw_one_buffer_card_at_same_xy_as_top() {
|
fn waste_draw_one_buffer_card_at_same_xy_as_top() {
|
||||||
use solitaire_core::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
let mut g = GameState::new(42, DrawMode::DrawOne);
|
let mut g = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
// Draw 3 times so the waste pile has 3 cards and the buffer exists.
|
// Draw 3 times so the waste pile has 3 cards and the buffer exists.
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
let _ = g.draw();
|
let _ = g.draw();
|
||||||
@@ -2739,7 +2740,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn card_positions_tableau_cards_are_fanned_downward() {
|
fn card_positions_tableau_cards_are_fanned_downward() {
|
||||||
let g = GameState::new(42, solitaire_core::DrawMode::DrawOne);
|
let g = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne);
|
||||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
let positions = card_positions(&g, &layout);
|
let positions = card_positions(&g, &layout);
|
||||||
|
|
||||||
@@ -3082,7 +3083,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() {
|
fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() {
|
||||||
let g = GameState::new(42, solitaire_core::DrawMode::DrawOne);
|
let g = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne);
|
||||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
let positions = card_positions(&g, &layout);
|
let positions = card_positions(&g, &layout);
|
||||||
|
|
||||||
@@ -3532,7 +3533,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stock_card_count_helper_reads_zero_for_empty_stock() {
|
fn stock_card_count_helper_reads_zero_for_empty_stock() {
|
||||||
let g = GameState::new(42, solitaire_core::DrawMode::DrawOne);
|
let g = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne);
|
||||||
let mut g_empty_stock = g.clone();
|
let mut g_empty_stock = g.clone();
|
||||||
g_empty_stock.set_test_stock_cards(Vec::new());
|
g_empty_stock.set_test_stock_cards(Vec::new());
|
||||||
assert_eq!(stock_card_count(&g_empty_stock), 0);
|
assert_eq!(stock_card_count(&g_empty_stock), 0);
|
||||||
@@ -3804,8 +3805,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn waste_pile_cards_have_strictly_increasing_z() {
|
fn waste_pile_cards_have_strictly_increasing_z() {
|
||||||
use solitaire_core::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
let mut g = GameState::new(42, DrawMode::DrawThree);
|
let mut g = GameState::new(42, DrawStockConfig::DrawThree);
|
||||||
for _ in 0..5 {
|
for _ in 0..5 {
|
||||||
let _ = g.draw();
|
let _ = g.draw();
|
||||||
}
|
}
|
||||||
@@ -3848,8 +3849,8 @@ mod tests {
|
|||||||
/// offsets or flips the fan direction is caught immediately.
|
/// offsets or flips the fan direction is caught immediately.
|
||||||
#[test]
|
#[test]
|
||||||
fn waste_cards_do_not_overlap_stock_column_on_portrait() {
|
fn waste_cards_do_not_overlap_stock_column_on_portrait() {
|
||||||
use solitaire_core::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
let mut g = GameState::new(42, DrawMode::DrawThree);
|
let mut g = GameState::new(42, DrawStockConfig::DrawThree);
|
||||||
for _ in 0..5 {
|
for _ in 0..5 {
|
||||||
let _ = g.draw();
|
let _ = g.draw();
|
||||||
}
|
}
|
||||||
@@ -3884,8 +3885,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn waste_pile_draw_one_cards_have_distinct_z() {
|
fn waste_pile_draw_one_cards_have_distinct_z() {
|
||||||
use solitaire_core::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
let mut g = GameState::new(42, DrawMode::DrawOne);
|
let mut g = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
let _ = g.draw();
|
let _ = g.draw();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ mod tests {
|
|||||||
use crate::game_plugin::GamePlugin;
|
use crate::game_plugin::GamePlugin;
|
||||||
use crate::progress_plugin::ProgressPlugin;
|
use crate::progress_plugin::ProgressPlugin;
|
||||||
use crate::table_plugin::TablePlugin;
|
use crate::table_plugin::TablePlugin;
|
||||||
use solitaire_core::{DrawMode, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
|
||||||
fn headless_app() -> App {
|
fn headless_app() -> App {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
@@ -135,7 +135,7 @@ mod tests {
|
|||||||
fn challenge_win_advances_index() {
|
fn challenge_win_advances_index() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
|
GameState::new_with_mode(1, DrawStockConfig::DrawOne, GameMode::Challenge);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
@@ -224,7 +224,7 @@ mod tests {
|
|||||||
.0
|
.0
|
||||||
.challenge_index = 2;
|
.challenge_index = 2;
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
|
GameState::new_with_mode(1, DrawStockConfig::DrawOne, GameMode::Challenge);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
|
|||||||
@@ -34,9 +34,9 @@
|
|||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
|
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
|
||||||
use solitaire_core::card::Card;
|
use solitaire_core::Card;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
use solitaire_core::{DrawMode, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
|
||||||
use crate::card_plugin::RightClickHighlight;
|
use crate::card_plugin::RightClickHighlight;
|
||||||
use crate::layout::{Layout, LayoutResource};
|
use crate::layout::{Layout, LayoutResource};
|
||||||
@@ -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() == DrawStockConfig::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;
|
||||||
@@ -563,9 +563,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn cursor_over_draggable_returns_false_for_empty_game() {
|
fn cursor_over_draggable_returns_false_for_empty_game() {
|
||||||
use crate::layout::compute_layout;
|
use crate::layout::compute_layout;
|
||||||
use solitaire_core::{DrawMode, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
|
||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
// A cursor far off-screen should never hit anything.
|
// A cursor far off-screen should never hit anything.
|
||||||
assert!(!cursor_over_draggable(
|
assert!(!cursor_over_draggable(
|
||||||
@@ -580,8 +580,8 @@ mod tests {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
use crate::layout::compute_layout;
|
use crate::layout::compute_layout;
|
||||||
use solitaire_core::card::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
use solitaire_core::{DrawMode, game_state::{GameMode, GameState}};
|
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
|
||||||
|
|
||||||
/// Builds an `App` with `MinimalPlugins` and the overlay system
|
/// Builds an `App` with `MinimalPlugins` and the overlay system
|
||||||
/// registered, plus the resources the system needs. Callers
|
/// registered, plus the resources the system needs. Callers
|
||||||
@@ -629,7 +629,7 @@ mod tests {
|
|||||||
// 5 of Spades (black) onto Tableau(2)'s 6 of Clubs (also black)
|
// 5 of Spades (black) onto Tableau(2)'s 6 of Clubs (also black)
|
||||||
// — same colour family, illegal. Tableau(2) must NOT be
|
// — same colour family, illegal. Tableau(2) must NOT be
|
||||||
// highlighted.
|
// highlighted.
|
||||||
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
|
let mut game = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Classic);
|
||||||
set_tableau_top(
|
set_tableau_top(
|
||||||
&mut game,
|
&mut game,
|
||||||
2,
|
2,
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ mod tests {
|
|||||||
use crate::progress_plugin::ProgressPlugin;
|
use crate::progress_plugin::ProgressPlugin;
|
||||||
use crate::table_plugin::TablePlugin;
|
use crate::table_plugin::TablePlugin;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use solitaire_core::{DrawMode, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
|
||||||
fn headless_app() -> App {
|
fn headless_app() -> App {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
@@ -391,7 +391,7 @@ mod tests {
|
|||||||
|
|
||||||
// Replace the GameState with one whose seed matches the daily seed.
|
// Replace the GameState with one whose seed matches the daily seed.
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new(daily_seed, DrawMode::DrawOne);
|
GameState::new(daily_seed, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
@@ -419,7 +419,7 @@ mod tests {
|
|||||||
let daily_seed = app.world().resource::<DailyChallengeResource>().seed;
|
let daily_seed = app.world().resource::<DailyChallengeResource>().seed;
|
||||||
// Use a deliberately different seed.
|
// Use a deliberately different seed.
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new(daily_seed.wrapping_add(7777), DrawMode::DrawOne);
|
GameState::new(daily_seed.wrapping_add(7777), DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
@@ -442,7 +442,7 @@ mod tests {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
let daily_seed = app.world().resource::<DailyChallengeResource>().seed;
|
let daily_seed = app.world().resource::<DailyChallengeResource>().seed;
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new(daily_seed, DrawMode::DrawOne);
|
GameState::new(daily_seed, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use bevy::prelude::Message;
|
use bevy::prelude::Message;
|
||||||
use solitaire_core::KlondikePile;
|
use solitaire_core::KlondikePile;
|
||||||
use solitaire_core::card::{Card, Suit};
|
use solitaire_core::{Card, Suit};
|
||||||
use solitaire_core::game_state::GameMode;
|
use solitaire_core::game_state::GameMode;
|
||||||
use solitaire_data::AchievementRecord;
|
use solitaire_data::AchievementRecord;
|
||||||
use solitaire_sync::SyncResponse;
|
use solitaire_sync::SyncResponse;
|
||||||
|
|||||||
@@ -43,8 +43,9 @@ use std::hash::{Hash, Hasher};
|
|||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::RequestRedraw;
|
use bevy::window::RequestRedraw;
|
||||||
use solitaire_core::card::Card;
|
use solitaire_core::Card;
|
||||||
use solitaire_core::{Foundation, KlondikePile};
|
use solitaire_core::KlondikePile;
|
||||||
|
use solitaire_core::klondike_adapter::foundation_from_slot;
|
||||||
use solitaire_data::AnimSpeed;
|
use solitaire_data::AnimSpeed;
|
||||||
|
|
||||||
use crate::animation_plugin::CardAnim;
|
use crate::animation_plugin::CardAnim;
|
||||||
@@ -188,10 +189,6 @@ pub fn deal_stagger_jitter(card_id: u32) -> f32 {
|
|||||||
(jitter_norm - 0.5) * 0.2 // ±0.1 == ±10 %
|
(jitter_norm - 0.5) * 0.2 // ±0.1 == ±10 %
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-card jitter keys off the shared stable card id so it matches the
|
|
||||||
// numeric identity used elsewhere (and on the WASM replay side).
|
|
||||||
use solitaire_core::card::card_to_id;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Plugin
|
// Plugin
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -412,10 +409,14 @@ fn start_deal_anim(
|
|||||||
|
|
||||||
for (index, (entity, card_marker, transform)) in card_entities.iter().enumerate() {
|
for (index, (entity, card_marker, transform)) in card_entities.iter().enumerate() {
|
||||||
let final_pos = transform.translation;
|
let final_pos = transform.translation;
|
||||||
// ±10 % jitter, deterministic per card id, so the deal feels organic
|
// ±10 % jitter, deterministic per card, so the deal feels organic
|
||||||
// without losing reproducibility (a given seed still produces the
|
// without losing reproducibility (a given deal produces the same
|
||||||
// same per-card stagger pattern across runs).
|
// per-card stagger pattern across runs). The seed is a hash of the
|
||||||
let per_card_stagger = stagger_secs * (1.0 + deal_stagger_jitter(card_to_id(&card_marker.card)));
|
// card's own identity — no separate numeric id needed.
|
||||||
|
let mut card_hasher = DefaultHasher::new();
|
||||||
|
card_marker.card.hash(&mut card_hasher);
|
||||||
|
let per_card_stagger =
|
||||||
|
stagger_secs * (1.0 + deal_stagger_jitter(card_hasher.finish() as u32));
|
||||||
commands.entity(entity).insert((
|
commands.entity(entity).insert((
|
||||||
Transform::from_translation(stock_start.with_z(final_pos.z)),
|
Transform::from_translation(stock_start.with_z(final_pos.z)),
|
||||||
CardAnim {
|
CardAnim {
|
||||||
@@ -638,23 +639,13 @@ fn lerp_color(from: Color, to: Color, t: f32) -> Color {
|
|||||||
fn pile_cards(
|
fn pile_cards(
|
||||||
game: &solitaire_core::game_state::GameState,
|
game: &solitaire_core::game_state::GameState,
|
||||||
pile: &KlondikePile,
|
pile: &KlondikePile,
|
||||||
) -> Vec<(solitaire_core::card::Card, bool)> {
|
) -> Vec<(solitaire_core::Card, bool)> {
|
||||||
match pile {
|
match pile {
|
||||||
KlondikePile::Stock => game.waste_cards(),
|
KlondikePile::Stock => game.waste_cards(),
|
||||||
_ => game.pile(*pile),
|
_ => game.pile(*pile),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn foundation_from_slot(slot: u8) -> Option<Foundation> {
|
|
||||||
match slot {
|
|
||||||
0 => Some(Foundation::Foundation1),
|
|
||||||
1 => Some(Foundation::Foundation2),
|
|
||||||
2 => Some(Foundation::Foundation3),
|
|
||||||
3 => Some(Foundation::Foundation4),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Unit tests (pure functions only — no Bevy world required)
|
// Unit tests (pure functions only — no Bevy world required)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -855,13 +846,13 @@ mod tests {
|
|||||||
fn shake_anim_skipped_under_reduce_motion() {
|
fn shake_anim_skipped_under_reduce_motion() {
|
||||||
use bevy::ecs::message::Messages;
|
use bevy::ecs::message::Messages;
|
||||||
use solitaire_core::Tableau;
|
use solitaire_core::Tableau;
|
||||||
use solitaire_core::{DrawMode, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
use solitaire_data::Settings;
|
use solitaire_data::Settings;
|
||||||
|
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins)
|
app.add_plugins(MinimalPlugins)
|
||||||
.add_plugins(FeedbackAnimPlugin);
|
.add_plugins(FeedbackAnimPlugin);
|
||||||
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
|
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
|
||||||
app.insert_resource(SettingsResource(Settings {
|
app.insert_resource(SettingsResource(Settings {
|
||||||
reduce_motion_mode: true,
|
reduce_motion_mode: true,
|
||||||
..Settings::default()
|
..Settings::default()
|
||||||
@@ -909,13 +900,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn foundation_flourish_skipped_under_reduce_motion() {
|
fn foundation_flourish_skipped_under_reduce_motion() {
|
||||||
use bevy::ecs::message::Messages;
|
use bevy::ecs::message::Messages;
|
||||||
use solitaire_core::{DrawMode, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
use solitaire_data::Settings;
|
use solitaire_data::Settings;
|
||||||
|
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins)
|
app.add_plugins(MinimalPlugins)
|
||||||
.add_plugins(FeedbackAnimPlugin);
|
.add_plugins(FeedbackAnimPlugin);
|
||||||
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
|
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
|
||||||
app.insert_resource(SettingsResource(Settings {
|
app.insert_resource(SettingsResource(Settings {
|
||||||
reduce_motion_mode: true,
|
reduce_motion_mode: true,
|
||||||
..Settings::default()
|
..Settings::default()
|
||||||
@@ -926,7 +917,7 @@ mod tests {
|
|||||||
.resource_mut::<Messages<FoundationCompletedEvent>>()
|
.resource_mut::<Messages<FoundationCompletedEvent>>()
|
||||||
.write(FoundationCompletedEvent {
|
.write(FoundationCompletedEvent {
|
||||||
slot: 0,
|
slot: 0,
|
||||||
suit: solitaire_core::card::Suit::Spades,
|
suit: solitaire_core::Suit::Spades,
|
||||||
});
|
});
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,8 @@ use bevy::prelude::*;
|
|||||||
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
||||||
use bevy::window::AppLifecycle;
|
use bevy::window::AppLifecycle;
|
||||||
use solitaire_core::KlondikePile;
|
use solitaire_core::KlondikePile;
|
||||||
use solitaire_core::{DrawMode, game_state::{GameMode, GameState}};
|
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
|
||||||
use solitaire_data::solver::{
|
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET};
|
||||||
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, try_solve,
|
|
||||||
};
|
|
||||||
#[allow(deprecated)]
|
#[allow(deprecated)]
|
||||||
use solitaire_data::latest_replay_path;
|
use solitaire_data::latest_replay_path;
|
||||||
use solitaire_data::{
|
use solitaire_data::{
|
||||||
@@ -159,12 +157,12 @@ impl Plugin for GamePlugin {
|
|||||||
.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(), DrawStockConfig::DrawOne),
|
||||||
saved,
|
saved,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
(
|
(
|
||||||
saved.unwrap_or_else(|| GameState::new(seed_from_system_time(), DrawMode::DrawOne)),
|
saved.unwrap_or_else(|| GameState::new(seed_from_system_time(), DrawStockConfig::DrawOne)),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
@@ -318,7 +316,7 @@ fn seed_from_system_time() -> u64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Walks forward from `initial_seed` (incrementing by 1 with wrapping
|
/// Walks forward from `initial_seed` (incrementing by 1 with wrapping
|
||||||
/// arithmetic) until the [`solitaire_data::solver`] returns a verdict
|
/// arithmetic) until the [`GameState::solve_fresh_deal`] returns a verdict
|
||||||
/// the engine accepts as winnable, or until [`SOLVER_DEAL_RETRY_CAP`]
|
/// the engine accepts as winnable, or until [`SOLVER_DEAL_RETRY_CAP`]
|
||||||
/// attempts have elapsed.
|
/// attempts have elapsed.
|
||||||
///
|
///
|
||||||
@@ -390,10 +388,10 @@ fn poll_pending_new_game_seed(
|
|||||||
|
|
||||||
/// Pure helper extracted for testability — `new_game_with_solver_*`
|
/// Pure helper extracted for testability — `new_game_with_solver_*`
|
||||||
/// engine tests in the same file exercise this path.
|
/// engine tests in the same file exercise this path.
|
||||||
pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: DrawMode) -> u64 {
|
pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: DrawStockConfig) -> u64 {
|
||||||
let mut seed = initial_seed;
|
let mut seed = initial_seed;
|
||||||
for _ in 0..SOLVER_DEAL_RETRY_CAP {
|
for _ in 0..SOLVER_DEAL_RETRY_CAP {
|
||||||
match try_solve(
|
match GameState::solve_fresh_deal(
|
||||||
seed,
|
seed,
|
||||||
draw_mode,
|
draw_mode,
|
||||||
DEFAULT_SOLVE_MOVES_BUDGET,
|
DEFAULT_SOLVE_MOVES_BUDGET,
|
||||||
@@ -826,14 +824,14 @@ fn handle_draw(
|
|||||||
// so we can fire flip events after they land face-up in the waste.
|
// so we can fire flip events after they land face-up in the waste.
|
||||||
// Only relevant when stock is non-empty; a recycle moves waste back to
|
// Only relevant when stock is non-empty; a recycle moves waste back to
|
||||||
// stock face-down, so no flip events are needed in that case.
|
// stock face-down, so no flip events are needed in that case.
|
||||||
let drawn_cards: Vec<solitaire_core::card::Card> = {
|
let drawn_cards: Vec<solitaire_core::Card> = {
|
||||||
let stock = game.0.stock_cards();
|
let stock = game.0.stock_cards();
|
||||||
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,
|
DrawStockConfig::DrawOne => 1_usize,
|
||||||
DrawMode::DrawThree => 3_usize,
|
DrawStockConfig::DrawThree => 3_usize,
|
||||||
};
|
};
|
||||||
let n = stock.len();
|
let n = stock.len();
|
||||||
let take = n.min(draw_count);
|
let take = n.min(draw_count);
|
||||||
@@ -920,7 +918,7 @@ fn handle_move(
|
|||||||
changed.write(StateChangedEvent);
|
changed.write(StateChangedEvent);
|
||||||
if !was_won && game.0.is_won() {
|
if !was_won && game.0.is_won() {
|
||||||
won.write(GameWonEvent {
|
won.write(GameWonEvent {
|
||||||
score: game.0.score,
|
score: game.0.score(),
|
||||||
time_seconds: game.0.elapsed_seconds,
|
time_seconds: game.0.elapsed_seconds,
|
||||||
});
|
});
|
||||||
// Delete the saved state — a won game should not be resumed.
|
// Delete the saved state — a won game should not be resumed.
|
||||||
@@ -1015,7 +1013,7 @@ pub fn record_replay_on_win(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(solitaire_core::card::Card, bool)> {
|
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(solitaire_core::Card, bool)> {
|
||||||
match pile {
|
match pile {
|
||||||
KlondikePile::Stock => game.waste_cards(),
|
KlondikePile::Stock => game.waste_cards(),
|
||||||
_ => game.pile(*pile),
|
_ => game.pile(*pile),
|
||||||
@@ -1117,7 +1115,7 @@ fn check_no_moves(
|
|||||||
// Only spawn the overlay if one does not already exist, and no other
|
// Only spawn the overlay if one does not already exist, and no other
|
||||||
// modal scrim is currently open (global ModalScrim guard).
|
// modal scrim is currently open (global ModalScrim guard).
|
||||||
if game_over_screens.is_empty() && scrims.is_empty() {
|
if game_over_screens.is_empty() && scrims.is_empty() {
|
||||||
spawn_game_over_screen(&mut commands, game.0.score, font_res.as_deref());
|
spawn_game_over_screen(&mut commands, game.0.score(), font_res.as_deref());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1326,7 +1324,7 @@ mod tests {
|
|||||||
app.insert_resource(PendingRestoredGame(None));
|
app.insert_resource(PendingRestoredGame(None));
|
||||||
// Override the system-time seed with a known value.
|
// Override the system-time seed with a known value.
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new(seed, DrawMode::DrawOne);
|
GameState::new(seed, DrawStockConfig::DrawOne);
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1393,7 +1391,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn new_game_request_reseeds() {
|
fn new_game_request_reseeds() {
|
||||||
let mut app = test_app(1);
|
let mut app = test_app(1);
|
||||||
let before: Vec<solitaire_core::card::Card> = app
|
let before: Vec<solitaire_core::Card> = app
|
||||||
.world()
|
.world()
|
||||||
.resource::<GameStateResource>()
|
.resource::<GameStateResource>()
|
||||||
.0
|
.0
|
||||||
@@ -1409,7 +1407,7 @@ mod tests {
|
|||||||
});
|
});
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let after: Vec<solitaire_core::card::Card> = app
|
let after: Vec<solitaire_core::Card> = app
|
||||||
.world()
|
.world()
|
||||||
.resource::<GameStateResource>()
|
.resource::<GameStateResource>()
|
||||||
.0
|
.0
|
||||||
@@ -1542,7 +1540,7 @@ mod tests {
|
|||||||
app.insert_resource(GameStatePath(Some(path.clone())));
|
app.insert_resource(GameStatePath(Some(path.clone())));
|
||||||
// Override the seed so we can verify it was written.
|
// Override the seed so we can verify it was written.
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new(7654, DrawMode::DrawOne);
|
GameState::new(7654, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
app.world_mut().write_message(AppExit::Success);
|
app.world_mut().write_message(AppExit::Success);
|
||||||
app.update();
|
app.update();
|
||||||
@@ -1561,7 +1559,7 @@ mod tests {
|
|||||||
|
|
||||||
let path = tmp_gs_path("new_game_delete");
|
let path = tmp_gs_path("new_game_delete");
|
||||||
// Pre-create a saved file.
|
// Pre-create a saved file.
|
||||||
save_game_state_to(&path, &GameState::new(1, DrawMode::DrawOne)).unwrap();
|
save_game_state_to(&path, &GameState::new(1, DrawStockConfig::DrawOne)).unwrap();
|
||||||
assert!(path.exists());
|
assert!(path.exists());
|
||||||
|
|
||||||
let mut app = test_app(1);
|
let mut app = test_app(1);
|
||||||
@@ -1651,7 +1649,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn moving_cards_off_face_up_card_does_not_fire_card_flipped_event() {
|
fn moving_cards_off_face_up_card_does_not_fire_card_flipped_event() {
|
||||||
use solitaire_core::card::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
let mut app = test_app(1);
|
let mut app = test_app(1);
|
||||||
// Build a tableau with two face-up cards.
|
// Build a tableau with two face-up cards.
|
||||||
{
|
{
|
||||||
@@ -1695,7 +1693,7 @@ mod tests {
|
|||||||
fn has_legal_moves_returns_true_for_fresh_game() {
|
fn has_legal_moves_returns_true_for_fresh_game() {
|
||||||
// A fresh deal always has a non-empty stock (24 cards), so drawing
|
// A fresh deal always has a non-empty stock (24 cards), so drawing
|
||||||
// is always a legal move regardless of the initial face-up tableau cards.
|
// is always a legal move regardless of the initial face-up tableau cards.
|
||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
assert!(
|
assert!(
|
||||||
has_legal_moves(&game),
|
has_legal_moves(&game),
|
||||||
"fresh deal must contain at least one legal move"
|
"fresh deal must contain at least one legal move"
|
||||||
@@ -1708,8 +1706,8 @@ mod tests {
|
|||||||
// Klondike (unlimited recycles), even if the drawn card cannot be
|
// Klondike (unlimited recycles), even if the drawn card cannot be
|
||||||
// immediately placed. The game is only stuck when both stock AND waste
|
// immediately placed. The game is only stuck when both stock AND waste
|
||||||
// are exhausted and no visible card can be moved.
|
// are exhausted and no visible card can be moved.
|
||||||
use solitaire_core::card::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
for foundation in [
|
for foundation in [
|
||||||
Foundation::Foundation1,
|
Foundation::Foundation1,
|
||||||
Foundation::Foundation2,
|
Foundation::Foundation2,
|
||||||
@@ -1744,8 +1742,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn has_legal_moves_returns_true_when_ace_can_go_to_foundation() {
|
fn has_legal_moves_returns_true_when_ace_can_go_to_foundation() {
|
||||||
use solitaire_core::card::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
// Empty stock and waste so draw is NOT available.
|
// Empty stock and waste so draw is NOT available.
|
||||||
game.set_test_stock_cards(Vec::new());
|
game.set_test_stock_cards(Vec::new());
|
||||||
@@ -1788,8 +1786,8 @@ mod tests {
|
|||||||
// If the only legal move involves a face-up card that is NOT the top
|
// If the only legal move involves a face-up card that is NOT the top
|
||||||
// card of its column the previous code would return false (softlock)
|
// card of its column the previous code would return false (softlock)
|
||||||
// even though the player can still move that run.
|
// even though the player can still move that run.
|
||||||
use solitaire_core::card::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
game.set_test_stock_cards(Vec::new());
|
game.set_test_stock_cards(Vec::new());
|
||||||
game.set_test_waste_cards(Vec::new());
|
game.set_test_waste_cards(Vec::new());
|
||||||
@@ -1978,7 +1976,7 @@ mod tests {
|
|||||||
/// to have been a King.
|
/// to have been a King.
|
||||||
#[test]
|
#[test]
|
||||||
fn foundation_completed_event_does_not_fire_for_non_foundation_moves() {
|
fn foundation_completed_event_does_not_fire_for_non_foundation_moves() {
|
||||||
use solitaire_core::card::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
|
|
||||||
let mut app = test_app(1);
|
let mut app = test_app(1);
|
||||||
// Reset the world: clear stock + waste so a draw isn't possible,
|
// Reset the world: clear stock + waste so a draw isn't possible,
|
||||||
@@ -2187,7 +2185,7 @@ mod tests {
|
|||||||
assert_eq!(loaded.seed, 7654, "seed must match the live game state");
|
assert_eq!(loaded.seed, 7654, "seed must match the live game state");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
loaded.draw_mode,
|
loaded.draw_mode,
|
||||||
DrawMode::DrawOne,
|
DrawStockConfig::DrawOne,
|
||||||
"draw_mode must be captured"
|
"draw_mode must be captured"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -2328,7 +2326,7 @@ mod tests {
|
|||||||
"with solver toggle off, the requested seed must be honoured exactly"
|
"with solver toggle off, the requested seed must be honoured exactly"
|
||||||
);
|
);
|
||||||
// Cross-check: the dealt tableau must match GameState::new(999) byte-for-byte.
|
// Cross-check: the dealt tableau must match GameState::new(999) byte-for-byte.
|
||||||
let expected = GameState::new(999, DrawMode::DrawOne);
|
let expected = GameState::new(999, DrawStockConfig::DrawOne);
|
||||||
for tableau in [
|
for tableau in [
|
||||||
Tableau::Tableau1,
|
Tableau::Tableau1,
|
||||||
Tableau::Tableau2,
|
Tableau::Tableau2,
|
||||||
@@ -2405,7 +2403,7 @@ mod tests {
|
|||||||
//
|
//
|
||||||
// Seed 394 was previously Unwinnable under the old DFS; now it resolves
|
// Seed 394 was previously Unwinnable under the old DFS; now it resolves
|
||||||
// as Inconclusive, so the helper must accept it immediately.
|
// as Inconclusive, so the helper must accept it immediately.
|
||||||
let chosen = choose_winnable_seed(394, DrawMode::DrawOne);
|
let chosen = choose_winnable_seed(394, DrawStockConfig::DrawOne);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
chosen, 394,
|
chosen, 394,
|
||||||
"seed 394 resolves as Inconclusive; choose_winnable_seed must accept it as-is"
|
"seed 394 resolves as Inconclusive; choose_winnable_seed must accept it as-is"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::{DrawMode, game_state::DifficultyLevel};
|
use solitaire_core::{DrawStockConfig, game_state::DifficultyLevel};
|
||||||
use solitaire_data::save_settings_to;
|
use solitaire_data::save_settings_to;
|
||||||
|
|
||||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||||
@@ -432,7 +432,7 @@ fn build_home_context<'a>(
|
|||||||
zen_best: stats.map_or(0, |s| s.0.zen_best_score),
|
zen_best: stats.map_or(0, |s| s.0.zen_best_score),
|
||||||
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
|
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
|
||||||
daily_today,
|
daily_today,
|
||||||
draw_mode: settings.map(|s| s.0.draw_mode).unwrap_or(DrawMode::DrawOne),
|
draw_mode: settings.map(|s| s.0.draw_mode).unwrap_or(DrawStockConfig::DrawOne),
|
||||||
font_res,
|
font_res,
|
||||||
difficulty_expanded,
|
difficulty_expanded,
|
||||||
last_difficulty: settings.and_then(|s| s.0.last_difficulty),
|
last_difficulty: settings.and_then(|s| s.0.last_difficulty),
|
||||||
@@ -620,9 +620,9 @@ fn handle_home_draw_mode_buttons(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let target = if want_one {
|
let target = if want_one {
|
||||||
DrawMode::DrawOne
|
DrawStockConfig::DrawOne
|
||||||
} else {
|
} else {
|
||||||
DrawMode::DrawThree
|
DrawStockConfig::DrawThree
|
||||||
};
|
};
|
||||||
if settings.0.draw_mode == target {
|
if settings.0.draw_mode == target {
|
||||||
return; // already in this mode — avoid a redundant respawn.
|
return; // already in this mode — avoid a redundant respawn.
|
||||||
@@ -857,7 +857,7 @@ struct HomeContext<'a> {
|
|||||||
challenge_best: u32,
|
challenge_best: u32,
|
||||||
daily_streak: u32,
|
daily_streak: u32,
|
||||||
daily_today: Option<DailyToday>,
|
daily_today: Option<DailyToday>,
|
||||||
draw_mode: DrawMode,
|
draw_mode: DrawStockConfig,
|
||||||
font_res: Option<&'a FontResource>,
|
font_res: Option<&'a FontResource>,
|
||||||
/// Whether the difficulty section header is currently expanded.
|
/// Whether the difficulty section header is currently expanded.
|
||||||
difficulty_expanded: bool,
|
difficulty_expanded: bool,
|
||||||
@@ -1038,7 +1038,7 @@ fn spawn_draw_mode_row(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>)
|
|||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let active_one = matches!(ctx.draw_mode, DrawMode::DrawOne);
|
let active_one = matches!(ctx.draw_mode, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
parent
|
parent
|
||||||
.spawn(Node {
|
.spawn(Node {
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::WindowResized;
|
use bevy::window::WindowResized;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
use solitaire_core::card::Suit;
|
use solitaire_core::Suit;
|
||||||
use solitaire_core::{DrawMode, game_state::GameMode};
|
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||||
|
|
||||||
use crate::auto_complete_plugin::AutoCompleteState;
|
use crate::auto_complete_plugin::AutoCompleteState;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
@@ -1818,7 +1818,7 @@ fn detect_score_change(
|
|||||||
score_q: Query<Entity, With<HudScore>>,
|
score_q: Query<Entity, With<HudScore>>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
let current = game.0.score;
|
let current = game.0.score();
|
||||||
let delta = current - prev.0;
|
let delta = current - prev.0;
|
||||||
prev.0 = current;
|
prev.0 = current;
|
||||||
if delta <= 0 {
|
if delta <= 0 {
|
||||||
@@ -2275,7 +2275,7 @@ fn update_hud(
|
|||||||
**t = if is_zen {
|
**t = if is_zen {
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
format!("Score: {}", g.score)
|
format!("Score: {}", g.score())
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if let Ok(mut t) = moves_q.single_mut() {
|
if let Ok(mut t) = moves_q.single_mut() {
|
||||||
@@ -2284,8 +2284,8 @@ fn update_hud(
|
|||||||
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(),
|
DrawStockConfig::DrawOne => String::new(),
|
||||||
DrawMode::DrawThree => "Draw 3".to_string(),
|
DrawStockConfig::DrawThree => "Draw 3".to_string(),
|
||||||
},
|
},
|
||||||
GameMode::Zen => "ZEN".to_string(),
|
GameMode::Zen => "ZEN".to_string(),
|
||||||
GameMode::Challenge => "CHALLENGE".to_string(),
|
GameMode::Challenge => "CHALLENGE".to_string(),
|
||||||
@@ -2311,7 +2311,7 @@ fn update_hud(
|
|||||||
|
|
||||||
// --- Undo count ---
|
// --- Undo count ---
|
||||||
if let Ok((mut t, mut color)) = undos_q.single_mut() {
|
if let Ok((mut t, mut color)) = undos_q.single_mut() {
|
||||||
let count = g.undo_count;
|
let count = g.undo_count();
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
**t = String::new();
|
**t = String::new();
|
||||||
*color = TextColor(TEXT_PRIMARY);
|
*color = TextColor(TEXT_PRIMARY);
|
||||||
@@ -2325,8 +2325,8 @@ fn update_hud(
|
|||||||
|
|
||||||
// --- Recycle counter (both modes, hidden until first recycle) ---
|
// --- Recycle counter (both modes, hidden until first recycle) ---
|
||||||
if let Ok(mut t) = recycles_q.single_mut() {
|
if let Ok(mut t) = recycles_q.single_mut() {
|
||||||
**t = if g.recycle_count > 0 {
|
**t = if g.recycle_count() > 0 {
|
||||||
format!("Recycles: {}", g.recycle_count)
|
format!("Recycles: {}", g.recycle_count())
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
@@ -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() != DrawStockConfig::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 {
|
||||||
@@ -2726,7 +2726,7 @@ mod tests {
|
|||||||
use crate::game_plugin::GamePlugin;
|
use crate::game_plugin::GamePlugin;
|
||||||
use crate::table_plugin::TablePlugin;
|
use crate::table_plugin::TablePlugin;
|
||||||
use chrono::Local;
|
use chrono::Local;
|
||||||
use solitaire_core::{DrawMode, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
|
||||||
fn headless_app() -> App {
|
fn headless_app() -> App {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
@@ -2747,7 +2747,7 @@ mod tests {
|
|||||||
fn update_hud_runs_after_game_mutation_without_panic() {
|
fn update_hud_runs_after_game_mutation_without_panic() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new(42, DrawMode::DrawOne);
|
GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
app.update();
|
app.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2763,9 +2763,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn score_reflects_game_state() {
|
fn score_reflects_game_state() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.score = 750;
|
let score = app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(20);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(read_hud_text::<HudScore>(&mut app), "Score: 750");
|
assert_eq!(read_hud_text::<HudScore>(&mut app), format!("Score: {score}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2784,7 +2784,7 @@ mod tests {
|
|||||||
use solitaire_core::game_state::GameMode;
|
use solitaire_core::game_state::GameMode;
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new_with_mode(42, DrawMode::DrawThree, GameMode::Classic);
|
GameState::new_with_mode(42, DrawStockConfig::DrawThree, GameMode::Classic);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(read_hud_text::<HudMode>(&mut app), "Draw 3");
|
assert_eq!(read_hud_text::<HudMode>(&mut app), "Draw 3");
|
||||||
}
|
}
|
||||||
@@ -2794,8 +2794,7 @@ mod tests {
|
|||||||
use solitaire_core::game_state::GameMode;
|
use solitaire_core::game_state::GameMode;
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen);
|
GameState::new_with_mode(42, DrawStockConfig::DrawOne, GameMode::Zen);
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.score = 999;
|
|
||||||
app.update();
|
app.update();
|
||||||
// Zen mode spec: "No score display" → text must be empty.
|
// Zen mode spec: "No score display" → text must be empty.
|
||||||
assert_eq!(read_hud_text::<HudScore>(&mut app), "");
|
assert_eq!(read_hud_text::<HudScore>(&mut app), "");
|
||||||
@@ -2916,7 +2915,7 @@ mod tests {
|
|||||||
fn challenge_hud_empty_when_no_daily_resource() {
|
fn challenge_hud_empty_when_no_daily_resource() {
|
||||||
// No DailyChallengeResource inserted → HudChallenge must be empty.
|
// No DailyChallengeResource inserted → HudChallenge must be empty.
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.score = 1; // force change
|
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
||||||
}
|
}
|
||||||
@@ -2931,7 +2930,7 @@ mod tests {
|
|||||||
target_score: None,
|
target_score: None,
|
||||||
max_time_secs: Some(300),
|
max_time_secs: Some(300),
|
||||||
});
|
});
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.score = 1; // force change
|
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00");
|
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00");
|
||||||
}
|
}
|
||||||
@@ -2946,7 +2945,7 @@ mod tests {
|
|||||||
target_score: Some(4000),
|
target_score: Some(4000),
|
||||||
max_time_secs: None,
|
max_time_secs: None,
|
||||||
});
|
});
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.score = 1;
|
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts");
|
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts");
|
||||||
}
|
}
|
||||||
@@ -2984,7 +2983,7 @@ mod tests {
|
|||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<GameStateResource>()
|
.resource_mut::<GameStateResource>()
|
||||||
.0
|
.0
|
||||||
.undo_count = 3;
|
.force_test_undos(3);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(read_hud_text::<HudUndos>(&mut app), "Undos: 3");
|
assert_eq!(read_hud_text::<HudUndos>(&mut app), "Undos: 3");
|
||||||
}
|
}
|
||||||
@@ -3038,7 +3037,7 @@ mod tests {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
// Draw-One, no recycles yet — text must be empty.
|
// Draw-One, no recycles yet — text must be empty.
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new(42, DrawMode::DrawOne);
|
GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "");
|
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "");
|
||||||
}
|
}
|
||||||
@@ -3048,7 +3047,7 @@ mod tests {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
// Draw-Three, no recycles yet — text must also be empty.
|
// Draw-Three, no recycles yet — text must also be empty.
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new(42, DrawMode::DrawThree);
|
GameState::new(42, DrawStockConfig::DrawThree);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "");
|
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "");
|
||||||
}
|
}
|
||||||
@@ -3056,8 +3055,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn recycles_hud_shows_count_draw_three() {
|
fn recycles_hud_shows_count_draw_three() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
let mut gs = GameState::new(42, DrawMode::DrawThree);
|
let mut gs = GameState::new(42, DrawStockConfig::DrawThree);
|
||||||
gs.recycle_count = 3;
|
gs.force_test_recycles(3);
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 3");
|
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 3");
|
||||||
@@ -3067,8 +3066,8 @@ mod tests {
|
|||||||
fn recycles_hud_shows_count_draw_one() {
|
fn recycles_hud_shows_count_draw_one() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
// Draw-One with recycle_count > 0 must now show the counter too.
|
// Draw-One with recycle_count > 0 must now show the counter too.
|
||||||
let mut gs = GameState::new(42, DrawMode::DrawOne);
|
let mut gs = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
gs.recycle_count = 2;
|
gs.force_test_recycles(2);
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 2");
|
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 2");
|
||||||
@@ -3108,7 +3107,7 @@ mod tests {
|
|||||||
set_manual_time_step(&mut app, 0.0);
|
set_manual_time_step(&mut app, 0.0);
|
||||||
// Initial state has score=0; bumping by 50 (the threshold)
|
// Initial state has score=0; bumping by 50 (the threshold)
|
||||||
// is the smallest jump that triggers the floater.
|
// is the smallest jump that triggers the floater.
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.score = 50;
|
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
// One floater should now exist.
|
// One floater should now exist.
|
||||||
@@ -3129,7 +3128,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn score_floater_despawns_after_full_lifetime() {
|
fn score_floater_despawns_after_full_lifetime() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.score = 100;
|
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(count_with::<ScoreFloater>(&mut app), 1);
|
assert_eq!(count_with::<ScoreFloater>(&mut app), 1);
|
||||||
|
|
||||||
@@ -3155,7 +3154,7 @@ mod tests {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
// +5 mirrors a single tableau-to-foundation move; well below
|
// +5 mirrors a single tableau-to-foundation move; well below
|
||||||
// the 50-point threshold so the floater path stays dormant.
|
// the 50-point threshold so the floater path stays dormant.
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.score = 5;
|
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(5);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
count_with::<ScoreFloater>(&mut app),
|
count_with::<ScoreFloater>(&mut app),
|
||||||
@@ -3231,7 +3230,7 @@ mod tests {
|
|||||||
..Settings::default()
|
..Settings::default()
|
||||||
}));
|
}));
|
||||||
// +100 would normally create both a ScorePulse and a ScoreFloater.
|
// +100 would normally create both a ScorePulse and a ScoreFloater.
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.score = 100;
|
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
count_with::<ScorePulse>(&mut app),
|
count_with::<ScorePulse>(&mut app),
|
||||||
|
|||||||
@@ -27,13 +27,15 @@ use bevy::window::PrimaryWindow;
|
|||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
use bevy::window::{MonitorSelection, WindowMode};
|
use bevy::window::{MonitorSelection, WindowMode};
|
||||||
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
||||||
use solitaire_core::card::{Card, Suit};
|
use solitaire_core::{Card, Suit};
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
use crate::auto_complete_plugin::AutoCompleteState;
|
use crate::auto_complete_plugin::AutoCompleteState;
|
||||||
use crate::card_animation::tuning::AnimationTuning;
|
use crate::card_animation::tuning::AnimationTuning;
|
||||||
use crate::card_animation::{CardAnimation, MotionCurve};
|
use crate::card_animation::{CardAnimation, MotionCurve};
|
||||||
use crate::card_plugin::{CardEntity, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC};
|
use crate::card_plugin::{
|
||||||
|
CardEntity, CardEntityIndex, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC,
|
||||||
|
};
|
||||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
DrawRequestEvent, ForfeitRequestEvent, HintVisualEvent, InfoToastEvent, MoveRejectedEvent,
|
DrawRequestEvent, ForfeitRequestEvent, HintVisualEvent, InfoToastEvent, MoveRejectedEvent,
|
||||||
@@ -52,7 +54,7 @@ use crate::settings_plugin::SettingsResource;
|
|||||||
use crate::time_attack_plugin::TimeAttackResource;
|
use crate::time_attack_plugin::TimeAttackResource;
|
||||||
use crate::touch_selection_plugin::TouchSelectionState;
|
use crate::touch_selection_plugin::TouchSelectionState;
|
||||||
use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_SUCCESS, STATE_WARNING};
|
use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_SUCCESS, STATE_WARNING};
|
||||||
use solitaire_core::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
|
|
||||||
/// System-set labels used to anchor external systems relative to the touch
|
/// System-set labels used to anchor external systems relative to the touch
|
||||||
/// drag pipeline without duplicating the internal chain ordering.
|
/// drag pipeline without duplicating the internal chain ordering.
|
||||||
@@ -93,8 +95,8 @@ pub struct HintSolverConfig {
|
|||||||
impl Default for HintSolverConfig {
|
impl Default for HintSolverConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
moves_budget: solitaire_data::solver::DEFAULT_SOLVE_MOVES_BUDGET,
|
moves_budget: solitaire_core::DEFAULT_SOLVE_MOVES_BUDGET,
|
||||||
states_budget: solitaire_data::solver::DEFAULT_SOLVE_STATES_BUDGET,
|
states_budget: solitaire_core::DEFAULT_SOLVE_STATES_BUDGET,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,6 +123,10 @@ impl Plugin for InputPlugin {
|
|||||||
.init_resource::<HintSolverConfig>()
|
.init_resource::<HintSolverConfig>()
|
||||||
.init_resource::<crate::pending_hint::PendingHintTask>()
|
.init_resource::<crate::pending_hint::PendingHintTask>()
|
||||||
.init_resource::<GameInputConsumedResource>()
|
.init_resource::<GameInputConsumedResource>()
|
||||||
|
// The drag systems resolve cards via `CardEntityIndex`; `CardPlugin`
|
||||||
|
// owns and rebuilds it, but init here too so `InputPlugin` is
|
||||||
|
// self-sufficient in tests (idempotent if already registered).
|
||||||
|
.init_resource::<CardEntityIndex>()
|
||||||
.add_message::<StartZenRequestEvent>()
|
.add_message::<StartZenRequestEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.add_message::<ForfeitRequestEvent>()
|
.add_message::<ForfeitRequestEvent>()
|
||||||
@@ -674,6 +680,7 @@ fn follow_drag(
|
|||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
tuning: Res<AnimationTuning>,
|
tuning: Res<AnimationTuning>,
|
||||||
mut card_transforms: Query<(&CardEntity, &mut Transform, &mut Sprite)>,
|
mut card_transforms: Query<(&CardEntity, &mut Transform, &mut Sprite)>,
|
||||||
|
card_index: Res<CardEntityIndex>,
|
||||||
) {
|
) {
|
||||||
// Skip if idle or if a touch drag is running.
|
// Skip if idle or if a touch drag is running.
|
||||||
if drag.is_idle() || drag.active_touch_id.is_some() {
|
if drag.is_idle() || drag.active_touch_id.is_some() {
|
||||||
@@ -704,9 +711,8 @@ fn follow_drag(
|
|||||||
// Elevate cards: push to DRAG_Z and dim slightly so the board
|
// Elevate cards: push to DRAG_Z and dim slightly so the board
|
||||||
// beneath stays readable.
|
// beneath stays readable.
|
||||||
for (i, card) in drag.cards.iter().enumerate() {
|
for (i, card) in drag.cards.iter().enumerate() {
|
||||||
if let Some((_, mut transform, mut sprite)) = card_transforms
|
if let Some(entity) = card_index.get(card)
|
||||||
.iter_mut()
|
&& let Ok((_, mut transform, mut sprite)) = card_transforms.get_mut(entity)
|
||||||
.find(|(ce, _, _)| ce.card == *card)
|
|
||||||
{
|
{
|
||||||
transform.translation.z = dragged_card_z(i);
|
transform.translation.z = dragged_card_z(i);
|
||||||
sprite.color.set_alpha(0.85);
|
sprite.color.set_alpha(0.85);
|
||||||
@@ -719,9 +725,8 @@ fn follow_drag(
|
|||||||
let fan = -layout.0.card_size.y * layout.0.tableau_fan_frac;
|
let fan = -layout.0.card_size.y * layout.0.tableau_fan_frac;
|
||||||
|
|
||||||
for (i, card) in drag.cards.iter().enumerate() {
|
for (i, card) in drag.cards.iter().enumerate() {
|
||||||
if let Some((_, mut transform, _)) = card_transforms
|
if let Some(entity) = card_index.get(card)
|
||||||
.iter_mut()
|
&& let Ok((_, mut transform, _)) = card_transforms.get_mut(entity)
|
||||||
.find(|(ce, _, _)| ce.card == *card)
|
|
||||||
{
|
{
|
||||||
transform.translation.x = bottom_pos.x;
|
transform.translation.x = bottom_pos.x;
|
||||||
transform.translation.y = bottom_pos.y + fan * i as f32;
|
transform.translation.y = bottom_pos.y + fan * i as f32;
|
||||||
@@ -743,6 +748,7 @@ fn end_drag(
|
|||||||
mut changed: MessageWriter<StateChangedEvent>,
|
mut changed: MessageWriter<StateChangedEvent>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||||
|
card_index: Res<CardEntityIndex>,
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
drag.clear();
|
drag.clear();
|
||||||
@@ -830,9 +836,8 @@ fn end_drag(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
|
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
|
||||||
if let Some((entity, _, transform)) = card_entities
|
if let Some(entity) = card_index.get(card)
|
||||||
.iter()
|
&& let Ok((_, _, transform)) = card_entities.get(entity)
|
||||||
.find(|(_, ce, _)| ce.card == *card)
|
|
||||||
{
|
{
|
||||||
let drag_pos = transform.translation.truncate();
|
let drag_pos = transform.translation.truncate();
|
||||||
let drag_z = transform.translation.z;
|
let drag_z = transform.translation.z;
|
||||||
@@ -930,6 +935,7 @@ fn touch_follow_drag(
|
|||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
tuning: Res<AnimationTuning>,
|
tuning: Res<AnimationTuning>,
|
||||||
mut card_transforms: Query<(&CardEntity, &mut Transform, &mut Sprite)>,
|
mut card_transforms: Query<(&CardEntity, &mut Transform, &mut Sprite)>,
|
||||||
|
card_index: Res<CardEntityIndex>,
|
||||||
) {
|
) {
|
||||||
let Some(active_id) = drag.active_touch_id else {
|
let Some(active_id) = drag.active_touch_id else {
|
||||||
return; // Mouse drag or idle.
|
return; // Mouse drag or idle.
|
||||||
@@ -957,9 +963,8 @@ fn touch_follow_drag(
|
|||||||
drag.committed = true;
|
drag.committed = true;
|
||||||
|
|
||||||
for (i, card) in drag.cards.iter().enumerate() {
|
for (i, card) in drag.cards.iter().enumerate() {
|
||||||
if let Some((_, mut transform, mut sprite)) = card_transforms
|
if let Some(entity) = card_index.get(card)
|
||||||
.iter_mut()
|
&& let Ok((_, mut transform, mut sprite)) = card_transforms.get_mut(entity)
|
||||||
.find(|(ce, _, _)| ce.card == *card)
|
|
||||||
{
|
{
|
||||||
transform.translation.z = dragged_card_z(i);
|
transform.translation.z = dragged_card_z(i);
|
||||||
sprite.color.set_alpha(0.85);
|
sprite.color.set_alpha(0.85);
|
||||||
@@ -971,9 +976,8 @@ fn touch_follow_drag(
|
|||||||
let fan = -layout.0.card_size.y * layout.0.tableau_fan_frac;
|
let fan = -layout.0.card_size.y * layout.0.tableau_fan_frac;
|
||||||
|
|
||||||
for (i, card) in drag.cards.iter().enumerate() {
|
for (i, card) in drag.cards.iter().enumerate() {
|
||||||
if let Some((_, mut transform, _)) = card_transforms
|
if let Some(entity) = card_index.get(card)
|
||||||
.iter_mut()
|
&& let Ok((_, mut transform, _)) = card_transforms.get_mut(entity)
|
||||||
.find(|(ce, _, _)| ce.card == *card)
|
|
||||||
{
|
{
|
||||||
transform.translation.x = bottom_pos.x;
|
transform.translation.x = bottom_pos.x;
|
||||||
transform.translation.y = bottom_pos.y + fan * i as f32;
|
transform.translation.y = bottom_pos.y + fan * i as f32;
|
||||||
@@ -998,6 +1002,7 @@ fn touch_end_drag(
|
|||||||
mut changed: MessageWriter<StateChangedEvent>,
|
mut changed: MessageWriter<StateChangedEvent>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||||
|
card_index: Res<CardEntityIndex>,
|
||||||
) {
|
) {
|
||||||
let Some(active_id) = drag.active_touch_id else {
|
let Some(active_id) = drag.active_touch_id else {
|
||||||
return; // Mouse drag or idle.
|
return; // Mouse drag or idle.
|
||||||
@@ -1070,9 +1075,8 @@ fn touch_end_drag(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
|
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
|
||||||
if let Some((entity, _, transform)) = card_entities
|
if let Some(entity) = card_index.get(card)
|
||||||
.iter()
|
&& let Ok((_, _, transform)) = card_entities.get(entity)
|
||||||
.find(|(_, ce, _)| ce.card == *card)
|
|
||||||
{
|
{
|
||||||
let drag_pos = transform.translation.truncate();
|
let drag_pos = transform.translation.truncate();
|
||||||
let drag_z = transform.translation.z;
|
let drag_z = transform.translation.z;
|
||||||
@@ -1169,7 +1173,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() == DrawStockConfig::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.
|
||||||
@@ -1826,7 +1830,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::layout::compute_layout;
|
use crate::layout::compute_layout;
|
||||||
use solitaire_core::{Foundation, Tableau};
|
use solitaire_core::{Foundation, Tableau};
|
||||||
use solitaire_core::{DrawMode, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
|
||||||
fn clear_test_piles(game: &mut GameState) {
|
fn clear_test_piles(game: &mut GameState) {
|
||||||
game.set_test_stock_cards(Vec::new());
|
game.set_test_stock_cards(Vec::new());
|
||||||
@@ -1894,7 +1898,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn find_draggable_picks_top_of_tableau() {
|
fn find_draggable_picks_top_of_tableau() {
|
||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
|
||||||
// In tableau 6, the visually topmost card is the last (face-up) one.
|
// In tableau 6, the visually topmost card is the last (face-up) one.
|
||||||
@@ -1908,7 +1912,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn find_draggable_skips_face_down_cards() {
|
fn find_draggable_skips_face_down_cards() {
|
||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
|
||||||
// Tableau 6 has 7 cards: 6 face-down (indices 0..5) + 1 face-up at
|
// Tableau 6 has 7 cards: 6 face-down (indices 0..5) + 1 face-up at
|
||||||
@@ -1930,7 +1934,7 @@ mod tests {
|
|||||||
// at 0.12 — so for any column with face-down cards above the
|
// at 0.12 — so for any column with face-down cards above the
|
||||||
// face-up bottom card, clicking the visible card face missed the
|
// face-up bottom card, clicking the visible card face missed the
|
||||||
// hit-test box and only the bottom strip of the card responded.
|
// hit-test box and only the bottom strip of the card responded.
|
||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
|
||||||
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
|
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
|
||||||
@@ -1948,9 +1952,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn find_draggable_returns_run_when_picking_mid_stack() {
|
fn find_draggable_returns_run_when_picking_mid_stack() {
|
||||||
// Manually construct a tableau with three face-up cards all stacked.
|
// Manually construct a tableau with three face-up cards all stacked.
|
||||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
use solitaire_core::card::Deck as D;
|
use solitaire_core::Deck as D;
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
let king = Card::new(D::Deck1, Suit::Spades, Rank::King);
|
let king = Card::new(D::Deck1, Suit::Spades, Rank::King);
|
||||||
let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen);
|
let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen);
|
||||||
let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack);
|
let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack);
|
||||||
@@ -1975,9 +1979,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn find_draggable_skips_non_top_waste_card() {
|
fn find_draggable_skips_non_top_waste_card() {
|
||||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
use solitaire_core::card::Deck as D;
|
use solitaire_core::Deck as D;
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
|
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
|
||||||
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
|
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
|
||||||
game.set_test_waste_cards(vec![two_spades, three_hearts.clone()]);
|
game.set_test_waste_cards(vec![two_spades, three_hearts.clone()]);
|
||||||
@@ -1994,7 +1998,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn find_drop_target_hits_empty_tableau_pile_marker() {
|
fn find_drop_target_hits_empty_tableau_pile_marker() {
|
||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
// Move all cards out of tableau 0 so its marker is the only drop area.
|
// Move all cards out of tableau 0 so its marker is the only drop area.
|
||||||
let mut game = game;
|
let mut game = game;
|
||||||
@@ -2011,7 +2015,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn find_drop_target_returns_none_for_origin() {
|
fn find_drop_target_returns_none_for_origin() {
|
||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau4)];
|
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau4)];
|
||||||
let target = find_drop_target(
|
let target = find_drop_target(
|
||||||
@@ -2025,7 +2029,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pile_drop_rect_extends_for_tableau_with_cards() {
|
fn pile_drop_rect_extends_for_tableau_with_cards() {
|
||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
// Tableau 6 has 7 cards.
|
// Tableau 6 has 7 cards.
|
||||||
let (_, size) = pile_drop_rect(&KlondikePile::Tableau(Tableau::Tableau7), &layout, &game);
|
let (_, size) = pile_drop_rect(&KlondikePile::Tableau(Tableau::Tableau7), &layout, &game);
|
||||||
@@ -2040,10 +2044,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() {
|
fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() {
|
||||||
use solitaire_core::card::Deck as D;
|
use solitaire_core::Deck as D;
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
use solitaire_core::{DrawMode, game_state::GameMode};
|
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||||
let mut game = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Classic);
|
let mut game = GameState::new_with_mode(1, DrawStockConfig::DrawThree, GameMode::Classic);
|
||||||
// Three waste cards; top (four_clubs) is rightmost in the fan.
|
// Three waste cards; top (four_clubs) is rightmost in the fan.
|
||||||
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
|
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
|
||||||
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
|
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
|
||||||
@@ -2068,7 +2072,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn find_draggable_returns_none_for_click_on_empty_pile() {
|
fn find_draggable_returns_none_for_click_on_empty_pile() {
|
||||||
let mut game = GameState::new(42, DrawMode::DrawOne);
|
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
// Clear tableau 0 so it's an empty slot.
|
// Clear tableau 0 so it's an empty slot.
|
||||||
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
|
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
|
||||||
@@ -2082,7 +2086,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pile_drop_rect_is_card_sized_for_non_tableau() {
|
fn pile_drop_rect_is_card_sized_for_non_tableau() {
|
||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
for pile in [
|
for pile in [
|
||||||
KlondikePile::Stock,
|
KlondikePile::Stock,
|
||||||
@@ -2099,9 +2103,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn best_destination_returns_none_when_no_legal_move() {
|
fn best_destination_returns_none_when_no_legal_move() {
|
||||||
use solitaire_core::card::Deck as D;
|
use solitaire_core::Deck as D;
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
// Clear everything except one card that has nowhere to go.
|
// Clear everything except one card that has nowhere to go.
|
||||||
clear_test_piles(&mut game);
|
clear_test_piles(&mut game);
|
||||||
@@ -2117,9 +2121,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn best_tableau_destination_for_stack_skips_source_pile() {
|
fn best_tableau_destination_for_stack_skips_source_pile() {
|
||||||
use solitaire_core::card::Deck as D;
|
use solitaire_core::Deck as D;
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
clear_test_piles(&mut game);
|
clear_test_piles(&mut game);
|
||||||
|
|
||||||
@@ -2143,9 +2147,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn best_tableau_destination_for_stack_returns_none_when_no_legal_move() {
|
fn best_tableau_destination_for_stack_returns_none_when_no_legal_move() {
|
||||||
use solitaire_core::card::Deck as D;
|
use solitaire_core::Deck as D;
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
clear_test_piles(&mut game);
|
clear_test_piles(&mut game);
|
||||||
|
|
||||||
@@ -2172,9 +2176,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn find_hint_finds_ace_to_foundation() {
|
fn find_hint_finds_ace_to_foundation() {
|
||||||
use solitaire_core::card::Deck as D;
|
use solitaire_core::Deck as D;
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
// Place Ace of Clubs on top of tableau 0.
|
// Place Ace of Clubs on top of tableau 0.
|
||||||
clear_test_piles(&mut game);
|
clear_test_piles(&mut game);
|
||||||
@@ -2216,9 +2220,9 @@ mod tests {
|
|||||||
/// are no other moves and the stock is non-empty.
|
/// are no other moves and the stock is non-empty.
|
||||||
#[test]
|
#[test]
|
||||||
fn all_hints_suggests_draw_when_no_moves_and_stock_nonempty() {
|
fn all_hints_suggests_draw_when_no_moves_and_stock_nonempty() {
|
||||||
use solitaire_core::card::Deck as D;
|
use solitaire_core::Deck as D;
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
// Remove all foundation, tableau, and waste cards so no pile-to-pile
|
// Remove all foundation, tableau, and waste cards so no pile-to-pile
|
||||||
// move exists. Leave one card in the stock.
|
// move exists. Leave one card in the stock.
|
||||||
@@ -2269,8 +2273,8 @@ mod tests {
|
|||||||
/// gets a CardAnimation" — same coverage, new component.
|
/// gets a CardAnimation" — same coverage, new component.
|
||||||
#[test]
|
#[test]
|
||||||
fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
|
fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
|
||||||
use solitaire_core::card::Deck as D;
|
use solitaire_core::Deck as D;
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
// Simulate a stack drag of two cards.
|
// Simulate a stack drag of two cards.
|
||||||
let dragged_cards: Vec<Card> = vec![
|
let dragged_cards: Vec<Card> = vec![
|
||||||
Card::new(D::Deck1, Suit::Hearts, Rank::King),
|
Card::new(D::Deck1, Suit::Hearts, Rank::King),
|
||||||
@@ -2427,7 +2431,7 @@ mod tests {
|
|||||||
app.insert_resource(crate::layout::LayoutResource(
|
app.insert_resource(crate::layout::LayoutResource(
|
||||||
crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true),
|
crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true),
|
||||||
));
|
));
|
||||||
app.insert_resource(GameStateResource(GameState::new(42, DrawMode::DrawOne)));
|
app.insert_resource(GameStateResource(GameState::new(42, DrawStockConfig::DrawOne)));
|
||||||
app.add_systems(Update, handle_keyboard_hint);
|
app.add_systems(Update, handle_keyboard_hint);
|
||||||
|
|
||||||
// Simulate the H key being pressed this frame.
|
// Simulate the H key being pressed this frame.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
//! active opens the overlay as normal.
|
//! active opens the overlay as normal.
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
use solitaire_data::save_game_state_to;
|
use solitaire_data::save_game_state_to;
|
||||||
|
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
@@ -86,10 +86,10 @@ struct ForfeitConfirmButton;
|
|||||||
/// Returns the human-readable label for a draw mode.
|
/// Returns the human-readable label for a draw mode.
|
||||||
///
|
///
|
||||||
/// Used on the pause overlay draw-mode toggle button.
|
/// Used on the pause overlay draw-mode toggle button.
|
||||||
pub fn draw_mode_label(mode: DrawMode) -> &'static str {
|
pub fn draw_mode_label(mode: DrawStockConfig) -> &'static str {
|
||||||
match mode {
|
match mode {
|
||||||
DrawMode::DrawOne => "Draw 1",
|
DrawStockConfig::DrawOne => "Draw 1",
|
||||||
DrawMode::DrawThree => "Draw 3",
|
DrawStockConfig::DrawThree => "Draw 3",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,9 +273,9 @@ fn handle_pause_draw_buttons(
|
|||||||
}
|
}
|
||||||
let Some(mut settings) = settings else { return };
|
let Some(mut settings) = settings else { return };
|
||||||
let new_mode = if pressed_one {
|
let new_mode = if pressed_one {
|
||||||
DrawMode::DrawOne
|
DrawStockConfig::DrawOne
|
||||||
} else {
|
} else {
|
||||||
DrawMode::DrawThree
|
DrawStockConfig::DrawThree
|
||||||
};
|
};
|
||||||
if settings.0.draw_mode == new_mode {
|
if settings.0.draw_mode == new_mode {
|
||||||
return;
|
return;
|
||||||
@@ -477,7 +477,7 @@ fn spawn_pause_screen(
|
|||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
level: Option<u32>,
|
level: Option<u32>,
|
||||||
streak: Option<u32>,
|
streak: Option<u32>,
|
||||||
draw_mode: Option<DrawMode>,
|
draw_mode: Option<DrawStockConfig>,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
) {
|
) {
|
||||||
spawn_modal(commands, PauseScreen, ui_theme::Z_PAUSE, |card| {
|
spawn_modal(commands, PauseScreen, ui_theme::Z_PAUSE, |card| {
|
||||||
@@ -516,7 +516,7 @@ fn spawn_pause_screen(
|
|||||||
/// `Tertiary` (recessed), giving an obvious selection state at a glance.
|
/// `Tertiary` (recessed), giving an obvious selection state at a glance.
|
||||||
fn spawn_draw_mode_row(
|
fn spawn_draw_mode_row(
|
||||||
parent: &mut ChildSpawnerCommands,
|
parent: &mut ChildSpawnerCommands,
|
||||||
mode: DrawMode,
|
mode: DrawStockConfig,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
) {
|
) {
|
||||||
let label_font = TextFont {
|
let label_font = TextFont {
|
||||||
@@ -530,8 +530,8 @@ fn spawn_draw_mode_row(
|
|||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
let (one_variant, three_variant) = match mode {
|
let (one_variant, three_variant) = match mode {
|
||||||
DrawMode::DrawOne => (ButtonVariant::Secondary, ButtonVariant::Tertiary),
|
DrawStockConfig::DrawOne => (ButtonVariant::Secondary, ButtonVariant::Tertiary),
|
||||||
DrawMode::DrawThree => (ButtonVariant::Tertiary, ButtonVariant::Secondary),
|
DrawStockConfig::DrawThree => (ButtonVariant::Tertiary, ButtonVariant::Secondary),
|
||||||
};
|
};
|
||||||
parent
|
parent
|
||||||
.spawn(Node {
|
.spawn(Node {
|
||||||
@@ -800,20 +800,20 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn draw_mode_label_draw_one() {
|
fn draw_mode_label_draw_one() {
|
||||||
assert_eq!(draw_mode_label(DrawMode::DrawOne), "Draw 1");
|
assert_eq!(draw_mode_label(DrawStockConfig::DrawOne), "Draw 1");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn draw_mode_label_draw_three() {
|
fn draw_mode_label_draw_three() {
|
||||||
assert_eq!(draw_mode_label(DrawMode::DrawThree), "Draw 3");
|
assert_eq!(draw_mode_label(DrawStockConfig::DrawThree), "Draw 3");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Both variants are covered so the match is exhaustive — this test would
|
/// Both variants are covered so the match is exhaustive — this test would
|
||||||
/// fail to compile if a new DrawMode variant were added without updating
|
/// fail to compile if a new DrawStockConfig variant were added without updating
|
||||||
/// `draw_mode_label`.
|
/// `draw_mode_label`.
|
||||||
#[test]
|
#[test]
|
||||||
fn draw_mode_label_covers_all_variants() {
|
fn draw_mode_label_covers_all_variants() {
|
||||||
for mode in [DrawMode::DrawOne, DrawMode::DrawThree] {
|
for mode in [DrawStockConfig::DrawOne, DrawStockConfig::DrawThree] {
|
||||||
let label = draw_mode_label(mode);
|
let label = draw_mode_label(mode);
|
||||||
assert!(
|
assert!(
|
||||||
!label.is_empty(),
|
!label.is_empty(),
|
||||||
@@ -842,7 +842,7 @@ mod tests {
|
|||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<SettingsResource>()
|
.resource_mut::<SettingsResource>()
|
||||||
.0
|
.0
|
||||||
.draw_mode = DrawMode::DrawOne;
|
.draw_mode = DrawStockConfig::DrawOne;
|
||||||
|
|
||||||
// Set paused so handle_pause_draw_toggle acts.
|
// Set paused so handle_pause_draw_toggle acts.
|
||||||
app.world_mut().resource_mut::<PausedResource>().0 = true;
|
app.world_mut().resource_mut::<PausedResource>().0 = true;
|
||||||
@@ -856,7 +856,7 @@ mod tests {
|
|||||||
let mode = &app.world().resource::<SettingsResource>().0.draw_mode;
|
let mode = &app.world().resource::<SettingsResource>().0.draw_mode;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
*mode,
|
*mode,
|
||||||
DrawMode::DrawThree,
|
DrawStockConfig::DrawThree,
|
||||||
"pressing Draw 3 must set mode to DrawThree"
|
"pressing Draw 3 must set mode to DrawThree"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -869,7 +869,7 @@ mod tests {
|
|||||||
let mode2 = &app.world().resource::<SettingsResource>().0.draw_mode;
|
let mode2 = &app.world().resource::<SettingsResource>().0.draw_mode;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
*mode2,
|
*mode2,
|
||||||
DrawMode::DrawOne,
|
DrawStockConfig::DrawOne,
|
||||||
"pressing Draw 1 must set mode to DrawOne"
|
"pressing Draw 1 must set mode to DrawOne"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -965,11 +965,11 @@ mod tests {
|
|||||||
/// Provides a fresh `GameStateResource` (not won) so the modal can
|
/// Provides a fresh `GameStateResource` (not won) so the modal can
|
||||||
/// open. `move_count` doesn't matter — the gate is just `!is_won`.
|
/// open. `move_count` doesn't matter — the gate is just `!is_won`.
|
||||||
fn forfeit_app() -> App {
|
fn forfeit_app() -> App {
|
||||||
use solitaire_core::{DrawMode, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
|
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
|
||||||
app.init_resource::<ButtonInput<KeyCode>>();
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
|
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
|
||||||
app.update();
|
app.update();
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
@@ -1020,11 +1020,11 @@ mod tests {
|
|||||||
/// hotkey was received but is currently a no-op.
|
/// hotkey was received but is currently a no-op.
|
||||||
#[test]
|
#[test]
|
||||||
fn forfeit_request_emits_toast_and_skips_modal_when_game_is_won() {
|
fn forfeit_request_emits_toast_and_skips_modal_when_game_is_won() {
|
||||||
use solitaire_core::{DrawMode, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
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, DrawStockConfig::DrawOne);
|
||||||
game.set_test_won(true);
|
game.set_test_won(true);
|
||||||
app.insert_resource(GameStateResource(game));
|
app.insert_resource(GameStateResource(game));
|
||||||
app.update();
|
app.update();
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ use bevy::prelude::*;
|
|||||||
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
||||||
use solitaire_core::KlondikeInstruction;
|
use solitaire_core::KlondikeInstruction;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
use solitaire_data::solver::try_solve_from_state;
|
|
||||||
|
|
||||||
use crate::card_plugin::CardEntity;
|
use crate::card_plugin::CardEntity;
|
||||||
use crate::events::{HintVisualEvent, InfoToastEvent, StateChangedEvent};
|
use crate::events::{HintVisualEvent, InfoToastEvent, StateChangedEvent};
|
||||||
@@ -66,7 +65,7 @@ impl PendingHintTask {
|
|||||||
// Winnable (`Ok(Some)`) carries the first move on a winning path;
|
// Winnable (`Ok(Some)`) carries the first move on a winning path;
|
||||||
// unwinnable (`Ok(None)`) and inconclusive (`Err`) both fall back
|
// unwinnable (`Ok(None)`) and inconclusive (`Err`) both fall back
|
||||||
// to the live-state heuristic so H always produces feedback.
|
// to the live-state heuristic so H always produces feedback.
|
||||||
match try_solve_from_state(&state, moves_budget, states_budget) {
|
match state.solve_first_move(moves_budget, states_budget) {
|
||||||
Ok(Some(first_move)) => HintTaskOutput::SolverMove(first_move),
|
Ok(Some(first_move)) => HintTaskOutput::SolverMove(first_move),
|
||||||
Ok(None) | Err(_) => HintTaskOutput::NeedsHeuristic,
|
Ok(None) | Err(_) => HintTaskOutput::NeedsHeuristic,
|
||||||
}
|
}
|
||||||
@@ -180,8 +179,8 @@ mod tests {
|
|||||||
use crate::events::HintVisualEvent;
|
use crate::events::HintVisualEvent;
|
||||||
use crate::input_plugin::HintSolverConfig;
|
use crate::input_plugin::HintSolverConfig;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
use solitaire_core::card::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
use solitaire_core::{DrawMode, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
|
||||||
/// Build a minimal Bevy app exercising only the polling system
|
/// Build a minimal Bevy app exercising only the polling system
|
||||||
/// and the resources/messages it touches.
|
/// and the resources/messages it touches.
|
||||||
@@ -210,7 +209,7 @@ mod tests {
|
|||||||
/// foundations hold A..Q for each suit, four Kings sit on
|
/// foundations hold A..Q for each suit, four Kings sit on
|
||||||
/// tableau columns 0..3, stock and waste empty.
|
/// tableau columns 0..3, stock and waste empty.
|
||||||
fn near_finished_state() -> GameState {
|
fn near_finished_state() -> GameState {
|
||||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
game.set_test_stock_cards(Vec::new());
|
game.set_test_stock_cards(Vec::new());
|
||||||
game.set_test_waste_cards(Vec::new());
|
game.set_test_waste_cards(Vec::new());
|
||||||
for foundation in [
|
for foundation in [
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
//! 3. `handle_text_input` appends decimal digits / handles Backspace while
|
//! 3. `handle_text_input` appends decimal digits / handles Backspace while
|
||||||
//! the modal is open, updating [`SeedInputBuffer`] each frame.
|
//! the modal is open, updating [`SeedInputBuffer`] each frame.
|
||||||
//! 4. `tick_debounce_and_spawn_solver_task` waits for 12 frames (~200 ms at
|
//! 4. `tick_debounce_and_spawn_solver_task` waits for 12 frames (~200 ms at
|
||||||
//! 60 Hz) of no input before spawning a [`try_solve`] task on
|
//! 60 Hz) of no input before spawning a [`GameState::solve_fresh_deal`] task on
|
||||||
//! [`AsyncComputeTaskPool`]. Any fresh keypress drops the in-flight task
|
//! [`AsyncComputeTaskPool`]. Any fresh keypress drops the in-flight task
|
||||||
//! by resetting the resource.
|
//! by resetting the resource.
|
||||||
//! 5. `poll_solver_task` polls the in-flight task each frame and updates the
|
//! 5. `poll_solver_task` polls the in-flight task each frame and updates the
|
||||||
@@ -23,10 +23,9 @@
|
|||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
||||||
use solitaire_core::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
use solitaire_data::solver::{
|
use solitaire_core::game_state::GameState;
|
||||||
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome, try_solve,
|
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
|
||||||
};
|
|
||||||
|
|
||||||
use crate::events::{NewGameRequestEvent, StartPlayBySeedRequestEvent};
|
use crate::events::{NewGameRequestEvent, StartPlayBySeedRequestEvent};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
@@ -341,9 +340,9 @@ fn tick_debounce_and_spawn_solver_task(
|
|||||||
|
|
||||||
let draw_mode = settings
|
let draw_mode = settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or(DrawMode::DrawOne, |s| s.0.draw_mode);
|
.map_or(DrawStockConfig::DrawOne, |s| s.0.draw_mode);
|
||||||
let task = AsyncComputeTaskPool::get().spawn(async move {
|
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||||
try_solve(
|
GameState::solve_fresh_deal(
|
||||||
seed,
|
seed,
|
||||||
draw_mode,
|
draw_mode,
|
||||||
DEFAULT_SOLVE_MOVES_BUDGET,
|
DEFAULT_SOLVE_MOVES_BUDGET,
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ fn award_xp_on_win(
|
|||||||
mut progress: ResMut<ProgressResource>,
|
mut progress: ResMut<ProgressResource>,
|
||||||
) {
|
) {
|
||||||
for ev in wins.read() {
|
for ev in wins.read() {
|
||||||
let used_undo = game.0.undo_count > 0;
|
let used_undo = game.0.undo_count() > 0;
|
||||||
let amount = xp_for_win(ev.time_seconds, used_undo);
|
let amount = xp_for_win(ev.time_seconds, used_undo);
|
||||||
let prev_level = progress.0.add_xp(amount);
|
let prev_level = progress.0.add_xp(amount);
|
||||||
xp_awarded.write(XpAwardedEvent { amount });
|
xp_awarded.write(XpAwardedEvent { amount });
|
||||||
@@ -151,7 +151,7 @@ mod tests {
|
|||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<GameStateResource>()
|
.resource_mut::<GameStateResource>()
|
||||||
.0
|
.0
|
||||||
.undo_count = 1;
|
.force_test_undos(1);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ use bevy::math::Vec2;
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::PrimaryWindow;
|
use bevy::window::PrimaryWindow;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
use solitaire_core::card::Card;
|
use solitaire_core::Card;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC;
|
use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC;
|
||||||
@@ -113,9 +113,9 @@ pub enum RightClickRadialState {
|
|||||||
/// radial is built around single-card foundation/tableau
|
/// radial is built around single-card foundation/tableau
|
||||||
/// shortcuts and that matches the right-click highlight set).
|
/// shortcuts and that matches the right-click highlight set).
|
||||||
count: usize,
|
count: usize,
|
||||||
/// Card ids that would be moved (bottom-to-top order). Length
|
/// Cards that would be moved (bottom-to-top order). Length
|
||||||
/// always equals `count`. Currently always one element.
|
/// always equals `count`. Currently always one element.
|
||||||
cards: Vec<u32>,
|
cards: Vec<Card>,
|
||||||
/// Pre-computed `(destination, icon_anchor_world_pos)` pairs.
|
/// Pre-computed `(destination, icon_anchor_world_pos)` pairs.
|
||||||
///
|
///
|
||||||
/// Anchors are evenly spaced around a ring of radius
|
/// Anchors are evenly spaced around a ring of radius
|
||||||
@@ -359,7 +359,6 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
use solitaire_core::card::card_to_id;
|
|
||||||
|
|
||||||
const fn foundations() -> [Foundation; 4] {
|
const fn foundations() -> [Foundation; 4] {
|
||||||
[
|
[
|
||||||
@@ -500,7 +499,7 @@ fn radial_open_on_right_click(
|
|||||||
*state = RightClickRadialState::Active {
|
*state = RightClickRadialState::Active {
|
||||||
source_pile,
|
source_pile,
|
||||||
count: 1,
|
count: 1,
|
||||||
cards: vec![card_to_id(&card)],
|
cards: vec![card.clone()],
|
||||||
legal_destinations,
|
legal_destinations,
|
||||||
centre: world,
|
centre: world,
|
||||||
hovered_index: None,
|
hovered_index: None,
|
||||||
@@ -573,7 +572,7 @@ fn radial_open_on_long_press(
|
|||||||
*state = RightClickRadialState::Active {
|
*state = RightClickRadialState::Active {
|
||||||
source_pile,
|
source_pile,
|
||||||
count: 1,
|
count: 1,
|
||||||
cards: vec![card_to_id(&card)],
|
cards: vec![card.clone()],
|
||||||
legal_destinations,
|
legal_destinations,
|
||||||
centre: world,
|
centre: world,
|
||||||
hovered_index: None,
|
hovered_index: None,
|
||||||
@@ -796,8 +795,8 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::layout::compute_layout;
|
use crate::layout::compute_layout;
|
||||||
use bevy::ecs::message::Messages;
|
use bevy::ecs::message::Messages;
|
||||||
use solitaire_core::card::{Card as CoreCard, Deck, Rank, Suit};
|
use solitaire_core::{Card as CoreCard, Deck, Rank, Suit};
|
||||||
use solitaire_core::{DrawMode, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
|
||||||
/// Build a minimal Bevy app wired with `RadialMenuPlugin` and the
|
/// Build a minimal Bevy app wired with `RadialMenuPlugin` and the
|
||||||
/// resources / messages it depends on. No window, no camera — the
|
/// resources / messages it depends on. No window, no camera — the
|
||||||
@@ -820,7 +819,7 @@ mod tests {
|
|||||||
/// destination — Foundation(0) — under the standard rules
|
/// destination — Foundation(0) — under the standard rules
|
||||||
/// (`can_place_on_foundation` accepts the Ace on an empty foundation).
|
/// (`can_place_on_foundation` accepts the Ace on an empty foundation).
|
||||||
fn ace_only_state() -> GameState {
|
fn ace_only_state() -> GameState {
|
||||||
let mut g = GameState::new(0, DrawMode::DrawOne);
|
let mut g = GameState::new(0, DrawStockConfig::DrawOne);
|
||||||
// Wipe everything.
|
// Wipe everything.
|
||||||
g.set_test_stock_cards(Vec::new());
|
g.set_test_stock_cards(Vec::new());
|
||||||
g.set_test_waste_cards(Vec::new());
|
g.set_test_waste_cards(Vec::new());
|
||||||
@@ -854,7 +853,7 @@ mod tests {
|
|||||||
/// Place a face-down King on Tableau(0). `find_top_face_up_card_at`
|
/// Place a face-down King on Tableau(0). `find_top_face_up_card_at`
|
||||||
/// must skip it.
|
/// must skip it.
|
||||||
fn face_down_only_state() -> GameState {
|
fn face_down_only_state() -> GameState {
|
||||||
let mut g = GameState::new(0, DrawMode::DrawOne);
|
let mut g = GameState::new(0, DrawStockConfig::DrawOne);
|
||||||
g.set_test_stock_cards(Vec::new());
|
g.set_test_stock_cards(Vec::new());
|
||||||
g.set_test_waste_cards(Vec::new());
|
g.set_test_waste_cards(Vec::new());
|
||||||
for foundation in [
|
for foundation in [
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use super::ReplayPlaybackState;
|
use super::ReplayPlaybackState;
|
||||||
use chrono::Datelike;
|
use chrono::Datelike;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
use solitaire_core::klondike_adapter::SavedKlondikePile;
|
use solitaire_core::klondike_adapter::SavedKlondikePile;
|
||||||
use solitaire_data::ReplayMove;
|
use solitaire_data::ReplayMove;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
use solitaire_core::card::{Rank, Suit};
|
use solitaire_core::{Rank, Suit};
|
||||||
use solitaire_core::{DrawMode, game_state::GameMode};
|
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||||
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
|
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
|
||||||
use solitaire_data::{Replay, ReplayMove};
|
use solitaire_data::{Replay, ReplayMove};
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ use solitaire_data::{Replay, ReplayMove};
|
|||||||
fn synthetic_replay(move_count: usize) -> Replay {
|
fn synthetic_replay(move_count: usize) -> Replay {
|
||||||
Replay::new(
|
Replay::new(
|
||||||
42,
|
42,
|
||||||
DrawMode::DrawOne,
|
DrawStockConfig::DrawOne,
|
||||||
GameMode::Classic,
|
GameMode::Classic,
|
||||||
120,
|
120,
|
||||||
1_000,
|
1_000,
|
||||||
@@ -2314,7 +2314,7 @@ fn format_suit_glyph_all_suits() {
|
|||||||
fn format_foundations_row_empty_board() {
|
fn format_foundations_row_empty_board() {
|
||||||
let game = solitaire_core::game_state::GameState::new_with_mode(
|
let game = solitaire_core::game_state::GameState::new_with_mode(
|
||||||
42,
|
42,
|
||||||
solitaire_core::DrawMode::DrawOne,
|
solitaire_core::DrawStockConfig::DrawOne,
|
||||||
solitaire_core::game_state::GameMode::Classic,
|
solitaire_core::game_state::GameMode::Classic,
|
||||||
);
|
);
|
||||||
assert_eq!(format_foundations_row(&game), "F: -- -- -- --");
|
assert_eq!(format_foundations_row(&game), "F: -- -- -- --");
|
||||||
@@ -2326,7 +2326,7 @@ fn format_foundations_row_empty_board() {
|
|||||||
fn format_stock_waste_row_initial_state() {
|
fn format_stock_waste_row_initial_state() {
|
||||||
let game = solitaire_core::game_state::GameState::new_with_mode(
|
let game = solitaire_core::game_state::GameState::new_with_mode(
|
||||||
42,
|
42,
|
||||||
solitaire_core::DrawMode::DrawOne,
|
solitaire_core::DrawStockConfig::DrawOne,
|
||||||
solitaire_core::game_state::GameMode::Classic,
|
solitaire_core::game_state::GameMode::Classic,
|
||||||
);
|
);
|
||||||
let text = format_stock_waste_row(&game);
|
let text = format_stock_waste_row(&game);
|
||||||
|
|||||||
@@ -556,7 +556,7 @@ mod tests {
|
|||||||
use bevy::time::TimeUpdateStrategy;
|
use bevy::time::TimeUpdateStrategy;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use solitaire_core::{KlondikePile, Tableau};
|
use solitaire_core::{KlondikePile, Tableau};
|
||||||
use solitaire_core::{DrawMode, game_state::GameMode};
|
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||||
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
|
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -598,7 +598,7 @@ mod tests {
|
|||||||
fn sample_replay_three_moves() -> Replay {
|
fn sample_replay_three_moves() -> Replay {
|
||||||
Replay::new(
|
Replay::new(
|
||||||
12345,
|
12345,
|
||||||
DrawMode::DrawOne,
|
DrawStockConfig::DrawOne,
|
||||||
GameMode::Classic,
|
GameMode::Classic,
|
||||||
60,
|
60,
|
||||||
500,
|
500,
|
||||||
@@ -771,7 +771,7 @@ mod tests {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
let one_move = Replay::new(
|
let one_move = Replay::new(
|
||||||
42,
|
42,
|
||||||
DrawMode::DrawOne,
|
DrawStockConfig::DrawOne,
|
||||||
GameMode::Classic,
|
GameMode::Classic,
|
||||||
10,
|
10,
|
||||||
100,
|
100,
|
||||||
@@ -880,7 +880,7 @@ mod tests {
|
|||||||
fn ten_draws_replay() -> Replay {
|
fn ten_draws_replay() -> Replay {
|
||||||
Replay::new(
|
Replay::new(
|
||||||
7,
|
7,
|
||||||
DrawMode::DrawOne,
|
DrawStockConfig::DrawOne,
|
||||||
GameMode::Classic,
|
GameMode::Classic,
|
||||||
10,
|
10,
|
||||||
100,
|
100,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use bevy::math::Vec2;
|
|||||||
use bevy::prelude::Resource;
|
use bevy::prelude::Resource;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use solitaire_core::KlondikePile;
|
use solitaire_core::KlondikePile;
|
||||||
use solitaire_core::card::Card;
|
use solitaire_core::Card;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
|
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
use solitaire_core::card::Card;
|
use solitaire_core::Card;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
use crate::card_plugin::CardEntityIndex;
|
use crate::card_plugin::CardEntityIndex;
|
||||||
@@ -534,7 +534,7 @@ fn handle_selection_keys(
|
|||||||
/// destination after a lift. Players who want a different column simply
|
/// destination after a lift. Players who want a different column simply
|
||||||
/// press the right-arrow key once or twice.
|
/// press the right-arrow key once or twice.
|
||||||
pub(crate) fn legal_destinations_for(
|
pub(crate) fn legal_destinations_for(
|
||||||
_bottom: &solitaire_core::card::Card,
|
_bottom: &solitaire_core::Card,
|
||||||
source: &KlondikePile,
|
source: &KlondikePile,
|
||||||
game: &GameState,
|
game: &GameState,
|
||||||
stack_count: usize,
|
stack_count: usize,
|
||||||
@@ -579,7 +579,7 @@ pub(crate) fn legal_destinations_for(
|
|||||||
/// Walks backwards from the last element and stops at the first face-down card
|
/// Walks backwards from the last element and stops at the first face-down card
|
||||||
/// (or when the slice is exhausted). Returns at least `1` when the top card is
|
/// (or when the slice is exhausted). Returns at least `1` when the top card is
|
||||||
/// face-up; returns `0` for an empty slice or when the top card is face-down.
|
/// face-up; returns `0` for an empty slice or when the top card is face-down.
|
||||||
fn face_up_run_len(cards: &[(solitaire_core::card::Card, bool)]) -> usize {
|
fn face_up_run_len(cards: &[(solitaire_core::Card, bool)]) -> usize {
|
||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
for (_, face_up) in cards.iter().rev() {
|
for (_, face_up) in cards.iter().rev() {
|
||||||
if *face_up {
|
if *face_up {
|
||||||
@@ -598,7 +598,7 @@ fn face_up_run_len(cards: &[(solitaire_core::card::Card, bool)]) -> usize {
|
|||||||
/// handler can attempt a foundation move first and fall through to a
|
/// handler can attempt a foundation move first and fall through to a
|
||||||
/// multi-card stack move rather than accepting a single-card tableau move.
|
/// multi-card stack move rather than accepting a single-card tableau move.
|
||||||
fn try_foundation_dest(
|
fn try_foundation_dest(
|
||||||
card: &solitaire_core::card::Card,
|
card: &solitaire_core::Card,
|
||||||
game: &solitaire_core::game_state::GameState,
|
game: &solitaire_core::game_state::GameState,
|
||||||
) -> Option<KlondikePile> {
|
) -> Option<KlondikePile> {
|
||||||
let source = game.pile_containing_card(card.clone())?;
|
let source = game.pile_containing_card(card.clone())?;
|
||||||
@@ -886,7 +886,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn face_up_run_len_all_face_up() {
|
fn face_up_run_len_all_face_up() {
|
||||||
use solitaire_core::card::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
let cards = vec![
|
let cards = vec![
|
||||||
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true),
|
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true),
|
||||||
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), true),
|
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), true),
|
||||||
@@ -897,7 +897,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn face_up_run_len_mixed_stops_at_face_down() {
|
fn face_up_run_len_mixed_stops_at_face_down() {
|
||||||
use solitaire_core::card::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
let cards = vec![
|
let cards = vec![
|
||||||
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), false),
|
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), false),
|
||||||
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false),
|
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false),
|
||||||
@@ -910,7 +910,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn face_up_run_len_top_card_face_down_is_zero() {
|
fn face_up_run_len_top_card_face_down_is_zero() {
|
||||||
use solitaire_core::card::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
let cards = vec![
|
let cards = vec![
|
||||||
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true),
|
(Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true),
|
||||||
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false),
|
(Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false),
|
||||||
@@ -920,7 +920,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn face_up_run_len_single_face_up_card() {
|
fn face_up_run_len_single_face_up_card() {
|
||||||
use solitaire_core::card::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
let cards = vec![(Card::new(Deck::Deck1, Suit::Hearts, Rank::Ace), true)];
|
let cards = vec![(Card::new(Deck::Deck1, Suit::Hearts, Rank::Ace), true)];
|
||||||
assert_eq!(face_up_run_len(&cards), 1);
|
assert_eq!(face_up_run_len(&cards), 1);
|
||||||
}
|
}
|
||||||
@@ -934,8 +934,8 @@ mod tests {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
use bevy::ecs::message::Messages;
|
use bevy::ecs::message::Messages;
|
||||||
use solitaire_core::card::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
use solitaire_core::{DrawMode, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
|
||||||
/// Build a minimal app with `SelectionPlugin` only — no GamePlugin, no
|
/// Build a minimal app with `SelectionPlugin` only — no GamePlugin, no
|
||||||
/// AssetServer. The `MoveRequestEvent` / `StateChangedEvent` /
|
/// AssetServer. The `MoveRequestEvent` / `StateChangedEvent` /
|
||||||
@@ -968,7 +968,7 @@ mod tests {
|
|||||||
/// Ace first). It cannot go to an empty tableau (only Kings).
|
/// Ace first). It cannot go to an empty tableau (only Kings).
|
||||||
/// Empty tableaus T3..T6 only accept Kings, so they are filtered out.
|
/// Empty tableaus T3..T6 only accept Kings, so they are filtered out.
|
||||||
fn deterministic_state() -> GameState {
|
fn deterministic_state() -> GameState {
|
||||||
let mut g = GameState::new(0, DrawMode::DrawOne);
|
let mut g = GameState::new(0, DrawStockConfig::DrawOne);
|
||||||
// Clear stock, waste, all tableaus.
|
// Clear stock, waste, all tableaus.
|
||||||
g.set_test_stock_cards(Vec::new());
|
g.set_test_stock_cards(Vec::new());
|
||||||
g.set_test_waste_cards(Vec::new());
|
g.set_test_waste_cards(Vec::new());
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
||||||
use bevy::window::{WindowMoved, WindowResized};
|
use bevy::window::{WindowMoved, WindowResized};
|
||||||
use solitaire_core::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
use solitaire_data::{
|
use solitaire_data::{
|
||||||
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, Settings, TIME_BONUS_MULTIPLIER_STEP,
|
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, Settings, TIME_BONUS_MULTIPLIER_STEP,
|
||||||
TOOLTIP_DELAY_STEP_SECS, WindowGeometry, load_settings_from, save_settings_to, settings::Theme,
|
TOOLTIP_DELAY_STEP_SECS, WindowGeometry, load_settings_from, save_settings_to, settings::Theme,
|
||||||
@@ -241,7 +241,7 @@ enum SettingsButton {
|
|||||||
ToggleTouchInputMode,
|
ToggleTouchInputMode,
|
||||||
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
|
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
|
||||||
/// random Classic-mode deals are filtered through
|
/// random Classic-mode deals are filtered through
|
||||||
/// [`solitaire_data::solver::try_solve`] until one is provably
|
/// [`solitaire_core::game_state::GameState::solve_fresh_deal`] until one is provably
|
||||||
/// winnable (or the retry cap is hit). Off by default.
|
/// winnable (or the retry cap is hit). Off by default.
|
||||||
ToggleWinnableDealsOnly,
|
ToggleWinnableDealsOnly,
|
||||||
/// Toggle the inverse of [`Settings::disable_smart_default_size`].
|
/// Toggle the inverse of [`Settings::disable_smart_default_size`].
|
||||||
@@ -1086,8 +1086,8 @@ fn handle_settings_buttons(
|
|||||||
}
|
}
|
||||||
SettingsButton::ToggleDrawMode => {
|
SettingsButton::ToggleDrawMode => {
|
||||||
settings.0.draw_mode = match settings.0.draw_mode {
|
settings.0.draw_mode = match settings.0.draw_mode {
|
||||||
DrawMode::DrawOne => DrawMode::DrawThree,
|
DrawStockConfig::DrawOne => DrawStockConfig::DrawThree,
|
||||||
DrawMode::DrawThree => DrawMode::DrawOne,
|
DrawStockConfig::DrawThree => DrawStockConfig::DrawOne,
|
||||||
};
|
};
|
||||||
persist(&path, &settings.0);
|
persist(&path, &settings.0);
|
||||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
@@ -1310,10 +1310,10 @@ fn handle_sync_buttons(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_mode_label(mode: &DrawMode) -> String {
|
fn draw_mode_label(mode: &DrawStockConfig) -> String {
|
||||||
match mode {
|
match mode {
|
||||||
DrawMode::DrawOne => "Draw 1".into(),
|
DrawStockConfig::DrawOne => "Draw 1".into(),
|
||||||
DrawMode::DrawThree => "Draw 3".into(),
|
DrawStockConfig::DrawThree => "Draw 3".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1327,7 +1327,7 @@ mod tests {
|
|||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<crate::resources::GameStateResource>()
|
.resource_mut::<crate::resources::GameStateResource>()
|
||||||
.0
|
.0
|
||||||
.set_test_draw_mode(solitaire_core::DrawMode::DrawThree);
|
.set_test_draw_mode(solitaire_core::DrawStockConfig::DrawThree);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
@@ -1952,7 +1952,7 @@ mod tests {
|
|||||||
let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 8).expect("valid date");
|
let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 8).expect("valid date");
|
||||||
let mut r = solitaire_data::Replay::new(
|
let mut r = solitaire_data::Replay::new(
|
||||||
1,
|
1,
|
||||||
solitaire_core::DrawMode::DrawOne,
|
solitaire_core::DrawStockConfig::DrawOne,
|
||||||
solitaire_core::game_state::GameMode::Classic,
|
solitaire_core::game_state::GameMode::Classic,
|
||||||
time_seconds,
|
time_seconds,
|
||||||
0,
|
0,
|
||||||
|
|||||||
@@ -604,7 +604,7 @@ mod tests {
|
|||||||
/// would silently drop the link.
|
/// would silently drop the link.
|
||||||
#[test]
|
#[test]
|
||||||
fn upload_result_writes_share_url_into_replay_and_persists() {
|
fn upload_result_writes_share_url_into_replay_and_persists() {
|
||||||
use solitaire_core::{DrawMode, game_state::GameMode};
|
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||||
use solitaire_data::{
|
use solitaire_data::{
|
||||||
Replay, ReplayHistory, load_replay_history_from, save_replay_history_to,
|
Replay, ReplayHistory, load_replay_history_from, save_replay_history_to,
|
||||||
};
|
};
|
||||||
@@ -617,7 +617,7 @@ mod tests {
|
|||||||
// share_url — the upload-poll path must populate it.
|
// share_url — the upload-poll path must populate it.
|
||||||
let initial = Replay::new(
|
let initial = Replay::new(
|
||||||
42,
|
42,
|
||||||
DrawMode::DrawOne,
|
DrawStockConfig::DrawOne,
|
||||||
GameMode::Classic,
|
GameMode::Classic,
|
||||||
60,
|
60,
|
||||||
500,
|
500,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::WindowResized;
|
use bevy::window::WindowResized;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
use solitaire_core::card::Suit;
|
use solitaire_core::Suit;
|
||||||
|
|
||||||
use crate::events::{HintVisualEvent, StateChangedEvent};
|
use crate::events::{HintVisualEvent, StateChangedEvent};
|
||||||
use crate::hud_plugin::HudVisibility;
|
use crate::hud_plugin::HudVisibility;
|
||||||
@@ -520,7 +520,7 @@ fn sync_pile_marker_visibility(
|
|||||||
fn pile_cards(
|
fn pile_cards(
|
||||||
game: &solitaire_core::game_state::GameState,
|
game: &solitaire_core::game_state::GameState,
|
||||||
pile: &KlondikePile,
|
pile: &KlondikePile,
|
||||||
) -> Vec<(solitaire_core::card::Card, bool)> {
|
) -> Vec<(solitaire_core::Card, bool)> {
|
||||||
match pile {
|
match pile {
|
||||||
KlondikePile::Stock => {
|
KlondikePile::Stock => {
|
||||||
let stock = game.stock_cards();
|
let stock = game.stock_cards();
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ use bevy::reflect::TypePath;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use solitaire_core::card::{Rank, Suit};
|
use solitaire_core::{Rank, Suit};
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use importer::{ImportError, ThemeId, import_theme, import_theme_into};
|
pub use importer::{ImportError, ThemeId, import_theme, import_theme_into};
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use bevy::asset::AssetEvent;
|
|||||||
use bevy::ecs::message::MessageReader;
|
use bevy::ecs::message::MessageReader;
|
||||||
use bevy::math::UVec2;
|
use bevy::math::UVec2;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::card::{Rank, Suit};
|
use solitaire_core::{Rank, Suit};
|
||||||
|
|
||||||
use crate::assets::{
|
use crate::assets::{
|
||||||
bundled_theme_url, classic_theme_svg_bytes, dark_theme_svg_bytes, rasterize_svg, user_theme_dir,
|
bundled_theme_url, classic_theme_svg_bytes, dark_theme_svg_bytes, rasterize_svg, user_theme_dir,
|
||||||
|
|||||||
@@ -299,7 +299,7 @@ mod tests {
|
|||||||
use crate::game_plugin::GamePlugin;
|
use crate::game_plugin::GamePlugin;
|
||||||
use crate::progress_plugin::ProgressPlugin;
|
use crate::progress_plugin::ProgressPlugin;
|
||||||
use crate::table_plugin::TablePlugin;
|
use crate::table_plugin::TablePlugin;
|
||||||
use solitaire_core::{DrawMode, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
|
||||||
fn headless_app() -> App {
|
fn headless_app() -> App {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
@@ -430,7 +430,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
// The current game must be in TimeAttack mode for auto-deal to fire.
|
// The current game must be in TimeAttack mode for auto-deal to fire.
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::TimeAttack);
|
GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::TimeAttack);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
@@ -454,7 +454,7 @@ mod tests {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
// Default session is inactive. Game is TimeAttack mode — still no count.
|
// Default session is inactive. Game is TimeAttack mode — still no count.
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::TimeAttack);
|
GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::TimeAttack);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
use bevy::ecs::message::MessageReader;
|
use bevy::ecs::message::MessageReader;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::KlondikePile;
|
use solitaire_core::KlondikePile;
|
||||||
use solitaire_core::card::Card;
|
use solitaire_core::Card;
|
||||||
|
|
||||||
use crate::card_plugin::CardEntity;
|
use crate::card_plugin::CardEntity;
|
||||||
use crate::events::StateChangedEvent;
|
use crate::events::StateChangedEvent;
|
||||||
@@ -194,7 +194,7 @@ fn spawn_touch_highlight(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use solitaire_core::Tableau;
|
use solitaire_core::Tableau;
|
||||||
use solitaire_core::card::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
|
|
||||||
/// Three distinct test cards, used in place of the old `vec![1, 2, 3]`
|
/// Three distinct test cards, used in place of the old `vec![1, 2, 3]`
|
||||||
/// numeric ids. Identity is now the `Card` value.
|
/// numeric ids. Identity is now the `Card` value.
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ fn evaluate_weekly_goals(
|
|||||||
for ev in events.drain(..) {
|
for ev in events.drain(..) {
|
||||||
let ctx = WeeklyGoalContext {
|
let ctx = WeeklyGoalContext {
|
||||||
time_seconds: ev.time_seconds,
|
time_seconds: ev.time_seconds,
|
||||||
used_undo: game.0.undo_count > 0,
|
used_undo: game.0.undo_count() > 0,
|
||||||
draw_mode: game.0.draw_mode(),
|
draw_mode: game.0.draw_mode(),
|
||||||
};
|
};
|
||||||
for def in WEEKLY_GOALS {
|
for def in WEEKLY_GOALS {
|
||||||
@@ -177,7 +177,7 @@ mod tests {
|
|||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<GameStateResource>()
|
.resource_mut::<GameStateResource>()
|
||||||
.0
|
.0
|
||||||
.undo_count = 1;
|
.force_test_undos(1);
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
|
|||||||
@@ -90,28 +90,23 @@ pub struct WinSummaryPending {
|
|||||||
|
|
||||||
/// Builds a human-readable XP breakdown string for the win modal.
|
/// Builds a human-readable XP breakdown string for the win modal.
|
||||||
///
|
///
|
||||||
/// Mirrors the logic in `solitaire_data::xp_for_win` so the breakdown always
|
/// Reads the components from `solitaire_data::xp_breakdown` — the single source
|
||||||
/// matches the total shown on the `XpAwardedEvent`.
|
/// of truth shared with `xp_for_win` — so the breakdown can never drift from
|
||||||
|
/// the total shown on the `XpAwardedEvent`.
|
||||||
///
|
///
|
||||||
/// Examples:
|
/// Examples:
|
||||||
/// - slow win, no undo → `"+50 base +25 no-undo"`
|
/// - slow win, no undo → `"+50 base +25 no-undo"`
|
||||||
/// - fast win, undo → `"+50 base +30 speed"`
|
/// - fast win, undo → `"+50 base +30 speed"`
|
||||||
/// - fast win, no undo → `"+50 base +25 no-undo +30 speed"`
|
/// - fast win, no undo → `"+50 base +25 no-undo +30 speed"`
|
||||||
fn build_xp_detail(time_seconds: u64, used_undo: bool) -> String {
|
fn build_xp_detail(time_seconds: u64, used_undo: bool) -> String {
|
||||||
let speed_bonus: u64 = if time_seconds >= 120 {
|
let xp = solitaire_data::xp_breakdown(time_seconds, used_undo);
|
||||||
0
|
|
||||||
} else {
|
|
||||||
let scaled = 50_u64.saturating_sub(time_seconds.saturating_mul(40) / 120);
|
|
||||||
scaled.max(10)
|
|
||||||
};
|
|
||||||
let no_undo_bonus: u64 = if used_undo { 0 } else { 25 };
|
|
||||||
|
|
||||||
let mut parts = vec!["+50 base".to_string()];
|
let mut parts = vec![format!("+{} base", xp.base)];
|
||||||
if no_undo_bonus > 0 {
|
if xp.no_undo_bonus > 0 {
|
||||||
parts.push("+25 no-undo".to_string());
|
parts.push(format!("+{} no-undo", xp.no_undo_bonus));
|
||||||
}
|
}
|
||||||
if speed_bonus > 0 {
|
if xp.speed_bonus > 0 {
|
||||||
parts.push(format!("+{speed_bonus} speed"));
|
parts.push(format!("+{} speed", xp.speed_bonus));
|
||||||
}
|
}
|
||||||
parts.join(" ")
|
parts.join(" ")
|
||||||
}
|
}
|
||||||
@@ -477,14 +472,14 @@ fn cache_win_data(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let used_undo = game.0.undo_count > 0;
|
let used_undo = game.0.undo_count() > 0;
|
||||||
pending.score = ev.score;
|
pending.score = ev.score;
|
||||||
pending.time_seconds = ev.time_seconds;
|
pending.time_seconds = ev.time_seconds;
|
||||||
pending.xp = 0; // reset; XP event follows
|
pending.xp = 0; // reset; XP event follows
|
||||||
pending.xp_detail = build_xp_detail(ev.time_seconds, used_undo);
|
pending.xp_detail = build_xp_detail(ev.time_seconds, used_undo);
|
||||||
pending.new_record = is_new_record;
|
pending.new_record = is_new_record;
|
||||||
pending.challenge_level = challenge_level;
|
pending.challenge_level = challenge_level;
|
||||||
pending.undo_count = game.0.undo_count;
|
pending.undo_count = game.0.undo_count();
|
||||||
pending.mode = game.0.mode;
|
pending.mode = game.0.mode;
|
||||||
|
|
||||||
if is_new_record {
|
if is_new_record {
|
||||||
@@ -1210,7 +1205,7 @@ mod tests {
|
|||||||
.insert_resource(StatsResource(StatsSnapshot::default()))
|
.insert_resource(StatsResource(StatsSnapshot::default()))
|
||||||
.insert_resource(GameStateResource(GameState::new(
|
.insert_resource(GameStateResource(GameState::new(
|
||||||
0,
|
0,
|
||||||
solitaire_core::DrawMode::DrawOne,
|
solitaire_core::DrawStockConfig::DrawOne,
|
||||||
)))
|
)))
|
||||||
.insert_resource(ProgressResource(PlayerProgress::default()));
|
.insert_resource(ProgressResource(PlayerProgress::default()));
|
||||||
app.update();
|
app.update();
|
||||||
@@ -1539,9 +1534,9 @@ mod tests {
|
|||||||
.challenge_index = 4;
|
.challenge_index = 4;
|
||||||
// Switch game mode to Challenge.
|
// Switch game mode to Challenge.
|
||||||
{
|
{
|
||||||
use solitaire_core::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
|
GameState::new_with_mode(1, DrawStockConfig::DrawOne, GameMode::Challenge);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
@@ -1585,14 +1580,14 @@ mod tests {
|
|||||||
/// mode-multiplier rows.
|
/// mode-multiplier rows.
|
||||||
#[test]
|
#[test]
|
||||||
fn cache_win_data_captures_undo_count_and_mode() {
|
fn cache_win_data_captures_undo_count_and_mode() {
|
||||||
use solitaire_core::DrawMode;
|
use solitaire_core::DrawStockConfig;
|
||||||
|
|
||||||
let mut app = make_app();
|
let mut app = make_app();
|
||||||
// Set up a Zen-mode game with 2 undos used.
|
// Set up a Zen-mode game with 2 undos used.
|
||||||
{
|
{
|
||||||
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
||||||
game.0 = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Zen);
|
game.0 = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Zen);
|
||||||
game.0.undo_count = 2;
|
game.0.force_test_undos(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
|
|||||||
+78
-58
@@ -21,9 +21,9 @@
|
|||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use solitaire_core::card::Suit;
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
use solitaire_core::error::MoveError;
|
use solitaire_core::error::MoveError;
|
||||||
use solitaire_core::{DrawMode, game_state::{GameMode, GameState}};
|
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
|
||||||
use solitaire_core::klondike_adapter::{
|
use solitaire_core::klondike_adapter::{
|
||||||
SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, tableau_from_index,
|
SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, tableau_from_index,
|
||||||
};
|
};
|
||||||
@@ -48,7 +48,7 @@ pub struct Replay {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
pub seed: u64,
|
pub seed: u64,
|
||||||
pub draw_mode: DrawMode,
|
pub draw_mode: DrawStockConfig,
|
||||||
pub mode: GameMode,
|
pub mode: GameMode,
|
||||||
pub time_seconds: u64,
|
pub time_seconds: u64,
|
||||||
pub final_score: i32,
|
pub final_score: i32,
|
||||||
@@ -77,9 +77,11 @@ pub struct StateSnapshot {
|
|||||||
/// means the card back is drawn; in that case `suit` and `rank` are
|
/// means the card back is drawn; in that case `suit` and `rank` are
|
||||||
/// still set (so the renderer doesn't need separate "unknown" data),
|
/// still set (so the renderer doesn't need separate "unknown" data),
|
||||||
/// just hidden visually.
|
/// just hidden visually.
|
||||||
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
pub struct CardSnapshot {
|
pub struct CardSnapshot {
|
||||||
pub id: u32,
|
/// Stable per-card identity for the JS renderer (an opaque key). Serialises
|
||||||
|
/// as the upstream `Card`'s transparent integer value.
|
||||||
|
pub id: Card,
|
||||||
/// `"clubs" | "diamonds" | "hearts" | "spades"`.
|
/// `"clubs" | "diamonds" | "hearts" | "spades"`.
|
||||||
pub suit: &'static str,
|
pub suit: &'static str,
|
||||||
/// 1-13, where 1 is Ace and 13 is King.
|
/// 1-13, where 1 is Ace and 13 is King.
|
||||||
@@ -87,14 +89,10 @@ pub struct CardSnapshot {
|
|||||||
pub face_up: bool,
|
pub face_up: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stable 0..=51 card identity, shared with the desktop engine via
|
impl From<&(Card, bool)> for CardSnapshot {
|
||||||
// solitaire_core so replay snapshots are identical across platforms.
|
fn from((card, face_up): &(Card, bool)) -> Self {
|
||||||
use solitaire_core::card::card_to_id;
|
|
||||||
|
|
||||||
impl From<&(solitaire_core::card::Card, bool)> for CardSnapshot {
|
|
||||||
fn from((card, face_up): &(solitaire_core::card::Card, bool)) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
id: card_to_id(card),
|
id: card.clone(),
|
||||||
suit: match card.suit() {
|
suit: match card.suit() {
|
||||||
Suit::Clubs => "clubs",
|
Suit::Clubs => "clubs",
|
||||||
Suit::Diamonds => "diamonds",
|
Suit::Diamonds => "diamonds",
|
||||||
@@ -184,7 +182,7 @@ impl ReplayPlayer {
|
|||||||
StateSnapshot {
|
StateSnapshot {
|
||||||
step_idx: self.step_idx,
|
step_idx: self.step_idx,
|
||||||
total_steps: self.moves.len(),
|
total_steps: self.moves.len(),
|
||||||
score: self.game.score,
|
score: self.game.score(),
|
||||||
move_count: self.game.move_count(),
|
move_count: self.game.move_count(),
|
||||||
is_won: self.game.is_won(),
|
is_won: self.game.is_won(),
|
||||||
stock: self
|
stock: self
|
||||||
@@ -318,9 +316,10 @@ pub enum DebugMove {
|
|||||||
pub struct DebugInvariantReport {
|
pub struct DebugInvariantReport {
|
||||||
pub state_ok: bool,
|
pub state_ok: bool,
|
||||||
pub total_cards_seen: usize,
|
pub total_cards_seen: usize,
|
||||||
pub duplicate_card_ids: Vec<u32>,
|
/// Cards that appeared more than once across all piles.
|
||||||
pub missing_card_ids: Vec<u32>,
|
pub duplicate_cards: Vec<Card>,
|
||||||
pub out_of_range_card_ids: Vec<u32>,
|
/// Cards from the full single-deck set that are absent from the board.
|
||||||
|
pub missing_cards: Vec<Card>,
|
||||||
pub stock_has_face_up_cards: bool,
|
pub stock_has_face_up_cards: bool,
|
||||||
pub waste_has_face_down_cards: bool,
|
pub waste_has_face_down_cards: bool,
|
||||||
pub foundation_has_face_down_cards: bool,
|
pub foundation_has_face_down_cards: bool,
|
||||||
@@ -332,7 +331,7 @@ pub struct DebugInvariantReport {
|
|||||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
pub struct DebugSnapshot {
|
pub struct DebugSnapshot {
|
||||||
pub seed: u64,
|
pub seed: u64,
|
||||||
pub draw_mode: DrawMode,
|
pub draw_mode: DrawStockConfig,
|
||||||
pub mode: GameMode,
|
pub mode: GameMode,
|
||||||
pub state: GameSnapshot,
|
pub state: GameSnapshot,
|
||||||
pub legal_moves: Vec<DebugMove>,
|
pub legal_moves: Vec<DebugMove>,
|
||||||
@@ -389,24 +388,15 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb
|
|||||||
game.pile(KlondikePile::Tableau(Tableau::Tableau7)),
|
game.pile(KlondikePile::Tableau(Tableau::Tableau7)),
|
||||||
];
|
];
|
||||||
|
|
||||||
let mut seen = [false; 52];
|
let mut seen: std::collections::HashSet<Card> = std::collections::HashSet::new();
|
||||||
let mut duplicate_card_ids = Vec::new();
|
let mut duplicate_cards = Vec::new();
|
||||||
let mut out_of_range_card_ids = Vec::new();
|
|
||||||
let mut total_cards_seen = 0_usize;
|
let mut total_cards_seen = 0_usize;
|
||||||
|
|
||||||
let mut feed = |cards: &[(solitaire_core::card::Card, bool)]| {
|
let mut feed = |cards: &[(Card, bool)]| {
|
||||||
for (card, _) in cards {
|
for (card, _) in cards {
|
||||||
total_cards_seen += 1;
|
total_cards_seen += 1;
|
||||||
let id = card_to_id(card);
|
if !seen.insert(card.clone()) {
|
||||||
if id >= 52 {
|
duplicate_cards.push(card.clone());
|
||||||
out_of_range_card_ids.push(id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let idx = id as usize;
|
|
||||||
if seen[idx] {
|
|
||||||
duplicate_card_ids.push(id);
|
|
||||||
} else {
|
|
||||||
seen[idx] = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -420,9 +410,22 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb
|
|||||||
feed(pile);
|
feed(pile);
|
||||||
}
|
}
|
||||||
|
|
||||||
let missing_card_ids = (0_u32..52_u32)
|
// Reference set: the full 52-card single deck, using whichever deck id the
|
||||||
.filter(|id| !seen[*id as usize])
|
// dealt cards carry. Any of those 52 not on the board is missing.
|
||||||
.collect::<Vec<_>>();
|
let deck = seen
|
||||||
|
.iter()
|
||||||
|
.next()
|
||||||
|
.map(|c| c.deck())
|
||||||
|
.unwrap_or_else(|| Deck::new(0).expect("deck id 0 is valid"));
|
||||||
|
let mut missing_cards = Vec::new();
|
||||||
|
for suit in Suit::SUITS {
|
||||||
|
for rank in Rank::RANKS {
|
||||||
|
let card = Card::new(deck, suit, rank);
|
||||||
|
if !seen.contains(&card) {
|
||||||
|
missing_cards.push(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let stock_has_face_up_cards = stock.iter().any(|(_, face_up)| *face_up);
|
let stock_has_face_up_cards = stock.iter().any(|(_, face_up)| *face_up);
|
||||||
let waste_has_face_down_cards = waste.iter().any(|(_, face_up)| !*face_up);
|
let waste_has_face_down_cards = waste.iter().any(|(_, face_up)| !*face_up);
|
||||||
@@ -444,9 +447,8 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb
|
|||||||
|
|
||||||
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_cards.is_empty()
|
||||||
&& missing_card_ids.is_empty()
|
&& missing_cards.is_empty()
|
||||||
&& out_of_range_card_ids.is_empty()
|
|
||||||
&& !stock_has_face_up_cards
|
&& !stock_has_face_up_cards
|
||||||
&& !waste_has_face_down_cards
|
&& !waste_has_face_down_cards
|
||||||
&& !foundation_has_face_down_cards
|
&& !foundation_has_face_down_cards
|
||||||
@@ -455,9 +457,8 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb
|
|||||||
DebugInvariantReport {
|
DebugInvariantReport {
|
||||||
state_ok,
|
state_ok,
|
||||||
total_cards_seen,
|
total_cards_seen,
|
||||||
duplicate_card_ids,
|
duplicate_cards,
|
||||||
missing_card_ids,
|
missing_cards,
|
||||||
out_of_range_card_ids,
|
|
||||||
stock_has_face_up_cards,
|
stock_has_face_up_cards,
|
||||||
waste_has_face_down_cards,
|
waste_has_face_down_cards,
|
||||||
foundation_has_face_down_cards,
|
foundation_has_face_down_cards,
|
||||||
@@ -487,12 +488,12 @@ impl SolitaireGame {
|
|||||||
!stock_empty || !waste_empty || !self.game.possible_instructions().is_empty()
|
!stock_empty || !waste_empty || !self.game.possible_instructions().is_empty()
|
||||||
};
|
};
|
||||||
GameSnapshot {
|
GameSnapshot {
|
||||||
score: self.game.score,
|
score: self.game.score(),
|
||||||
move_count: self.game.move_count(),
|
move_count: self.game.move_count(),
|
||||||
is_won: self.game.is_won(),
|
is_won: self.game.is_won(),
|
||||||
is_auto_completable: self.game.is_auto_completable(),
|
is_auto_completable: self.game.is_auto_completable(),
|
||||||
has_moves,
|
has_moves,
|
||||||
undo_count: self.game.undo_count,
|
undo_count: self.game.undo_count(),
|
||||||
undo_stack_len: self.game.undo_stack_len(),
|
undo_stack_len: self.game.undo_stack_len(),
|
||||||
stock: self
|
stock: self
|
||||||
.game
|
.game
|
||||||
@@ -726,9 +727,9 @@ impl SolitaireGame {
|
|||||||
#[cfg(feature = "console_error_panic_hook")]
|
#[cfg(feature = "console_error_panic_hook")]
|
||||||
console_error_panic_hook::set_once();
|
console_error_panic_hook::set_once();
|
||||||
let dm = if draw_three {
|
let dm = if draw_three {
|
||||||
DrawMode::DrawThree
|
DrawStockConfig::DrawThree
|
||||||
} else {
|
} else {
|
||||||
DrawMode::DrawOne
|
DrawStockConfig::DrawOne
|
||||||
};
|
};
|
||||||
SolitaireGame {
|
SolitaireGame {
|
||||||
game: GameState::new_with_mode(seed as u64, dm, GameMode::Classic),
|
game: GameState::new_with_mode(seed as u64, dm, GameMode::Classic),
|
||||||
@@ -893,6 +894,25 @@ mod tests {
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
|
|
||||||
|
/// The JS card renderer reads `card.id` as an opaque identity key. After the
|
||||||
|
/// `card_to_id` removal, `CardSnapshot.id` is a `card_game::Card`, which is
|
||||||
|
/// `#[serde(transparent)]` over `NonZeroU8` — so it must still serialise as a
|
||||||
|
/// plain JSON number (the same `Serialize` impl `serde_wasm_bindgen` uses).
|
||||||
|
#[test]
|
||||||
|
fn card_snapshot_id_serialises_as_a_plain_number() {
|
||||||
|
let card = Card::new(Deck::new(0).unwrap(), Suit::Hearts, Rank::RANKS[0]);
|
||||||
|
let snap = CardSnapshot::from(&(card, true));
|
||||||
|
let json = serde_json::to_value(&snap).expect("serialise CardSnapshot");
|
||||||
|
assert!(
|
||||||
|
json["id"].is_number(),
|
||||||
|
"card.id must serialise as a JSON number for the JS opaque key, got {:?}",
|
||||||
|
json["id"]
|
||||||
|
);
|
||||||
|
assert_eq!(json["suit"], "hearts");
|
||||||
|
assert_eq!(json["rank"], 1);
|
||||||
|
assert_eq!(json["face_up"], true);
|
||||||
|
}
|
||||||
|
|
||||||
fn pick_move_index(moves: &[DebugMove]) -> Option<usize> {
|
fn pick_move_index(moves: &[DebugMove]) -> Option<usize> {
|
||||||
if moves.is_empty() {
|
if moves.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
@@ -933,7 +953,7 @@ mod tests {
|
|||||||
for card in cards {
|
for card in cards {
|
||||||
let _ = write!(
|
let _ = write!(
|
||||||
key,
|
key,
|
||||||
"{}:{}:{},",
|
"{:?}:{}:{},",
|
||||||
card.id,
|
card.id,
|
||||||
card.rank,
|
card.rank,
|
||||||
if card.face_up { 1 } else { 0 }
|
if card.face_up { 1 } else { 0 }
|
||||||
@@ -952,7 +972,7 @@ mod tests {
|
|||||||
key
|
key
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_autonomous(seed: u64, draw_mode: DrawMode, max_steps: usize) -> DebugSnapshot {
|
fn run_autonomous(seed: u64, draw_mode: DrawStockConfig, max_steps: usize) -> DebugSnapshot {
|
||||||
let mut game = SolitaireGame {
|
let mut game = SolitaireGame {
|
||||||
game: GameState::new_with_mode(seed, draw_mode, GameMode::Classic),
|
game: GameState::new_with_mode(seed, draw_mode, GameMode::Classic),
|
||||||
};
|
};
|
||||||
@@ -983,7 +1003,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn debug_snapshot_exposes_replayable_seed_and_history() {
|
fn debug_snapshot_exposes_replayable_seed_and_history() {
|
||||||
let seed = 42_u64;
|
let seed = 42_u64;
|
||||||
let final_snapshot = run_autonomous(seed, DrawMode::DrawOne, 1500);
|
let final_snapshot = run_autonomous(seed, DrawStockConfig::DrawOne, 1500);
|
||||||
assert_eq!(final_snapshot.seed, seed);
|
assert_eq!(final_snapshot.seed, seed);
|
||||||
assert!(
|
assert!(
|
||||||
!final_snapshot.state_json.is_empty(),
|
!final_snapshot.state_json.is_empty(),
|
||||||
@@ -1000,7 +1020,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn replay_moves_export_is_json_compatible_and_replayable() {
|
fn replay_moves_export_is_json_compatible_and_replayable() {
|
||||||
let seed = 7_u64;
|
let seed = 7_u64;
|
||||||
let draw_mode = DrawMode::DrawThree;
|
let draw_mode = DrawStockConfig::DrawThree;
|
||||||
let mut game = SolitaireGame {
|
let mut game = SolitaireGame {
|
||||||
game: GameState::new_with_mode(seed, draw_mode, GameMode::Classic),
|
game: GameState::new_with_mode(seed, draw_mode, GameMode::Classic),
|
||||||
};
|
};
|
||||||
@@ -1059,7 +1079,7 @@ mod tests {
|
|||||||
draw_mode,
|
draw_mode,
|
||||||
mode: GameMode::Classic,
|
mode: GameMode::Classic,
|
||||||
time_seconds: 120,
|
time_seconds: 120,
|
||||||
final_score: game.game.score,
|
final_score: game.game.score(),
|
||||||
recorded_at,
|
recorded_at,
|
||||||
moves: exported_moves,
|
moves: exported_moves,
|
||||||
};
|
};
|
||||||
@@ -1098,9 +1118,9 @@ mod tests {
|
|||||||
fn debug_api_autonomous_seed_batch_smoke() {
|
fn debug_api_autonomous_seed_batch_smoke() {
|
||||||
for seed in 0_u64..128_u64 {
|
for seed in 0_u64..128_u64 {
|
||||||
let draw_mode = if seed % 2 == 0 {
|
let draw_mode = if seed % 2 == 0 {
|
||||||
DrawMode::DrawOne
|
DrawStockConfig::DrawOne
|
||||||
} else {
|
} else {
|
||||||
DrawMode::DrawThree
|
DrawStockConfig::DrawThree
|
||||||
};
|
};
|
||||||
let snapshot = run_autonomous(seed, draw_mode, 2000);
|
let snapshot = run_autonomous(seed, draw_mode, 2000);
|
||||||
assert_invariants(&snapshot, seed);
|
assert_invariants(&snapshot, seed);
|
||||||
@@ -1112,9 +1132,9 @@ mod tests {
|
|||||||
fn debug_api_autonomous_thousands_seed_soak() {
|
fn debug_api_autonomous_thousands_seed_soak() {
|
||||||
for seed in 10_000_u64..12_000_u64 {
|
for seed in 10_000_u64..12_000_u64 {
|
||||||
let draw_mode = if seed % 2 == 0 {
|
let draw_mode = if seed % 2 == 0 {
|
||||||
DrawMode::DrawOne
|
DrawStockConfig::DrawOne
|
||||||
} else {
|
} else {
|
||||||
DrawMode::DrawThree
|
DrawStockConfig::DrawThree
|
||||||
};
|
};
|
||||||
let snapshot = run_autonomous(seed, draw_mode, 3000);
|
let snapshot = run_autonomous(seed, draw_mode, 3000);
|
||||||
assert_invariants(&snapshot, seed);
|
assert_invariants(&snapshot, seed);
|
||||||
@@ -1125,7 +1145,7 @@ mod tests {
|
|||||||
fn serialize_from_saved_round_trip() {
|
fn serialize_from_saved_round_trip() {
|
||||||
let seed = 55_u64;
|
let seed = 55_u64;
|
||||||
let mut game = SolitaireGame {
|
let mut game = SolitaireGame {
|
||||||
game: GameState::new_with_mode(seed, DrawMode::DrawOne, GameMode::Classic),
|
game: GameState::new_with_mode(seed, DrawStockConfig::DrawOne, GameMode::Classic),
|
||||||
};
|
};
|
||||||
// Advance a few moves so there is non-trivial state to round-trip.
|
// Advance a few moves so there is non-trivial state to round-trip.
|
||||||
for _ in 0..20 {
|
for _ in 0..20 {
|
||||||
@@ -1160,7 +1180,7 @@ mod tests {
|
|||||||
fn undo_reverts_to_prior_state() {
|
fn undo_reverts_to_prior_state() {
|
||||||
let seed = 99_u64;
|
let seed = 99_u64;
|
||||||
let mut game = SolitaireGame {
|
let mut game = SolitaireGame {
|
||||||
game: GameState::new_with_mode(seed, DrawMode::DrawOne, GameMode::Classic),
|
game: GameState::new_with_mode(seed, DrawStockConfig::DrawOne, GameMode::Classic),
|
||||||
};
|
};
|
||||||
|
|
||||||
let before_key = board_key(&game.debug_snapshot_native().state);
|
let before_key = board_key(&game.debug_snapshot_native().state);
|
||||||
@@ -1198,7 +1218,7 @@ mod tests {
|
|||||||
fn draw_one_advances_waste_by_one() {
|
fn draw_one_advances_waste_by_one() {
|
||||||
let seed = 1_u64;
|
let seed = 1_u64;
|
||||||
let mut game = SolitaireGame {
|
let mut game = SolitaireGame {
|
||||||
game: GameState::new_with_mode(seed, DrawMode::DrawOne, GameMode::Classic),
|
game: GameState::new_with_mode(seed, DrawStockConfig::DrawOne, GameMode::Classic),
|
||||||
};
|
};
|
||||||
|
|
||||||
let stock_before = game.game.stock_cards().len();
|
let stock_before = game.game.stock_cards().len();
|
||||||
@@ -1224,7 +1244,7 @@ mod tests {
|
|||||||
fn draw_three_advances_waste_by_three() {
|
fn draw_three_advances_waste_by_three() {
|
||||||
let seed = 1_u64;
|
let seed = 1_u64;
|
||||||
let mut game = SolitaireGame {
|
let mut game = SolitaireGame {
|
||||||
game: GameState::new_with_mode(seed, DrawMode::DrawThree, GameMode::Classic),
|
game: GameState::new_with_mode(seed, DrawStockConfig::DrawThree, GameMode::Classic),
|
||||||
};
|
};
|
||||||
|
|
||||||
let stock_before = game.game.stock_cards().len();
|
let stock_before = game.game.stock_cards().len();
|
||||||
@@ -1254,7 +1274,7 @@ mod tests {
|
|||||||
fn debug_apply_move_json_stock_click_advances_waste() {
|
fn debug_apply_move_json_stock_click_advances_waste() {
|
||||||
let seed = 3_u64;
|
let seed = 3_u64;
|
||||||
let mut game = SolitaireGame {
|
let mut game = SolitaireGame {
|
||||||
game: GameState::new_with_mode(seed, DrawMode::DrawOne, GameMode::Classic),
|
game: GameState::new_with_mode(seed, DrawStockConfig::DrawOne, GameMode::Classic),
|
||||||
};
|
};
|
||||||
|
|
||||||
let waste_before = game.game.waste_cards().len();
|
let waste_before = game.game.waste_cards().len();
|
||||||
|
|||||||
Reference in New Issue
Block a user