Merge pull request 'refactor(core): card_game redundancy cleanup + derive scoring from upstream stats' (#88) from refactor/strip-card_game-redundancies into master
Build and Deploy / build-and-push (push) Failing after 1m0s
Web E2E / web-e2e (push) Failing after 3m19s

This commit was merged in pull request #88.
This commit is contained in:
2026-06-22 18:44:37 +00:00
88 changed files with 1541 additions and 2073 deletions
+11
View File
@@ -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
+7 -2
View File
@@ -208,9 +208,14 @@ Embed via `include_bytes!()` only when ALL of the following are true:
Currently embedded: Currently embedded:
* **Audio** — all `.wav` files in `audio_plugin.rs` * **Audio** — all `.wav` files in `audio_plugin.rs`
* **Default card theme** — shipped via `embedded://` scheme in `ThemePlugin` * **Default card theme** — shipped via `embedded://` scheme in `ThemePlugin`
* **Bundled UI font** — `assets/fonts/main.ttf` (FiraMono) via `include_bytes!`
in `font_plugin.rs` and `assets/svg_loader.rs`; it is the canonical UI face
and must always be present, so it is embedded rather than `AssetServer`-loaded
Do NOT embed card face PNGs, background images, or user fonts — Do NOT embed card face PNGs or background images — these are loaded via
these are loaded via `AssetServer` so art can be swapped without recompile. `AssetServer` so art can be swapped without recompile. User-supplied fonts
(if ever added) likewise go through `AssetServer`; only the bundled FiraMono
face above is embedded.
--- ---
Generated
+1
View File
@@ -7306,6 +7306,7 @@ name = "solitaire_app"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"bevy", "bevy",
"jni 0.21.1",
"keyring", "keyring",
"solitaire_data", "solitaire_data",
"solitaire_engine", "solitaire_engine",
+16
View File
@@ -18,6 +18,22 @@ version = "0.1.0"
license = "MIT" license = "MIT"
rust-version = "1.95" rust-version = "1.95"
# Pedantic correctness lints applied across every member crate via
# `[lints] workspace = true`.
[workspace.lints.rust]
# Workspace-wide ban on `unsafe`. The sole exception is `solitaire_app`,
# which sets its own `deny`-level lints (see its Cargo.toml) because the
# Android cdylib entry point must reconstruct raw JNI handles. Every other
# crate reaches Android JNI through the safe `solitaire_data::android_jni`
# bridge and stays fully unsafe-free.
unsafe_code = "forbid"
single_use_lifetimes = "warn"
trivial_casts = "warn"
unused_lifetimes = "warn"
unused_qualifications = "warn"
variant_size_differences = "warn"
unexpected_cfgs = "warn"
[workspace.dependencies] [workspace.dependencies]
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
+22
View File
@@ -22,6 +22,13 @@ bevy = { workspace = true }
solitaire_engine = { workspace = true } solitaire_engine = { workspace = true }
solitaire_data = { workspace = true } solitaire_data = { workspace = true }
# Android-only: the entry point reconstructs the raw `JavaVM` / activity
# handles and registers the safe `solitaire_data::android_jni` bridge. This
# is the one crate in the workspace that performs `unsafe` FFI, so it is also
# the only one that depends on `jni` directly at the app layer.
[target.'cfg(target_os = "android")'.dependencies]
jni = { workspace = true }
# Desktop-only deps. `keyring`'s default-store init only matters on # Desktop-only deps. `keyring`'s default-store init only matters on
# platforms with a real keychain backend (Linux Secret Service, # platforms with a real keychain backend (Linux Secret Service,
# macOS Keychain, Windows Credential Store), and its transitive # macOS Keychain, Windows Credential Store), and its transitive
@@ -99,3 +106,18 @@ icon = "@mipmap/ic_launcher"
# in portrait orientation. Remove (or add a landscape layout) before # in portrait orientation. Remove (or add a landscape layout) before
# enabling auto-rotate. # enabling auto-rotate.
orientation = "portrait" orientation = "portrait"
# `solitaire_app` is the one crate that cannot inherit the workspace
# `forbid(unsafe_code)`: as the Android cdylib it must export the
# `#[unsafe(no_mangle)]` entry point and reconstruct the raw JNI handles
# there (a `no_mangle` symbol cannot live in a dependency rlib). It mirrors
# the workspace lints but at `deny`, so the two `#[allow(unsafe_code)]`
# scopes in the Android entry point are the only unsafe in the whole tree.
[lints.rust]
unsafe_code = "deny"
single_use_lifetimes = "warn"
trivial_casts = "warn"
unused_lifetimes = "warn"
unused_qualifications = "warn"
variant_size_differences = "warn"
unexpected_cfgs = "warn"
+46 -5
View File
@@ -144,7 +144,7 @@ fn build_app_with_settings(
// Android windows always fill the screen; max_width/max_height // Android windows always fill the screen; max_width/max_height
// default to 0.0, which panics Bevy's clamp when min > max. // default to 0.0, which panics Bevy's clamp when min > max.
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
resize_constraints: bevy::window::WindowResizeConstraints { resize_constraints: WindowResizeConstraints {
min_width: 800.0, min_width: 800.0,
min_height: 600.0, min_height: 600.0,
..default() ..default()
@@ -166,7 +166,7 @@ fn build_app_with_settings(
// default makes it walk *out* of the APK's assets root and // default makes it walk *out* of the APK's assets root and
// all loads fail silently — which is what produced the // all loads fail silently — which is what produced the
// solid-red card-back fallback in the v0.22.3 screenshot. // solid-red card-back fallback in the v0.22.3 screenshot.
.set(bevy::asset::AssetPlugin { .set(AssetPlugin {
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
file_path: "../assets".to_string(), file_path: "../assets".to_string(),
..default() ..default()
@@ -363,16 +363,57 @@ fn set_window_icon(
/// works on a function named `main`; our shared entry point is `run`, so /// works on a function named `main`; our shared entry point is `run`, so
/// we emit the equivalent expansion manually. /// we emit the equivalent expansion manually.
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
#[allow(unsafe_code)]
#[unsafe(no_mangle)] #[unsafe(no_mangle)]
fn android_main(android_app: bevy::android::android_activity::AndroidApp) { fn android_main(android_app: bevy::android::android_activity::AndroidApp) {
let vm_ptr = android_app.vm_as_ptr().cast(); if let Err(e) = init_android_jni(&android_app) {
if let Err(e) = solitaire_data::init_android_jvm(vm_ptr) { eprintln!("warn: could not initialise Android JNI bridge ({e})");
eprintln!("warn: could not initialise Android Keystore JNI ({e})");
} }
let _ = bevy::android::ANDROID_APP.set(android_app); let _ = bevy::android::ANDROID_APP.set(android_app);
run(); run();
} }
/// Reconstructs the raw `JavaVM` / `NativeActivity` handles handed over by the
/// Android runtime and registers safe wrappers with `solitaire_data`.
///
/// This is the *only* place in the workspace that performs `unsafe` FFI handle
/// reconstruction. Every other crate consumes the safe
/// [`solitaire_data::android_jni`] bridge and stays `forbid(unsafe_code)`;
/// `solitaire_app` opts down to `deny` with a narrowly scoped allow on this
/// function and the `#[unsafe(no_mangle)]` entry point above.
#[cfg(target_os = "android")]
#[allow(unsafe_code)]
fn init_android_jni(
android_app: &bevy::android::android_activity::AndroidApp,
) -> Result<(), String> {
use jni::JavaVM;
use jni::objects::JObject;
let vm_ptr = android_app.vm_as_ptr();
if vm_ptr.is_null() {
return Err("JavaVM pointer is null".into());
}
// SAFETY: `vm_as_ptr()` returns the process-wide JavaVM* established by the
// Android runtime; it is valid for the lifetime of the process.
let vm = unsafe { JavaVM::from_raw(vm_ptr.cast()) }.map_err(|e| format!("JavaVM: {e}"))?;
let env = vm
.attach_current_thread_permanently()
.map_err(|e| format!("attach_current_thread: {e}"))?;
// SAFETY: `activity_as_ptr()` returns the NativeActivity jobject pointer,
// valid for the lifetime of the process. Promote it to a global reference
// so the safe bridge can hand it to any thread.
let activity = unsafe { JObject::from_raw(android_app.activity_as_ptr().cast()) };
let activity_ref = env
.new_global_ref(&activity)
.map_err(|e| format!("activity global ref: {e}"))?;
solitaire_data::android_jni::set_jvm(vm);
solitaire_data::android_jni::set_activity(activity_ref);
Ok(())
}
/// Wraps the default panic hook with one that also appends a crash log /// Wraps the default panic hook with one that also appends a crash log
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook /// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
/// still runs afterwards, so stderr output and debugger integration are /// still runs afterwards, so stderr output and debugger integration are
+3
View File
@@ -30,3 +30,6 @@ path = "src/bin/gen_seeds.rs"
[[bin]] [[bin]]
name = "gen_difficulty_seeds" name = "gen_difficulty_seeds"
path = "src/bin/gen_difficulty_seeds.rs" path = "src/bin/gen_difficulty_seeds.rs"
[lints]
workspace = true
@@ -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!(
+5 -4
View File
@@ -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,
+3
View File
@@ -16,3 +16,6 @@ serde = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
klondike = { workspace = true } klondike = { workspace = true }
card_game = { workspace = true } card_game = { workspace = true }
[lints]
workspace = true
-23
View File
@@ -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)
}
+322 -285
View File
@@ -1,12 +1,10 @@
use crate::error::MoveError; use crate::error::MoveError;
use crate::klondike_adapter::{ use crate::klondike_adapter::{
DrawMode, KlondikeAdapter, SavedInstruction, KlondikeAdapter, foundation_from_slot as adapter_foundation_from_slot,
compute_time_bonus as scoring_time_bonus,
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,
@@ -20,12 +18,31 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// History: /// History:
/// - v1: `Foundation(Suit)` keys. /// - v1: `Foundation(Suit)` keys.
/// - 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 (rejected): session-backed save files using local mirror types with u8
/// with u8 indices for enum variants. /// indices for enum variants. No longer loadable — v3 files are discarded.
/// - 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`).
/// auto-migrated on load via `AnyInstruction` transparent deserialization. /// - v5 (current): `score`, `undo_count`, and `recycle_count` are no longer
pub const GAME_STATE_SCHEMA_VERSION: u32 = 4; /// 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,53 +99,34 @@ 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>,
} }
/// Transparent migration wrapper for deserialisation. /// Input struct that accepts schema v4 and v5 `saved_moves` formats.
/// ///
/// Tries `KlondikeInstruction` (schema v4, named variants) first; if that /// `saved_moves` is deserialised directly as upstream `KlondikeInstruction`
/// fails (because the value uses u8 indices), falls back to `SavedInstruction` /// (named-variant serde). `score`, `undo_count`, and `recycle_count` are
/// (schema v3). Converting the V3 variant yields a `KlondikeInstruction` via /// intentionally absent: all three are rebuilt by replaying the instruction
/// the existing `TryFrom` impl. /// history through the upstream session stats. Older v4 save files still carry
/// /// those keys; serde ignores them.
/// `SavedInstruction` remains `pub` in `klondike_adapter` because
/// `solitaire_data::ReplayMove` and the WASM replay layer depend on it.
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum AnyInstruction {
V4(KlondikeInstruction),
V3(SavedInstruction),
}
/// Input struct that accepts both schema v3 and v4 `saved_moves` formats.
///
/// `recycle_count` is intentionally absent: the value is rebuilt from the
/// instruction replay so that stale counts (from the pre-Phase-3 undo drift
/// bug) are corrected on load. Serde ignores the field in the JSON.
#[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")]
pub schema_version: u32, pub schema_version: u32,
pub saved_moves: Vec<AnyInstruction>, pub saved_moves: Vec<KlondikeInstruction>,
} }
#[cfg(feature = "test-support")] #[cfg(feature = "test-support")]
@@ -162,33 +160,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 +188,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 +218,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 +232,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 v4 (upstream named-variant serde) and v5 (current, derived
// upstream named-variant serde). Reject everything else. // stats). v3 (legacy u8-index format) and all others are rejected.
match persisted.schema_version { match persisted.schema_version {
3 | 4 => {} 4 | 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,45 +245,21 @@ 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 instruction in persisted.saved_moves {
// AnyInstruction::V4 arrives directly from upstream serde (schema v4).
// AnyInstruction::V3 was serialised with u8 indices (schema v3) and is
// converted here via the existing TryFrom impl.
let instruction = match any {
AnyInstruction::V4(i) => i,
AnyInstruction::V3(s) => {
KlondikeInstruction::try_from(s).map_err(serde::de::Error::custom)?
}
};
// 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 +271,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 +279,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 +298,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 +375,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
@@ -424,20 +413,17 @@ impl GameState {
} }
/// Returns the deterministic instruction history for the current deal as /// Returns the deterministic instruction history for the current deal as
/// legacy mirror types. /// upstream [`KlondikeInstruction`] values.
/// ///
/// Combined with [`GameState::seed`] and [`GameState::draw_mode`], this /// Combined with [`GameState::seed`] and [`GameState::draw_mode`], this
/// sequence is sufficient to replay the game state exactly. /// sequence is sufficient to replay the game state exactly. Consumers
/// /// record these directly (they serialise via `KlondikeInstruction`'s
/// Returns [`SavedInstruction`] (u8-index mirror types) for backward /// compact serde) and play them back via [`GameState::apply_instruction`].
/// compatibility with the WASM replay layer and `solitaire_data::ReplayMove` pub fn instruction_history(&self) -> Vec<KlondikeInstruction> {
/// format. New code that does not need serde should prefer
/// `session().history()` directly.
pub fn instruction_history(&self) -> Vec<SavedInstruction> {
self.session self.session
.history() .history()
.iter() .iter()
.map(|snapshot| SavedInstruction::from(*snapshot.instruction())) .map(|snapshot| *snapshot.instruction())
.collect() .collect()
} }
@@ -568,7 +554,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 +588,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 +720,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 +874,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 +915,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 +939,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,29 +963,13 @@ 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(())
} }
/// Returns `true` when all four foundation slots each contain a complete A→K sequence. /// Returns `true` when all four foundation slots each contain a complete A→K sequence.
pub fn check_win(&self) -> bool { pub fn check_win(&self) -> bool {
self.session.state().state().is_win() self.session.is_win()
} }
/// Returns `true` when the game can be completed without further player input /// Returns `true` when the game can be completed without further player input
@@ -1118,11 +1064,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 +1073,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 +1144,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 +1193,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 +1207,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 +1273,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(_))));
}
} }
+10 -403
View File
@@ -3,46 +3,32 @@
//! [`KlondikeAdapter`] is a pure helper namespace for: //! [`KlondikeAdapter`] is a pure helper namespace for:
//! - building [`KlondikeConfig`] from Ferrous settings //! - building [`KlondikeConfig`] from Ferrous settings
//! - translating between local and upstream types //! - translating between local and upstream types
//! - applying Ferrous-specific scoring policy on top of upstream defaults //!
//! Ferrous-specific scoring policy (the win-time bonus) lives in
//! [`crate::scoring`], not here.
//! //!
//! All `From` / `TryFrom` conversions between `solitaire_core` product types and //! All `From` / `TryFrom` conversions between `solitaire_core` product types and
//! upstream `card_game` / `klondike` types live here so that the product modules //! upstream `card_game` / `klondike` types live here so that the product modules
//! (`card`, `pile`, etc.) remain free of upstream dependencies. //! (`card`, `pile`, etc.) remain free of upstream dependencies.
use klondike::{ use klondike::{
DrawStockConfig, DstFoundation, DstTableau, Foundation, KlondikeConfig, KlondikeInstruction, DrawStockConfig, Foundation, KlondikeConfig, MoveFromFoundationConfig, ScoringConfig,
KlondikePile, KlondikePileStack, MoveFromFoundationConfig, ScoringConfig, SkipCards, Tableau, SkipCards, Tableau,
TableauStack,
}; };
use serde::{Deserialize, Serialize};
use crate::game_state::GameMode; /// Bridges `solitaire_core` game config to the upstream `klondike` crate.
/// 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.
/// ///
/// This type is intentionally zero-sized: it does not carry mutable runtime /// This type is intentionally zero-sized: it does not carry mutable runtime
/// state, and exists only as a namespace for configuration, conversion, and /// state, and exists only as a namespace for configuration and conversion
/// scoring helpers. /// helpers.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct KlondikeAdapter; 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`].
@@ -207,272 +83,3 @@ pub fn skip_cards_from_count(skip: usize) -> Option<SkipCards> {
_ => None, _ => None,
} }
} }
// ── Legacy serde mirror types (kept for backward compatibility) ───────────────
//
// These types were introduced when upstream `klondike` had no serde feature.
// Mainline `klondike` now provides full serde support (with a hand-written
// compact `KlondikeInstruction` impl), and `GameState` serialises
// `saved_moves` directly as `Vec<KlondikeInstruction>` (schema v4).
//
// The mirror types are retained for three reasons:
// 1. Schema v3 migration: `AnyInstruction` in `game_state.rs` uses
// `TryFrom<SavedInstruction> for KlondikeInstruction` to parse old save
// files with u8 indices and replay them.
// 2. `solitaire_data::ReplayMove` uses `SavedKlondikePile` as its serde
// type; changing it would break the on-disk replay format (schema v2).
// 3. `solitaire_wasm` mirrors `ReplayMove` using the same types so that
// replay JSON is cross-compatible between the desktop and browser builds.
//
// These types should not be used for new serialisation concerns. If the
// ReplayMove format is ever bumped to a new schema, migrate those callers to
// `KlondikePile` / `KlondikePileStack` and the types here can then be deleted.
/// A `Serialize` + `Deserialize` mirror of [`klondike::Tableau`] (0 = Tableau1 … 6 = Tableau7).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedTableau(pub u8);
/// A `Serialize` + `Deserialize` mirror of [`klondike::Foundation`] (0 = Foundation1 … 3 = Foundation4).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedFoundation(pub u8);
/// A `Serialize` + `Deserialize` mirror of [`klondike::SkipCards`] (0 = Skip0 … 12 = Skip12).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedSkipCards(pub u8);
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikePile`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavedKlondikePile {
Tableau(SavedTableau),
Stock,
Foundation(SavedFoundation),
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::TableauStack`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedTableauStack {
pub tableau: SavedTableau,
pub skip_cards: SavedSkipCards,
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikePileStack`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavedKlondikePileStack {
Tableau(SavedTableauStack),
Stock,
Foundation(SavedFoundation),
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::DstFoundation`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedDstFoundation {
pub src: SavedKlondikePile,
pub foundation: SavedFoundation,
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::DstTableau`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedDstTableau {
pub src: SavedKlondikePileStack,
pub tableau: SavedTableau,
}
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikeInstruction`].
///
/// Convert to/from the upstream type with:
/// ```ignore
/// let saved = SavedInstruction::from(instruction);
/// let instruction = KlondikeInstruction::try_from(saved)?;
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavedInstruction {
DstFoundation(SavedDstFoundation),
DstTableau(SavedDstTableau),
RotateStock,
}
/// Error returned when a [`SavedInstruction`] contains an out-of-range numeric value
/// and cannot be converted back to a [`klondike::KlondikeInstruction`].
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum InvalidSavedInstruction {
#[error("invalid tableau index {0} (expected 06)")]
Tableau(u8),
#[error("invalid foundation index {0} (expected 03)")]
Foundation(u8),
#[error("invalid skip_cards value {0} (expected 012)")]
SkipCards(u8),
}
// ── From impls: KlondikeInstruction → Saved* ─────────────────────────────────
impl From<Tableau> for SavedTableau {
fn from(t: Tableau) -> Self {
Self(t as u8)
}
}
impl From<Foundation> for SavedFoundation {
fn from(f: Foundation) -> Self {
Self(f as u8)
}
}
impl From<SkipCards> for SavedSkipCards {
fn from(s: SkipCards) -> Self {
Self(s as u8)
}
}
impl From<KlondikePile> for SavedKlondikePile {
fn from(p: KlondikePile) -> Self {
match p {
KlondikePile::Tableau(t) => Self::Tableau(t.into()),
KlondikePile::Stock => Self::Stock,
KlondikePile::Foundation(f) => Self::Foundation(f.into()),
}
}
}
impl From<TableauStack> for SavedTableauStack {
fn from(ts: TableauStack) -> Self {
Self {
tableau: ts.tableau.into(),
skip_cards: ts.skip_cards.into(),
}
}
}
impl From<KlondikePileStack> for SavedKlondikePileStack {
fn from(ps: KlondikePileStack) -> Self {
match ps {
KlondikePileStack::Tableau(ts) => Self::Tableau(ts.into()),
KlondikePileStack::Stock => Self::Stock,
KlondikePileStack::Foundation(f) => Self::Foundation(f.into()),
}
}
}
impl From<DstFoundation> for SavedDstFoundation {
fn from(df: DstFoundation) -> Self {
Self {
src: df.src.into(),
foundation: df.foundation.into(),
}
}
}
impl From<DstTableau> for SavedDstTableau {
fn from(dt: DstTableau) -> Self {
Self {
src: dt.src.into(),
tableau: dt.tableau.into(),
}
}
}
impl From<KlondikeInstruction> for SavedInstruction {
fn from(i: KlondikeInstruction) -> Self {
match i {
KlondikeInstruction::RotateStock => Self::RotateStock,
KlondikeInstruction::DstFoundation(df) => Self::DstFoundation(df.into()),
KlondikeInstruction::DstTableau(dt) => Self::DstTableau(dt.into()),
}
}
}
// ── TryFrom impls: Saved* → KlondikeInstruction ──────────────────────────────
impl TryFrom<SavedTableau> for Tableau {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedTableau) -> Result<Self, Self::Error> {
tableau_from_index(s.0 as usize).ok_or(InvalidSavedInstruction::Tableau(s.0))
}
}
impl TryFrom<SavedFoundation> for Foundation {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedFoundation) -> Result<Self, Self::Error> {
foundation_from_slot(s.0).ok_or(InvalidSavedInstruction::Foundation(s.0))
}
}
impl TryFrom<SavedSkipCards> for SkipCards {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedSkipCards) -> Result<Self, Self::Error> {
skip_cards_from_count(s.0 as usize).ok_or(InvalidSavedInstruction::SkipCards(s.0))
}
}
impl TryFrom<SavedKlondikePile> for KlondikePile {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedKlondikePile) -> Result<Self, Self::Error> {
Ok(match s {
SavedKlondikePile::Tableau(t) => KlondikePile::Tableau(t.try_into()?),
SavedKlondikePile::Stock => KlondikePile::Stock,
SavedKlondikePile::Foundation(f) => KlondikePile::Foundation(f.try_into()?),
})
}
}
impl TryFrom<SavedTableauStack> for TableauStack {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedTableauStack) -> Result<Self, Self::Error> {
Ok(TableauStack {
tableau: s.tableau.try_into()?,
skip_cards: s.skip_cards.try_into()?,
})
}
}
impl TryFrom<SavedKlondikePileStack> for KlondikePileStack {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedKlondikePileStack) -> Result<Self, Self::Error> {
Ok(match s {
SavedKlondikePileStack::Tableau(ts) => KlondikePileStack::Tableau(ts.try_into()?),
SavedKlondikePileStack::Stock => KlondikePileStack::Stock,
SavedKlondikePileStack::Foundation(f) => KlondikePileStack::Foundation(f.try_into()?),
})
}
}
impl TryFrom<SavedDstFoundation> for DstFoundation {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedDstFoundation) -> Result<Self, Self::Error> {
Ok(DstFoundation {
src: s.src.try_into()?,
foundation: s.foundation.try_into()?,
})
}
}
impl TryFrom<SavedDstTableau> for DstTableau {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedDstTableau) -> Result<Self, Self::Error> {
Ok(DstTableau {
src: s.src.try_into()?,
tableau: s.tableau.try_into()?,
})
}
}
impl TryFrom<SavedInstruction> for KlondikeInstruction {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedInstruction) -> Result<Self, Self::Error> {
Ok(match s {
SavedInstruction::RotateStock => KlondikeInstruction::RotateStock,
SavedInstruction::DstFoundation(df) => {
KlondikeInstruction::DstFoundation(df.try_into()?)
}
SavedInstruction::DstTableau(dt) => KlondikeInstruction::DstTableau(dt.try_into()?),
})
}
}
/// Time bonus added to the score on a win: `700_000 / elapsed_seconds`.
/// Returns 0 when `elapsed_seconds` is 0 to avoid division by zero.
pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
if elapsed_seconds == 0 {
return 0;
}
(700_000u64 / elapsed_seconds).min(i32::MAX as u64) as i32
}
+7 -4
View File
@@ -1,8 +1,8 @@
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;
pub mod scoring;
// Re-export the upstream types that cross the solitaire_core API boundary so // Re-export the upstream types that cross the solitaire_core API boundary so
// downstream crates (engine, wasm) can import from one place without a direct // downstream crates (engine, wasm) can import from one place without a direct
@@ -12,9 +12,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;
+3 -121
View File
@@ -1,14 +1,8 @@
use card_game::{Card, Game}; use card_game::{Card, Game};
use klondike::{Foundation, KlondikePile, KlondikeInstruction, SkipCards, Tableau}; use klondike::{DrawStockConfig, Foundation, KlondikePile, 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::{
InvalidSavedInstruction, SavedDstFoundation, SavedDstTableau, SavedFoundation,
SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, SavedSkipCards, SavedTableau,
SavedTableauStack,
};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Shared helpers // Shared helpers
@@ -52,8 +46,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.
@@ -262,116 +256,4 @@ proptest! {
} }
} }
// -------------------------------------------------------------------------
// SavedInstruction ↔ KlondikeInstruction round-trip
// -------------------------------------------------------------------------
/// Every valid `SavedInstruction` survives a round-trip through
/// `KlondikeInstruction::try_from(SavedInstruction::from(original))`.
///
/// Covers all three variants (`RotateStock`, `DstFoundation`, `DstTableau`)
/// and all legal sub-field ranges:
/// - `SavedTableau`: 06
/// - `SavedFoundation`: 03
/// - `SavedSkipCards`: 012
#[test]
fn saved_instruction_round_trip(
instruction in saved_instruction_strategy(),
) {
let klondike = KlondikeInstruction::try_from(instruction);
prop_assert!(
klondike.is_ok(),
"TryFrom failed for valid SavedInstruction {instruction:?}: {:?}",
klondike.err(),
);
let saved_again = SavedInstruction::from(klondike.expect("checked above"));
prop_assert_eq!(
saved_again,
instruction,
"round-trip produced a different SavedInstruction",
);
}
}
// ---------------------------------------------------------------------------
// Proptest strategies for SavedInstruction and its sub-types
// ---------------------------------------------------------------------------
fn saved_tableau_strategy() -> impl Strategy<Value = SavedTableau> {
(0u8..=6).prop_map(SavedTableau)
}
fn saved_foundation_strategy() -> impl Strategy<Value = SavedFoundation> {
(0u8..=3).prop_map(SavedFoundation)
}
fn saved_skip_cards_strategy() -> impl Strategy<Value = SavedSkipCards> {
(0u8..=12).prop_map(SavedSkipCards)
}
fn saved_klondike_pile_strategy() -> impl Strategy<Value = SavedKlondikePile> {
prop_oneof![
saved_tableau_strategy().prop_map(SavedKlondikePile::Tableau),
Just(SavedKlondikePile::Stock),
saved_foundation_strategy().prop_map(SavedKlondikePile::Foundation),
]
}
fn saved_klondike_pile_stack_strategy() -> impl Strategy<Value = SavedKlondikePileStack> {
prop_oneof![
(saved_tableau_strategy(), saved_skip_cards_strategy()).prop_map(|(tableau, skip_cards)| {
SavedKlondikePileStack::Tableau(SavedTableauStack { tableau, skip_cards })
}),
Just(SavedKlondikePileStack::Stock),
saved_foundation_strategy().prop_map(SavedKlondikePileStack::Foundation),
]
}
fn saved_instruction_strategy() -> impl Strategy<Value = SavedInstruction> {
prop_oneof![
Just(SavedInstruction::RotateStock),
(saved_klondike_pile_strategy(), saved_foundation_strategy()).prop_map(
|(src, foundation)| {
SavedInstruction::DstFoundation(SavedDstFoundation { src, foundation })
}
),
(saved_klondike_pile_stack_strategy(), saved_tableau_strategy()).prop_map(
|(src, tableau)| {
SavedInstruction::DstTableau(SavedDstTableau { src, tableau })
}
),
]
}
// ---------------------------------------------------------------------------
// Boundary error unit tests (exact out-of-range values)
// ---------------------------------------------------------------------------
#[cfg(test)]
mod saved_instruction_boundary_tests {
use super::*;
#[test]
fn saved_tableau_7_is_invalid() {
let result = Tableau::try_from(SavedTableau(7));
assert_eq!(result, Err(InvalidSavedInstruction::Tableau(7)));
}
#[test]
fn saved_tableau_255_is_invalid() {
let result = Tableau::try_from(SavedTableau(255));
assert_eq!(result, Err(InvalidSavedInstruction::Tableau(255)));
}
#[test]
fn saved_foundation_4_is_invalid() {
let result = Foundation::try_from(SavedFoundation(4));
assert_eq!(result, Err(InvalidSavedInstruction::Foundation(4)));
}
#[test]
fn saved_skip_cards_13_is_invalid() {
let result = SkipCards::try_from(SavedSkipCards(13));
assert_eq!(result, Err(InvalidSavedInstruction::SkipCards(13)));
}
} }
+15
View File
@@ -0,0 +1,15 @@
//! Ferrous-specific scoring policy layered on top of upstream `klondike`.
//!
//! Upstream [`klondike::KlondikeStats::score`] owns the per-move point values
//! (move-to-foundation, flip-up bonus, recycle penalty, etc.). The functions
//! here are the Ferrous Solitaire house rules that upstream has no opinion on —
//! currently just the win-time bonus shown in the win modal.
/// Time bonus added to the score on a win: `700_000 / elapsed_seconds`.
/// Returns 0 when `elapsed_seconds` is 0 to avoid division by zero.
pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
if elapsed_seconds == 0 {
return 0;
}
(700_000u64 / elapsed_seconds).min(i32::MAX as u64) as i32
}
+3
View File
@@ -47,3 +47,6 @@ sqlx = { workspace = true }
jsonwebtoken = { workspace = true } jsonwebtoken = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
[lints]
workspace = true
+65
View File
@@ -0,0 +1,65 @@
//! Safe JNI bridge for Android platform integration.
//!
//! Reconstructing the raw `JavaVM` / `NativeActivity` handles handed over by
//! the Android runtime requires `unsafe` FFI. That single reconstruction lives
//! in `solitaire_app`'s entry point ([`solitaire_app::android_main`]); this
//! module only ever stores the resulting *safe* [`JavaVM`] and activity
//! [`GlobalRef`] and hands callers an attached [`JNIEnv`].
//!
//! As a result every consumer of Android JNI — the keystore, clipboard, and
//! safe-area subsystems — goes through the safe functions here and stays
//! `forbid(unsafe_code)`. The only crate carrying `unsafe` is `solitaire_app`.
//!
//! Only compiled and linked on `target_os = "android"`.
use jni::objects::{GlobalRef, JObject};
use jni::{JNIEnv, JavaVM};
use std::sync::OnceLock;
static ANDROID_JVM: OnceLock<JavaVM> = OnceLock::new();
static ANDROID_ACTIVITY: OnceLock<GlobalRef> = OnceLock::new();
/// Store the process-wide [`JavaVM`]. Called once from Android startup
/// (`solitaire_app::android_main`); subsequent calls are ignored.
pub fn set_jvm(vm: JavaVM) {
let _ = ANDROID_JVM.set(vm);
}
/// Store a global reference to the `NativeActivity`. Called once from Android
/// startup; subsequent calls are ignored.
pub fn set_activity(activity: GlobalRef) {
let _ = ANDROID_ACTIVITY.set(activity);
}
/// Run `f` with a [`JNIEnv`] attached to the current thread.
///
/// Returns an error string if the bridge has not been initialised yet or the
/// thread cannot be attached. The closure's JNI errors are surfaced through the
/// same `String` channel so callers have a single error type to map.
pub fn with_env<F, R>(f: F) -> Result<R, String>
where
F: for<'local> FnOnce(&mut JNIEnv<'local>) -> jni::errors::Result<R>,
{
let vm = ANDROID_JVM
.get()
.ok_or_else(|| "Android JavaVM not initialised".to_string())?;
let mut env = vm
.attach_current_thread_permanently()
.map_err(|e| format!("attach_current_thread: {e}"))?;
f(&mut env).map_err(|e| format!("JNI: {e}"))
}
/// Run `f` with an attached [`JNIEnv`] and the `NativeActivity` object.
///
/// Like [`with_env`] but also resolves the cached activity global reference, so
/// callers that need to invoke instance methods on the activity (clipboard,
/// window insets) never touch a raw handle.
pub fn with_activity_env<F, R>(f: F) -> Result<R, String>
where
F: for<'local> FnOnce(&mut JNIEnv<'local>, &JObject<'local>) -> jni::errors::Result<R>,
{
let activity = ANDROID_ACTIVITY
.get()
.ok_or_else(|| "Android activity not initialised".to_string())?;
with_env(|env| f(env, activity.as_obj()))
}
+35 -67
View File
@@ -14,19 +14,16 @@
/// ///
/// Only compiled and linked on `target_os = "android"`. /// Only compiled and linked on `target_os = "android"`.
use jni::{ use jni::{
JNIEnv, JavaVM, JNIEnv,
objects::{JByteArray, JObject, JObjectArray, JValue, JValueOwned}, objects::{JByteArray, JObject, JObjectArray, JValue, JValueOwned},
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::ffi::c_void;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::OnceLock;
use crate::auth_tokens::TokenError; use crate::auth_tokens::TokenError;
const KEY_ALIAS: &str = "ferrous_solitaire_token_key"; const KEY_ALIAS: &str = "ferrous_solitaire_token_key";
static ANDROID_JVM: OnceLock<JavaVM> = OnceLock::new();
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
struct TokenBlob { struct TokenBlob {
@@ -39,43 +36,15 @@ struct TokenBlob {
// JVM helper // JVM helper
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Initialise Android Keystore access with the process-wide `JavaVM*`. /// Run `f` with an attached `JNIEnv`, delegating thread attach and the
/// /// `JavaVM` handle to the safe [`crate::android_jni`] bridge. The bridge is
/// This is called by `solitaire_app` from Android startup code. Keeping the /// initialised once from Android startup, so the keystore never touches a raw
/// raw JVM pointer here avoids making `solitaire_data` depend on the app or /// pointer and this module stays `forbid(unsafe_code)`.
/// engine layer just to reach platform startup state.
pub fn init_android_jvm(vm_ptr: *mut c_void) -> Result<(), TokenError> {
if vm_ptr.is_null() {
return Err(TokenError::KeychainUnavailable(
"JavaVM pointer is null".into(),
));
}
if ANDROID_JVM.get().is_some() {
return Ok(());
}
// SAFETY: `vm_ptr` is supplied by Android startup code and must be the
// process-wide JavaVM* for this app. `OnceLock` keeps the wrapper alive for
// the process lifetime.
let vm = unsafe { JavaVM::from_raw(vm_ptr.cast()) }
.map_err(|e| TokenError::Keyring(format!("JavaVM: {e}")))?;
let _ = ANDROID_JVM.set(vm);
Ok(())
}
fn with_jvm<F, R>(f: F) -> Result<R, TokenError> fn with_jvm<F, R>(f: F) -> Result<R, TokenError>
where where
F: for<'env> FnOnce(&mut JNIEnv<'env>) -> Result<R, jni::errors::Error>, F: for<'env> FnOnce(&mut JNIEnv<'env>) -> Result<R, jni::errors::Error>,
{ {
let vm = ANDROID_JVM crate::android_jni::with_env(f).map_err(TokenError::Keyring)
.get()
.ok_or_else(|| TokenError::KeychainUnavailable("Android JavaVM not initialised".into()))?;
let mut env = vm
.attach_current_thread_permanently()
.map_err(|e| TokenError::Keyring(format!("attach: {e}")))?;
f(&mut env).map_err(|e| TokenError::Keyring(format!("JNI: {e}")))
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -230,9 +199,10 @@ fn encrypt_gcm(
.v()?; .v()?;
// IV is generated by Android's provider; read it back after init. // IV is generated by Android's provider; read it back after init.
// `getIV()` returns `[B`; the safe `From<JObject>` reinterprets the
// returned object reference as a typed byte array.
let iv_jobj = env.call_method(&cipher, "getIV", "()[B", &[])?.l()?; let iv_jobj = env.call_method(&cipher, "getIV", "()[B", &[])?.l()?;
// SAFETY: the method signature guarantees a byte array return. let iv_arr = JByteArray::from(iv_jobj);
let iv_arr = unsafe { JByteArray::from_raw(iv_jobj.into_raw()) };
let iv = env.convert_byte_array(&iv_arr)?; let iv = env.convert_byte_array(&iv_arr)?;
let pt_arr = env.byte_array_from_slice(plaintext)?; let pt_arr = env.byte_array_from_slice(plaintext)?;
@@ -240,8 +210,7 @@ fn encrypt_gcm(
let ct_jobj = env let ct_jobj = env
.call_method(&cipher, "doFinal", "([B)[B", &[pt_val.borrow()])? .call_method(&cipher, "doFinal", "([B)[B", &[pt_val.borrow()])?
.l()?; .l()?;
// SAFETY: doFinal([B) returns [B. let ct_arr = JByteArray::from(ct_jobj);
let ct_arr = unsafe { JByteArray::from_raw(ct_jobj.into_raw()) };
let ciphertext = env.convert_byte_array(&ct_arr)?; let ciphertext = env.convert_byte_array(&ct_arr)?;
let mut out = Vec::with_capacity(iv.len() + ciphertext.len()); let mut out = Vec::with_capacity(iv.len() + ciphertext.len());
@@ -292,8 +261,7 @@ fn decrypt_gcm(
let pt_jobj = env let pt_jobj = env
.call_method(&cipher, "doFinal", "([B)[B", &[ct_val.borrow()])? .call_method(&cipher, "doFinal", "([B)[B", &[ct_val.borrow()])?
.l()?; .l()?;
// SAFETY: doFinal([B) returns [B. let pt_arr = JByteArray::from(pt_jobj);
let pt_arr = unsafe { JByteArray::from_raw(pt_jobj.into_raw()) };
env.convert_byte_array(&pt_arr) env.convert_byte_array(&pt_arr)
} }
@@ -380,29 +348,29 @@ fn read_map() -> Result<HashMap<String, TokenBlob>, TokenError> {
} }
// --- 2. Legacy path migration --- // --- 2. Legacy path migration ---
if let Some(ref lpath) = legacy_path { if let Some(ref lpath) = legacy_path
if lpath.exists() { && lpath.exists()
let data = read_file_bytes_from(lpath).map_err(|e| match e { {
TokenError::NotFound(_) => TokenError::NotFound(String::new()), let data = read_file_bytes_from(lpath).map_err(|e| match e {
other => other, TokenError::NotFound(_) => TokenError::NotFound(String::new()),
other => other,
})?;
if data.len() >= 12 {
let plaintext = with_jvm(|env| {
let key = load_or_create_key(env)?;
decrypt_gcm(env, &key, &data)
})?; })?;
if data.len() >= 12 { if let Ok(blob) = serde_json::from_slice::<TokenBlob>(&plaintext) {
let plaintext = with_jvm(|env| { let mut map = HashMap::new();
let key = load_or_create_key(env)?; map.insert(blob.username.clone(), blob);
decrypt_gcm(env, &key, &data) // Write to the new location, then remove the legacy file.
})?; if write_map_inner(&map).is_ok() {
if let Ok(blob) = serde_json::from_slice::<TokenBlob>(&plaintext) { let _ = std::fs::remove_file(lpath);
let mut map = HashMap::new();
map.insert(blob.username.clone(), blob);
// Write to the new location, then remove the legacy file.
if write_map_inner(&map).is_ok() {
let _ = std::fs::remove_file(lpath);
}
return Ok(map);
} }
return Ok(map);
} }
// Legacy file corrupt or unrecognised — treat as empty.
} }
// Legacy file corrupt or unrecognised — treat as empty.
} }
// --- 3. No file found --- // --- 3. No file found ---
@@ -491,11 +459,11 @@ pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
if map.is_empty() { if map.is_empty() {
// No more users — remove the file and the Keystore key. // No more users — remove the file and the Keystore key.
if let Some(path) = token_file_path() { if let Some(path) = token_file_path()
if path.exists() { && path.exists()
std::fs::remove_file(&path) {
.map_err(|e| TokenError::Keyring(format!("delete auth_tokens.bin: {e}")))?; std::fs::remove_file(&path)
} .map_err(|e| TokenError::Keyring(format!("delete auth_tokens.bin: {e}")))?;
} }
// Remove the Keystore key so a future re-login generates a fresh key. // Remove the Keystore key so a future re-login generates a fresh key.
+3 -2
View File
@@ -19,8 +19,9 @@
//! `keyring-core` cannot compile for the android target (its `rpassword` //! `keyring-core` cannot compile for the android target (its `rpassword`
//! transitive dep uses `libc::__errno_location`, which Android's bionic //! transitive dep uses `libc::__errno_location`, which Android's bionic
//! doesn't expose). On Android this module delegates to an Android Keystore //! doesn't expose). On Android this module delegates to an Android Keystore
//! JNI backend. `solitaire_app` must call `solitaire_data::init_android_jvm` //! JNI backend. `solitaire_app` must initialise the safe
//! from Android startup before token operations can succeed. //! [`crate::android_jni`] bridge (via `set_jvm` / `set_activity`) from Android
//! startup before token operations can succeed.
//! //!
//! # Note: no unit tests — requires live OS keychain. //! # Note: no unit tests — requires live OS keychain.
+12 -14
View File
@@ -58,7 +58,7 @@ pub trait SyncProvider: Send + Sync {
/// so backends without a server (e.g. `LocalOnlyProvider`) are /// so backends without a server (e.g. `LocalOnlyProvider`) are
/// silently no-op'd by the engine's push-on-win system, matching /// silently no-op'd by the engine's push-on-win system, matching
/// the same pattern `pull` / `push` follow. /// the same pattern `pull` / `push` follow.
async fn push_replay(&self, _replay: &crate::replay::Replay) -> Result<String, SyncError> { async fn push_replay(&self, _replay: &Replay) -> Result<String, SyncError> {
Err(SyncError::UnsupportedPlatform) Err(SyncError::UnsupportedPlatform)
} }
} }
@@ -94,17 +94,11 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
async fn delete_account(&self) -> Result<(), SyncError> { async fn delete_account(&self) -> Result<(), SyncError> {
(**self).delete_account().await (**self).delete_account().await
} }
async fn push_replay(&self, replay: &crate::replay::Replay) -> Result<String, SyncError> { async fn push_replay(&self, replay: &Replay) -> Result<String, SyncError> {
(**self).push_replay(replay).await (**self).push_replay(replay).await
} }
} }
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;
@@ -150,9 +144,10 @@ pub use settings::{
}; };
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
mod android_keystore; pub mod android_jni;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
pub use android_keystore::init_android_jvm; mod android_keystore;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub mod auth_tokens; pub mod auth_tokens;
@@ -169,11 +164,14 @@ pub use sync_client::{SolitaireServerClient, provider_for_backend};
pub mod replay; pub mod replay;
pub use replay::{ pub use replay::{
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay, REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay,
ReplayHistory, ReplayMove, append_replay_to_history, load_replay_history_from, ReplayHistory, 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;
+35 -5
View File
@@ -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`.
+57 -66
View File
@@ -12,13 +12,22 @@
//! carries any other version so older replays are silently dropped instead //! carries any other version so older replays are silently dropped instead
//! of crashing the loader. //! of crashing the loader.
//! //!
//! The recording is intentionally minimal — only [`ReplayMove`] entries //! The recording is intentionally minimal — only the
//! that successfully advanced the game. `Undo` is **not** recorded: a //! [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) inputs that
//! replay represents the canonical path the player ultimately took to win, //! successfully advanced the game. `Undo` is **not** recorded: a replay
//! so backed-out missteps simply do not appear in the move list. The //! represents the canonical path the player ultimately took to win, so
//! starting deal is not stored either — the [`seed`](Replay::seed) + //! backed-out missteps simply do not appear in the move list. The starting
//! deal is not stored either — the [`seed`](Replay::seed) +
//! [`draw_mode`](Replay::draw_mode) + [`mode`](Replay::mode) are sufficient //! [`draw_mode`](Replay::draw_mode) + [`mode`](Replay::mode) are sufficient
//! for `GameState::new_with_mode` to rebuild the identical layout. //! for `GameState::new_with_mode` to rebuild the identical layout.
//!
//! Each recorded move is the player's atomic *input*, not its outcome.
//! `KlondikeInstruction::RotateStock` covers every click on the stock pile;
//! the engine resolves draw-vs-recycle deterministically from the current
//! stock state during playback, so the same input always produces the same
//! effect on the same starting deal. Runtime-only pile-position types are
//! never serialised — the instruction itself serialises via its compact
//! upstream serde representation.
use std::fs; use std::fs;
use std::io; use std::io;
@@ -26,8 +35,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, KlondikeInstruction, game_state::GameMode};
use solitaire_core::klondike_adapter::SavedKlondikePile;
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json"; const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json"; const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
@@ -65,14 +73,17 @@ fn history_schema_v0() -> u32 {
/// seeing a broken one. /// seeing a broken one.
/// ///
/// History: /// History:
/// - v1: initial release. `ReplayMove` had separate `Draw` and `Recycle` /// - v1: initial release. The move type had separate `Draw` and `Recycle`
/// variants which carried the *outcome* of a stock interaction rather /// variants which carried the *outcome* of a stock interaction rather
/// than the player's atomic input. /// than the player's atomic input.
/// - v2 (current): `Draw` + `Recycle` collapsed into a single `StockClick` /// - v2: `Draw` + `Recycle` collapsed into a single `StockClick` variant.
/// variant. The engine resolves draw-vs-recycle deterministically from /// - v3 (current): the bespoke `ReplayMove` serde mirror was dropped. Moves
/// the current stock state, so the input alone is sufficient and the /// are now stored directly as upstream
/// replay model now stores atomic player inputs end-to-end. /// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) (compact
pub const REPLAY_SCHEMA_VERSION: u32 = 2; /// int serde); `StockClick` is now `RotateStock`. Pile-position types are
/// runtime-only and are never serialised. v1/v2 files fail to deserialise
/// and are discarded by the loader.
pub const REPLAY_SCHEMA_VERSION: u32 = 3;
/// Default value for [`Replay::schema_version`] when deserialising files /// Default value for [`Replay::schema_version`] when deserialising files
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`] /// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`]
@@ -81,32 +92,6 @@ fn schema_v0() -> u32 {
0 0
} }
/// One atomic player input recorded during a winning game, in the order
/// it was applied to the live `GameState`.
///
/// `Undo` is intentionally absent — see the module-level docs.
///
/// The variants represent *inputs*, not outcomes. `StockClick` covers
/// every player click on the stock pile; the engine then resolves
/// draw-vs-recycle deterministically from the current state during both
/// recording and playback, so the same input always produces the same
/// effect on the same starting deal.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReplayMove {
/// A successful `move_cards(from, to, count)` call.
Move {
/// Source pile.
from: SavedKlondikePile,
/// Destination pile.
to: SavedKlondikePile,
/// Number of cards moved.
count: usize,
},
/// A click on the stock pile. Resolves to a draw when stock is
/// non-empty and to a waste→stock recycle when stock is empty.
StockClick,
}
/// A complete recording of a single winning game. /// A complete recording of a single winning game.
/// ///
/// Replays are reconstructed by rebuilding a fresh /// Replays are reconstructed by rebuilding a fresh
@@ -124,7 +109,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
@@ -134,9 +119,11 @@ pub struct Replay {
pub final_score: i32, pub final_score: i32,
/// ISO-8601 date the win was recorded. /// ISO-8601 date the win was recorded.
pub recorded_at: NaiveDate, pub recorded_at: NaiveDate,
/// Ordered move list. Each entry is what the player did, replayable /// Ordered move list. Each entry is the atomic
/// against a fresh `GameState` constructed from the seed. /// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) the player
pub moves: Vec<ReplayMove>, /// issued, replayable against a fresh `GameState` constructed from the
/// seed via `GameState::apply_instruction`.
pub moves: Vec<KlondikeInstruction>,
/// Public share URL for this replay on the active sync backend, set /// Public share URL for this replay on the active sync backend, set
/// by `sync_plugin::poll_replay_upload_result` when the upload /// by `sync_plugin::poll_replay_upload_result` when the upload
/// task resolves. `None` when the player won on a local-only /// task resolves. `None` when the player won on a local-only
@@ -180,12 +167,12 @@ 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,
recorded_at: NaiveDate, recorded_at: NaiveDate,
moves: Vec<ReplayMove>, moves: Vec<KlondikeInstruction>,
) -> Self { ) -> Self {
Self { Self {
schema_version: REPLAY_SCHEMA_VERSION, schema_version: REPLAY_SCHEMA_VERSION,
@@ -442,7 +429,9 @@ pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
#[allow(deprecated)] #[allow(deprecated)]
mod tests { mod tests {
use super::*; use super::*;
use solitaire_core::klondike_adapter::{SavedFoundation, SavedTableau}; use klondike::{
DstFoundation, DstTableau, Foundation, KlondikePile, KlondikePileStack, Tableau,
};
use std::env; use std::env;
fn tmp_path(name: &str) -> PathBuf { fn tmp_path(name: &str) -> PathBuf {
@@ -453,24 +442,22 @@ 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,
date, date,
vec![ vec![
ReplayMove::StockClick, KlondikeInstruction::RotateStock,
ReplayMove::Move { KlondikeInstruction::DstTableau(DstTableau {
from: SavedKlondikePile::Stock, src: KlondikePileStack::Stock,
to: SavedKlondikePile::Tableau(SavedTableau(3)), tableau: Tableau::Tableau4,
count: 1, }),
}, KlondikeInstruction::RotateStock,
ReplayMove::StockClick, KlondikeInstruction::DstFoundation(DstFoundation {
ReplayMove::Move { src: KlondikePile::Tableau(Tableau::Tableau4),
from: SavedKlondikePile::Tableau(SavedTableau(3)), foundation: Foundation::Foundation1,
to: SavedKlondikePile::Foundation(SavedFoundation(0)), }),
count: 1,
},
], ],
) )
} }
@@ -596,12 +583,12 @@ 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,
date, date,
vec![ReplayMove::StockClick], vec![KlondikeInstruction::RotateStock],
) )
} }
@@ -837,9 +824,11 @@ mod tests {
let path = tmp_path("legacy_no_win_move_index"); let path = tmp_path("legacy_no_win_move_index");
let _ = fs::remove_file(&path); let _ = fs::remove_file(&path);
// Hand-rolled minimal v2 replay JSON with no win_move_index field. // Hand-rolled minimal current-schema replay JSON with no
let v2_no_field = r#"{ // win_move_index field — the additive field must still default to None.
"schema_version": 2, let no_field = format!(
r#"{{
"schema_version": {schema},
"seed": 1, "seed": 1,
"draw_mode": "DrawOne", "draw_mode": "DrawOne",
"mode": "Classic", "mode": "Classic",
@@ -847,8 +836,10 @@ mod tests {
"final_score": 100, "final_score": 100,
"recorded_at": "2026-05-02", "recorded_at": "2026-05-02",
"moves": [] "moves": []
}"#; }}"#,
fs::write(&path, v2_no_field).expect("write fixture"); schema = REPLAY_SCHEMA_VERSION,
);
fs::write(&path, no_field).expect("write fixture");
let loaded = load_latest_replay_from(&path).expect("load"); let loaded = load_latest_replay_from(&path).expect("load");
assert_eq!(loaded.win_move_index, None); assert_eq!(loaded.win_move_index, None);
+6 -6
View File
@@ -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,
-140
View File
@@ -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
View File
@@ -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,
+51 -39
View File
@@ -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,40 +546,53 @@ 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",
); );
} }
/// A schema v3 save (instruction history using u8 indices) must load /// A schema v3 save (instruction history using the old u8-index mirror
/// successfully and be transparently migrated to schema v4. /// types) is no longer loadable. The legacy migration path was dropped,
/// /// so any file claiming `schema_version: 3` must be rejected and the
/// This verifies the `AnyInstruction` untagged deserialization migration /// player started on a fresh game.
/// path. v3 files with `RotateStock` (unit variant, format-identical in
/// v3 and v4) load correctly and report `schema_version == 4` after load.
/// The `SavedInstruction` boundary tests in `proptest_tests.rs` cover the
/// u8-to-named conversion for `DstFoundation` / `DstTableau` indices.
#[test] #[test]
fn game_state_v3_migrates_to_v4() { fn game_state_v3_is_rejected() {
use solitaire_core::game_state::GameState; let path = gs_path("v3_reject");
let path = gs_path("v3_migrate");
let _ = fs::remove_file(&path); let _ = fs::remove_file(&path);
// Hand-crafted schema v3 JSON: one RotateStock (draw) instruction. // Hand-crafted schema v3 JSON: one RotateStock (draw) instruction.
// RotateStock serialises as the string "RotateStock" in both v3 and v4,
// so this exercises the schema version acceptance code path.
let v3_json = r#"{ let v3_json = r#"{
"draw_mode": "DrawOne", "draw_mode": "DrawOne",
"mode": "Classic", "mode": "Classic",
@@ -594,13 +607,12 @@ mod tests {
}"#; }"#;
fs::write(&path, v3_json).expect("write v3 fixture"); fs::write(&path, v3_json).expect("write v3 fixture");
let loaded = load_game_state_from(&path) assert!(
.expect("schema v3 must be accepted and migrated to v4"); load_game_state_from(&path).is_none(),
"schema v3 must be rejected (no migration path)",
);
// The loaded game should match a fresh game that had one draw applied. let _ = fs::remove_file(&path);
let mut expected = GameState::new(42, DrawMode::DrawOne);
expected.draw().expect("draw must succeed on a fresh game");
assert_eq!(loaded, expected, "migrated v3 game state must match equivalent v4 state");
} }
/// Schema v2 stored raw pile arrays and undo snapshots (no instruction /// Schema v2 stored raw pile arrays and undo snapshots (no instruction
+5 -5
View File
@@ -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,
} }
} }
+3
View File
@@ -52,3 +52,6 @@ web-sys = { version = "0.3", features = ["Storage", "Window"] }
async-trait = { workspace = true } async-trait = { workspace = true }
tempfile = { workspace = true } tempfile = { workspace = true }
solitaire_core = { workspace = true, features = ["test-support"] } solitaire_core = { workspace = true, features = ["test-support"] }
[lints]
workspace = true
+14 -14
View File
@@ -116,7 +116,7 @@ impl Plugin for AchievementPlugin {
// achievements-scroll system also runs cleanly under // achievements-scroll system also runs cleanly under
// `MinimalPlugins` in tests. // `MinimalPlugins` in tests.
.add_message::<MouseWheel>() .add_message::<MouseWheel>()
.add_message::<bevy::input::touch::TouchInput>() .add_message::<TouchInput>()
// Run after GameMutation (so GameWonEvent is available), after // Run after GameMutation (so GameWonEvent is available), after
// StatsUpdate (so stats reflect this win), and after ProgressUpdate // StatsUpdate (so stats reflect this win), and after ProgressUpdate
// (so daily_challenge_streak is up to date for daily_devotee). // (so daily_challenge_streak is up to date for daily_devotee).
@@ -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,
}; };
@@ -671,7 +671,7 @@ mod tests {
.add_plugins(AchievementPlugin::headless()); .add_plugins(AchievementPlugin::headless());
// StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under // StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under
// MinimalPlugins it isn't auto-registered. // MinimalPlugins it isn't auto-registered.
app.init_resource::<bevy::input::ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
app.update(); app.update();
app app
} }
@@ -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(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(DrawStockConfig::DrawThree);
app.world_mut().write_message(GameWonEvent { app.world_mut().write_message(GameWonEvent {
score: 500, score: 500,
@@ -912,7 +912,7 @@ mod tests {
// Put the active game in Zen mode. evaluate_on_win reads // Put the active game in Zen mode. evaluate_on_win reads
// GameStateResource.mode directly to populate last_win_is_zen. // GameStateResource.mode directly to populate last_win_is_zen.
app.world_mut().resource_mut::<GameStateResource>().0.mode = app.world_mut().resource_mut::<GameStateResource>().0.mode =
solitaire_core::game_state::GameMode::Zen; GameMode::Zen;
app.world_mut().write_message(GameWonEvent { app.world_mut().write_message(GameWonEvent {
score: 0, score: 0,
@@ -946,7 +946,7 @@ mod tests {
// Default GameMode is Classic; assert and rely on it. // Default GameMode is Classic; assert and rely on it.
assert_eq!( assert_eq!(
app.world().resource::<GameStateResource>().0.mode, app.world().resource::<GameStateResource>().0.mode,
solitaire_core::game_state::GameMode::Classic GameMode::Classic
); );
app.world_mut().write_message(GameWonEvent { app.world_mut().write_message(GameWonEvent {
@@ -1250,7 +1250,7 @@ mod tests {
.add_plugins(crate::progress_plugin::ProgressPlugin::headless()) .add_plugins(crate::progress_plugin::ProgressPlugin::headless())
.add_plugins(crate::settings_plugin::SettingsPlugin::headless()) .add_plugins(crate::settings_plugin::SettingsPlugin::headless())
.add_plugins(AchievementPlugin::headless()); .add_plugins(AchievementPlugin::headless());
app.init_resource::<bevy::input::ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
app.update(); app.update();
app app
} }
@@ -1393,8 +1393,8 @@ 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, KlondikeInstruction, game_state::GameMode};
use solitaire_data::{Replay, ReplayMove}; use solitaire_data::Replay;
/// Headless app variant that injects a default `ReplayPlaybackState` /// Headless app variant that injects a default `ReplayPlaybackState`
/// directly (no `ReplayPlaybackPlugin`) so we can drive the resource /// directly (no `ReplayPlaybackPlugin`) so we can drive the resource
@@ -1409,12 +1409,12 @@ 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,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"), NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![ReplayMove::StockClick], vec![KlondikeInstruction::RotateStock],
) )
} }
+7 -26
View File
@@ -1,37 +1,19 @@
/// Android clipboard bridge via JNI. /// Android clipboard bridge via JNI.
/// ///
/// Writes text to the system clipboard by calling into `ClipboardManager` /// Writes text to the system clipboard by calling into `ClipboardManager`
/// through the JNI. Only compiled and linked on `target_os = "android"`. /// through the safe [`solitaire_data::android_jni`] bridge. Only compiled and
/// linked on `target_os = "android"`.
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
pub fn set_text(text: &str) -> Result<(), String> { pub fn set_text(text: &str) -> Result<(), String> {
use bevy::android::ANDROID_APP; use jni::objects::JValueOwned;
use jni::{ use solitaire_data::android_jni;
JavaVM,
objects::{JObject, JValueOwned},
};
let app = ANDROID_APP android_jni::with_activity_env(|env, activity| {
.get()
.ok_or_else(|| "ANDROID_APP not initialized".to_string())?;
// SAFETY: vm_as_ptr() returns the raw JavaVM* set up by the Android runtime.
let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }
.map_err(|e| format!("JavaVM::from_raw: {e}"))?;
let mut env = vm
.attach_current_thread_permanently()
.map_err(|e| format!("attach_current_thread: {e}"))?;
// SAFETY: activity_as_ptr() is the NativeActivity jobject pointer —
// valid for the lifetime of the process.
let activity = unsafe { JObject::from_raw(app.activity_as_ptr() as _) };
(|| -> jni::errors::Result<()> {
// ClipboardManager cm = activity.getSystemService("clipboard") // ClipboardManager cm = activity.getSystemService("clipboard")
let svc_name = JValueOwned::from(env.new_string("clipboard")?); let svc_name = JValueOwned::from(env.new_string("clipboard")?);
let cm = env let cm = env
.call_method( .call_method(
&activity, activity,
"getSystemService", "getSystemService",
"(Ljava/lang/String;)Ljava/lang/Object;", "(Ljava/lang/String;)Ljava/lang/Object;",
&[svc_name.borrow()], &[svc_name.borrow()],
@@ -60,6 +42,5 @@ pub fn set_text(text: &str) -> Result<(), String> {
&[clip_val.borrow()], &[clip_val.borrow()],
)? )?
.v() .v()
})() })
.map_err(|e| format!("clipboard JNI: {e}"))
} }
+1 -1
View File
@@ -354,7 +354,7 @@ fn handle_win_cascade(
end: target.truncate(), end: target.truncate(),
elapsed: 0.0, elapsed: 0.0,
duration, duration,
curve: crate::card_animation::MotionCurve::Expressive, curve: MotionCurve::Expressive,
delay: i as f32 * step, delay: i as f32 * step,
start_z: start.z, start_z: start.z,
end_z: target.z, end_z: target.z,
+1 -1
View File
@@ -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).
+6
View File
@@ -115,6 +115,10 @@ macro_rules! embed_classic_svg {
} }
/// Every Dark-theme SVG file bundled into the binary. /// Every Dark-theme SVG file bundled into the binary.
// The `as &[u8]` in `embed_dark_svg!` coerces each fixed-size
// `&[u8; N]` (N varies per file) to a uniform `&[u8]` so the tuples fit
// this array type. The cast is load-bearing, not trivial.
#[allow(trivial_casts)]
const DARK_THEME_SVGS: &[(&str, &[u8])] = &[ const DARK_THEME_SVGS: &[(&str, &[u8])] = &[
embed_dark_svg!("back.svg"), embed_dark_svg!("back.svg"),
embed_dark_svg!("clubs_ace.svg"), embed_dark_svg!("clubs_ace.svg"),
@@ -172,6 +176,8 @@ const DARK_THEME_SVGS: &[(&str, &[u8])] = &[
]; ];
/// Every Classic-theme SVG file bundled into the binary. /// Every Classic-theme SVG file bundled into the binary.
// See `DARK_THEME_SVGS`: the `as &[u8]` cast is load-bearing.
#[allow(trivial_casts)]
const CLASSIC_THEME_SVGS: &[(&str, &[u8])] = &[ const CLASSIC_THEME_SVGS: &[(&str, &[u8])] = &[
embed_classic_svg!("back.svg"), embed_classic_svg!("back.svg"),
embed_classic_svg!("clubs_ace.svg"), embed_classic_svg!("clubs_ace.svg"),
+2 -2
View File
@@ -192,7 +192,7 @@ fn shared_fontdb() -> Arc<fontdb::Database> {
fn bundled_font_resolver() -> usvg::FontResolver<'static> { fn bundled_font_resolver() -> usvg::FontResolver<'static> {
use usvg::FontResolver; use usvg::FontResolver;
usvg::FontResolver { FontResolver {
select_font: Box::new(|_font, db| db.faces().next().map(|face| face.id)), select_font: Box::new(|_font, db| db.faces().next().map(|face| face.id)),
select_fallback: FontResolver::default_fallback_selector(), select_fallback: FontResolver::default_fallback_selector(),
} }
@@ -282,7 +282,7 @@ mod tests {
/// tightens. /// tightens.
#[test] #[test]
fn settings_satisfies_loader_bounds() { fn settings_satisfies_loader_bounds() {
fn assert_loader_settings<T: Default + serde::Serialize + serde::de::DeserializeOwned>() {} fn assert_loader_settings<T: Default + Serialize + serde::de::DeserializeOwned>() {}
assert_loader_settings::<SvgLoaderSettings>(); assert_loader_settings::<SvgLoaderSettings>();
} }
} }
+6 -6
View File
@@ -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();
@@ -177,13 +177,13 @@ mod tests {
.add_plugins(GamePlugin) .add_plugins(GamePlugin)
.add_plugins(TablePlugin) .add_plugins(TablePlugin)
.add_plugins(AutoCompletePlugin); .add_plugins(AutoCompletePlugin);
app.init_resource::<bevy::input::ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
app.update(); app.update();
app app
} }
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);
+1 -1
View File
@@ -36,7 +36,7 @@ pub struct AvatarFetchEvent {
pub url: String, pub url: String,
} }
impl bevy::prelude::Message for AvatarFetchEvent {} impl Message for AvatarFetchEvent {}
/// In-flight avatar download task. Returns the raw image bytes on success, /// In-flight avatar download task. Returns the raw image bytes on success,
/// or `None` on any network / decode error. /// or `None` on any network / decode error.
@@ -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;
@@ -73,7 +73,7 @@ pub struct HoverState {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum BufferedInput { pub enum BufferedInput {
Move { Move {
from: crate::events::MoveRequestEvent, from: MoveRequestEvent,
}, },
Draw, Draw,
Undo, Undo,
+55 -54
View File
@@ -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;
@@ -483,7 +483,7 @@ impl Plugin for CardPlugin {
update_stock_empty_indicator.after(GameMutation), update_stock_empty_indicator.after(GameMutation),
update_stock_count_badge update_stock_count_badge
.after(GameMutation) .after(GameMutation)
.run_if(resource_changed::<crate::GameStateResource>), .run_if(resource_changed::<GameStateResource>),
collect_resize_events.after(LayoutSystem::UpdateOnResize), collect_resize_events.after(LayoutSystem::UpdateOnResize),
snap_cards_on_window_resize.after(collect_resize_events), snap_cards_on_window_resize.after(collect_resize_events),
), ),
@@ -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)
}); });
@@ -2430,7 +2431,7 @@ fn update_tableau_fan_frac(
.into_iter() .into_iter()
.map(|tableau| { .map(|tableau| {
game.0 game.0
.pile(solitaire_core::KlondikePile::Tableau(tableau)) .pile(KlondikePile::Tableau(tableau))
.into_iter() .into_iter()
.filter(|(_, face_up)| *face_up) .filter(|(_, face_up)| *face_up)
.count() .count()
@@ -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, 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,13 +2574,13 @@ 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();
} }
let waste_ids: std::collections::HashSet<Card> = let waste_ids: HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect(); g.waste_cards().iter().map(|c| c.0.clone()).collect();
assert_eq!(waste_ids.len(), 3); assert_eq!(waste_ids.len(), 3);
@@ -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();
@@ -2623,7 +2624,7 @@ mod tests {
"need at least 3 waste cards for this test" "need at least 3 waste cards for this test"
); );
let waste_ids: std::collections::HashSet<Card> = let waste_ids: HashSet<Card> =
waste_pile.iter().map(|c| c.0.clone()).collect(); waste_pile.iter().map(|c| c.0.clone()).collect();
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);
@@ -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();
@@ -2676,7 +2677,7 @@ mod tests {
let count = waste_pile.len(); let count = waste_pile.len();
assert!(count >= 2, "need at least 2 waste cards"); assert!(count >= 2, "need at least 2 waste cards");
let waste_ids: std::collections::HashSet<Card> = let waste_ids: HashSet<Card> =
waste_pile.iter().map(|c| c.0.clone()).collect(); waste_pile.iter().map(|c| c.0.clone()).collect();
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);
@@ -2708,13 +2709,13 @@ 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();
} }
let waste_ids: std::collections::HashSet<Card> = let waste_ids: HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect(); g.waste_cards().iter().map(|c| c.0.clone()).collect();
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);
@@ -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, 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, 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);
@@ -3152,7 +3153,7 @@ mod tests {
fn fire_window_resize(app: &mut App, width: f32, height: f32) { fn fire_window_resize(app: &mut App, width: f32, height: f32) {
// Any Entity will do — the snap system reads only width/height. // Any Entity will do — the snap system reads only width/height.
let window = bevy::ecs::entity::Entity::from_raw_u32(0) let window = Entity::from_raw_u32(0)
.expect("Entity::from_raw_u32(0) is a valid placeholder"); .expect("Entity::from_raw_u32(0) is a valid placeholder");
app.world_mut().write_message(WindowResized { app.world_mut().write_message(WindowResized {
window, window,
@@ -3170,9 +3171,9 @@ mod tests {
// entity IDs must remain alive. // entity IDs must remain alive.
let mut app = app(); let mut app = app();
let labels_before: std::collections::HashSet<bevy::prelude::Entity> = app let labels_before: HashSet<Entity> = app
.world_mut() .world_mut()
.query_filtered::<bevy::prelude::Entity, With<CardLabel>>() .query_filtered::<Entity, With<CardLabel>>()
.iter(app.world()) .iter(app.world())
.collect(); .collect();
assert!( assert!(
@@ -3183,9 +3184,9 @@ mod tests {
fire_window_resize(&mut app, 1024.0, 768.0); fire_window_resize(&mut app, 1024.0, 768.0);
advance_past_resize_throttle(&mut app); advance_past_resize_throttle(&mut app);
let labels_after: std::collections::HashSet<bevy::prelude::Entity> = app let labels_after: HashSet<Entity> = app
.world_mut() .world_mut()
.query_filtered::<bevy::prelude::Entity, With<CardLabel>>() .query_filtered::<Entity, With<CardLabel>>()
.iter(app.world()) .iter(app.world())
.collect(); .collect();
@@ -3335,9 +3336,9 @@ mod tests {
// Each shadow's parent must be a CardEntity, so the child relation // Each shadow's parent must be a CardEntity, so the child relation
// is wired correctly. // is wired correctly.
let cards: HashSet<bevy::prelude::Entity> = app let cards: HashSet<Entity> = app
.world_mut() .world_mut()
.query_filtered::<bevy::prelude::Entity, With<CardEntity>>() .query_filtered::<Entity, With<CardEntity>>()
.iter(app.world()) .iter(app.world())
.collect(); .collect();
let mut q = app let mut q = app
@@ -3422,7 +3423,7 @@ mod tests {
let card_entity = { let card_entity = {
let mut q = app let mut q = app
.world_mut() .world_mut()
.query::<(bevy::prelude::Entity, &CardEntity)>(); .query::<(Entity, &CardEntity)>();
q.iter(app.world()) q.iter(app.world())
.find(|(_, c)| c.card == *card) .find(|(_, c)| c.card == *card)
.map(|(e, _)| e) .map(|(e, _)| e)
@@ -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, 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);
@@ -3552,9 +3553,9 @@ mod tests {
// Allocate five different strong handles by passing each a // Allocate five different strong handles by passing each a
// distinct dummy `Image`. We never render these; we only // distinct dummy `Image`. We never render these; we only
// compare ids. // compare ids.
let mut images = bevy::asset::Assets::<bevy::image::Image>::default(); let mut images = Assets::<Image>::default();
let backs: [Handle<bevy::image::Image>; 5] = let backs: [Handle<Image>; 5] =
std::array::from_fn(|_| images.add(bevy::image::Image::default())); std::array::from_fn(|_| images.add(Image::default()));
CardImageSet { CardImageSet {
faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())), faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())),
backs, backs,
@@ -3568,8 +3569,8 @@ mod tests {
// card must render with the theme's back regardless of which // card must render with the theme's back regardless of which
// legacy back the player picked in Settings. // legacy back the player picked in Settings.
let mut set = image_set_with_distinct_back_handles(); let mut set = image_set_with_distinct_back_handles();
let mut images = bevy::asset::Assets::<bevy::image::Image>::default(); let mut images = Assets::<Image>::default();
let theme_back: Handle<bevy::image::Image> = images.add(bevy::image::Image::default()); let theme_back: Handle<Image> = images.add(Image::default());
set.theme_back = Some(theme_back.clone()); set.theme_back = Some(theme_back.clone());
let face_down = make_card(Suit::Spades, Rank::Ace); let face_down = make_card(Suit::Spades, Rank::Ace);
@@ -3633,8 +3634,8 @@ mod tests {
use std::collections::HashMap; use std::collections::HashMap;
let mut set = image_set_with_distinct_back_handles(); let mut set = image_set_with_distinct_back_handles();
let mut images = bevy::asset::Assets::<bevy::image::Image>::default(); let mut images = Assets::<Image>::default();
let theme_back: Handle<bevy::image::Image> = images.add(bevy::image::Image::default()); let theme_back: Handle<Image> = images.add(Image::default());
let theme = CardTheme { let theme = CardTheme {
meta: ThemeMeta { meta: ThemeMeta {
@@ -3644,7 +3645,7 @@ mod tests {
version: "0".into(), version: "0".into(),
card_aspect: (2, 3), card_aspect: (2, 3),
}, },
faces: HashMap::<CardKey, Handle<bevy::image::Image>>::new(), faces: HashMap::<CardKey, Handle<Image>>::new(),
back: theme_back.clone(), back: theme_back.clone(),
}; };
@@ -3804,15 +3805,15 @@ 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();
} }
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);
let waste_ids: std::collections::HashSet<Card> = let waste_ids: HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect(); g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_zs: Vec<f32> = positions let mut waste_zs: Vec<f32> = positions
@@ -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();
} }
@@ -3862,7 +3863,7 @@ mod tests {
let stock_x = layout.pile_positions[&KlondikePile::Stock].x; let stock_x = layout.pile_positions[&KlondikePile::Stock].x;
let waste_ids: std::collections::HashSet<Card> = let waste_ids: HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect(); g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_positions: Vec<_> = card_positions(&g, &layout) let mut waste_positions: Vec<_> = card_positions(&g, &layout)
@@ -3884,15 +3885,15 @@ 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();
} }
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);
let waste_ids: std::collections::HashSet<Card> = let waste_ids: HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect(); g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_zs: Vec<f32> = positions let mut waste_zs: Vec<f32> = positions
+3 -3
View File
@@ -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,
+9 -9
View File
@@ -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};
@@ -80,7 +80,7 @@ impl Plugin for CursorPlugin {
Update, Update,
( (
update_cursor_icon, update_cursor_icon,
update_drop_highlights.run_if(resource_changed::<crate::resources::DragState>), update_drop_highlights.run_if(resource_changed::<DragState>),
update_drop_target_overlays, update_drop_target_overlays,
), ),
); );
@@ -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,
+1 -1
View File
@@ -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;
+17 -26
View File
@@ -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<(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();
+3 -2
View File
@@ -2,8 +2,9 @@
//! //!
//! Bundling rather than runtime-loading guarantees the canonical UI face is //! Bundling rather than runtime-loading guarantees the canonical UI face is
//! always available regardless of install or platform. The bytes are //! always available regardless of install or platform. The bytes are
//! validated at startup; a parse failure aborts the program with a clear //! validated at startup; a parse failure logs a warning and continues with
//! error because it means the binary is corrupt. //! glyph-less UI rather than aborting, since crashing on a corrupt embed is
//! worse than degraded text.
use bevy::prelude::*; use bevy::prelude::*;
+72 -73
View File
@@ -14,14 +14,12 @@ 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, KlondikeInstruction};
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::{
Replay, ReplayMove, SOLVER_DEAL_RETRY_CAP, append_replay_to_history, delete_game_state_at, Replay, SOLVER_DEAL_RETRY_CAP, append_replay_to_history, delete_game_state_at,
game_state_file_path, load_game_state_from, migrate_legacy_latest_replay, replay_history_path, game_state_file_path, load_game_state_from, migrate_legacy_latest_replay, replay_history_path,
save_game_state_to, save_game_state_to,
}; };
@@ -107,18 +105,21 @@ pub struct RestoreContinueButton;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct RestoreNewGameButton; pub struct RestoreNewGameButton;
/// In-memory accumulator for [`ReplayMove`] entries during the current /// In-memory accumulator for [`KlondikeInstruction`] entries during the
/// game. Cleared on every new-game start; frozen into a [`Replay`] and /// current game. Cleared on every new-game start; frozen into a [`Replay`]
/// flushed to disk by [`record_replay_on_win`] when the player wins. /// and flushed to disk by [`record_replay_on_win`] when the player wins.
/// ///
/// Recording captures only successful state-mutating events the player /// Recording captures only successful state-mutating events the player
/// drove (`MoveRequestEvent`, `DrawRequestEvent`). `UndoRequestEvent` is /// drove (`MoveRequestEvent`, `DrawRequestEvent`). `UndoRequestEvent` is
/// intentionally not recorded — see [`solitaire_data::replay`] for the /// intentionally not recorded — see [`solitaire_data::replay`] for the
/// design rationale. /// design rationale. Each entry is the atomic player input as a
/// [`KlondikeInstruction`] (a stock click is
/// [`KlondikeInstruction::RotateStock`]); pile-position types are
/// runtime-only and never persisted.
#[derive(Resource, Debug, Default, Clone)] #[derive(Resource, Debug, Default, Clone)]
pub struct RecordingReplay { pub struct RecordingReplay {
/// Ordered list of moves applied so far this game. /// Ordered list of instructions applied so far this game.
pub moves: Vec<ReplayMove>, pub moves: Vec<KlondikeInstruction>,
} }
impl RecordingReplay { impl RecordingReplay {
@@ -159,12 +160,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,
) )
}; };
@@ -200,7 +201,7 @@ impl Plugin for GamePlugin {
.add_message::<StateChangedEvent>() .add_message::<StateChangedEvent>()
.add_message::<crate::events::MoveRejectedEvent>() .add_message::<crate::events::MoveRejectedEvent>()
.add_message::<GameWonEvent>() .add_message::<GameWonEvent>()
.add_message::<crate::events::CardFlippedEvent>() .add_message::<CardFlippedEvent>()
.add_message::<crate::events::AchievementUnlockedEvent>() .add_message::<crate::events::AchievementUnlockedEvent>()
.add_message::<FoundationCompletedEvent>() .add_message::<FoundationCompletedEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
@@ -318,7 +319,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 +391,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,
@@ -529,7 +530,7 @@ fn handle_new_game(
// hides that information and reads naturally as "dealt from the // hides that information and reads naturally as "dealt from the
// deck." Skipped when LayoutResource isn't present (headless tests). // deck." Skipped when LayoutResource isn't present (headless tests).
if let Some(layout) = layout.as_ref() if let Some(layout) = layout.as_ref()
&& let Some(stock) = layout.0.pile_positions.get(&solitaire_core::KlondikePile::Stock) && let Some(stock) = layout.0.pile_positions.get(&KlondikePile::Stock)
{ {
for mut tx in &mut card_transforms { for mut tx in &mut card_transforms {
tx.translation.x = stock.x; tx.translation.x = stock.x;
@@ -826,14 +827,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);
@@ -853,7 +854,7 @@ fn handle_draw(
// the click happens — re-executing on the same starting // the click happens — re-executing on the same starting
// deal produces the same effect, so the input alone is // deal produces the same effect, so the input alone is
// sufficient to recover the move on playback. // sufficient to recover the move on playback.
recording.moves.push(ReplayMove::StockClick); recording.moves.push(KlondikeInstruction::RotateStock);
changed.write(StateChangedEvent); changed.write(StateChangedEvent);
} }
Err(e) => warn!("draw rejected: {e}"), Err(e) => warn!("draw rejected: {e}"),
@@ -867,7 +868,7 @@ fn handle_move(
mut game: ResMut<GameStateResource>, mut game: ResMut<GameStateResource>,
mut changed: MessageWriter<StateChangedEvent>, mut changed: MessageWriter<StateChangedEvent>,
mut won: MessageWriter<GameWonEvent>, mut won: MessageWriter<GameWonEvent>,
mut flipped: MessageWriter<crate::events::CardFlippedEvent>, mut flipped: MessageWriter<CardFlippedEvent>,
mut foundation_done: MessageWriter<FoundationCompletedEvent>, mut foundation_done: MessageWriter<FoundationCompletedEvent>,
mut recording: ResMut<RecordingReplay>, mut recording: ResMut<RecordingReplay>,
path: Option<Res<GameStatePath>>, path: Option<Res<GameStatePath>>,
@@ -891,18 +892,24 @@ fn handle_move(
// Record the move in the in-flight replay buffer. Done // Record the move in the in-flight replay buffer. Done
// first so the entry is captured even if a subsequent // first so the entry is captured even if a subsequent
// event-write or pile-lookup happens to bail out below. // event-write or pile-lookup happens to bail out below.
recording.moves.push(ReplayMove::Move { // `move_cards` resolved the pile coordinates to a
from: ev.from.into(), // `KlondikeInstruction` and pushed it onto the session
to: ev.to.into(), // history; recover that exact instruction from the tail
count: ev.count, // (no clone — the instruction is `Copy`). Pile-position
}); // types are runtime-only, so we persist the instruction
// rather than the (from, to, count) triple.
if let Some(instruction) =
game.0.session().history().last().map(|s| *s.instruction())
{
recording.moves.push(instruction);
}
// Fire flip event if the candidate card is now face-up. // Fire flip event if the candidate card is now face-up.
if let Some(fcard) = flip_candidate if let Some(fcard) = flip_candidate
&& pile_cards(&game.0, &ev.from) && pile_cards(&game.0, &ev.from)
.last() .last()
.is_some_and(|c| c.0 == fcard && c.1) .is_some_and(|c| c.0 == fcard && c.1)
{ {
flipped.write(crate::events::CardFlippedEvent(fcard)); flipped.write(CardFlippedEvent(fcard));
} }
// If this move landed on a foundation pile and that pile is // If this move landed on a foundation pile and that pile is
// now complete (Ace → King, 13 cards), fire the per-suit // now complete (Ace → King, 13 cards), fire the per-suit
@@ -920,7 +927,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 +1022,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 +1124,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());
} }
} }
} }
@@ -1303,7 +1310,6 @@ fn save_game_state_on_exit(
mod tests { mod tests {
use super::*; use super::*;
use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
/// Build a minimal headless `App` with just `GamePlugin` installed. /// Build a minimal headless `App` with just `GamePlugin` installed.
/// Disables persistence and overrides the seed so tests are deterministic /// Disables persistence and overrides the seed so tests are deterministic
@@ -1326,7 +1332,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 +1399,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 +1415,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
@@ -1524,7 +1530,7 @@ mod tests {
// Persistence tests // Persistence tests
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
fn tmp_gs_path(name: &str) -> std::path::PathBuf { fn tmp_gs_path(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("engine_test_gs_{name}.json")) std::env::temp_dir().join(format!("engine_test_gs_{name}.json"))
} }
@@ -1542,7 +1548,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 +1567,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 +1657,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.
{ {
@@ -1678,7 +1684,7 @@ mod tests {
let events = app let events = app
.world() .world()
.resource::<Messages<crate::events::CardFlippedEvent>>(); .resource::<Messages<CardFlippedEvent>>();
let mut cursor = events.get_cursor(); let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).collect(); let fired: Vec<_> = cursor.read(events).collect();
assert!( assert!(
@@ -1695,7 +1701,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 +1714,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 +1750,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 +1794,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 +1984,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,
@@ -2104,7 +2110,7 @@ mod tests {
1, 1,
"only the draw is recorded; the undo does not erase it nor add a new entry", "only the draw is recorded; the undo does not erase it nor add a new entry",
); );
assert!(matches!(recording.moves[0], ReplayMove::StockClick)); assert!(matches!(recording.moves[0], KlondikeInstruction::RotateStock));
} }
/// Starting a new game wipes the recording so the next deal begins /// Starting a new game wipes the recording so the next deal begins
@@ -2156,16 +2162,16 @@ mod tests {
let mut app = test_app(7654); let mut app = test_app(7654);
app.insert_resource(ReplayPath(Some(path.clone()))); app.insert_resource(ReplayPath(Some(path.clone())));
// Push two recorded moves manually so we can verify they survive // Push two recorded instructions manually so we can verify they
// the freeze/save round-trip without having to drive a real win. // survive the freeze/save round-trip without having to drive a
// real win. Both are `RotateStock` — the only instruction
// constructible without the runtime-only `klondike` pile-stack
// types (which the engine intentionally does not depend on); the
// round-trip shape is identical for any instruction variant.
{ {
let mut recording = app.world_mut().resource_mut::<RecordingReplay>(); let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
recording.moves.push(ReplayMove::StockClick); recording.moves.push(KlondikeInstruction::RotateStock);
recording.moves.push(ReplayMove::Move { recording.moves.push(KlondikeInstruction::RotateStock);
from: SavedKlondikePile::Stock,
to: SavedKlondikePile::Tableau(SavedTableau(2)),
count: 1,
});
} }
// Fire the win event the engine emits when the last foundation // Fire the win event the engine emits when the last foundation
@@ -2187,7 +2193,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!(
@@ -2199,15 +2205,8 @@ mod tests {
"time_seconds must come from the win event" "time_seconds must come from the win event"
); );
assert_eq!(loaded.moves.len(), 2, "every recorded move must round-trip"); assert_eq!(loaded.moves.len(), 2, "every recorded move must round-trip");
assert!(matches!(loaded.moves[0], ReplayMove::StockClick)); assert!(matches!(loaded.moves[0], KlondikeInstruction::RotateStock));
match &loaded.moves[1] { assert!(matches!(loaded.moves[1], KlondikeInstruction::RotateStock));
ReplayMove::Move { from, to, count } => {
assert_eq!(*from, SavedKlondikePile::Stock);
assert_eq!(*to, SavedKlondikePile::Tableau(SavedTableau(2)));
assert_eq!(*count, 1);
}
other => panic!("second entry must be a Move, got {other:?}"),
}
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
@@ -2231,7 +2230,7 @@ mod tests {
{ {
let mut recording = app.world_mut().resource_mut::<RecordingReplay>(); let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
recording.moves.clear(); recording.moves.clear();
recording.moves.push(ReplayMove::StockClick); recording.moves.push(KlondikeInstruction::RotateStock);
} }
app.world_mut().write_message(GameWonEvent { app.world_mut().write_message(GameWonEvent {
score: 100, score: 100,
@@ -2243,8 +2242,8 @@ mod tests {
{ {
let mut recording = app.world_mut().resource_mut::<RecordingReplay>(); let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
recording.moves.clear(); recording.moves.clear();
recording.moves.push(ReplayMove::StockClick); recording.moves.push(KlondikeInstruction::RotateStock);
recording.moves.push(ReplayMove::StockClick); recording.moves.push(KlondikeInstruction::RotateStock);
} }
app.world_mut().write_message(GameWonEvent { app.world_mut().write_message(GameWonEvent {
score: 200, score: 200,
@@ -2328,7 +2327,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 +2404,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"
+1 -1
View File
@@ -51,7 +51,7 @@ impl Plugin for HelpPlugin {
// plugin under `DefaultPlugins`; register them explicitly so // plugin under `DefaultPlugins`; register them explicitly so
// scroll systems run cleanly under `MinimalPlugins` in tests. // scroll systems run cleanly under `MinimalPlugins` in tests.
.add_message::<MouseWheel>() .add_message::<MouseWheel>()
.add_message::<bevy::input::touch::TouchInput>() .add_message::<TouchInput>()
.add_systems( .add_systems(
Update, Update,
( (
+7 -7
View File
@@ -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 {
@@ -1878,7 +1878,7 @@ mod tests {
let states: Vec<(HomeMode, bool)> = app let states: Vec<(HomeMode, bool)> = app
.world_mut() .world_mut()
.query::<(&HomeModeCard, bevy::ecs::query::Has<Disabled>)>() .query::<(&HomeModeCard, Has<Disabled>)>()
.iter(app.world()) .iter(app.world())
.map(|(c, d)| (c.0, d)) .map(|(c, d)| (c.0, d))
.collect(); .collect();
+43 -43
View File
@@ -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"))]
@@ -36,7 +36,6 @@ use crate::game_plugin::GameMutation;
use crate::input_plugin::TouchDragSet; use crate::input_plugin::TouchDragSet;
use crate::layout::HUD_BAND_HEIGHT; use crate::layout::HUD_BAND_HEIGHT;
use crate::layout::LayoutSystem; use crate::layout::LayoutSystem;
#[cfg(target_os = "android")]
use crate::pause_plugin::PausedResource; use crate::pause_plugin::PausedResource;
use crate::platform::{SHOW_KEYBOARD_ACCELERATORS, USE_TOUCH_UI_LAYOUT}; use crate::platform::{SHOW_KEYBOARD_ACCELERATORS, USE_TOUCH_UI_LAYOUT};
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
@@ -174,7 +173,7 @@ pub enum HudVisibility {
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
#[derive(Resource, Debug, Default)] #[derive(Resource, Debug, Default)]
struct HudTapTracker { struct HudTapTracker {
start_pos: Option<bevy::math::Vec2>, start_pos: Option<Vec2>,
/// Set `true` when the finger-down hit an action button so the /// Set `true` when the finger-down hit an action button so the
/// finger-up never toggles bar visibility. /// finger-up never toggles bar visibility.
started_on_button: bool, started_on_button: bool,
@@ -529,7 +528,7 @@ impl Plugin for HudPlugin {
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
{ {
app.init_resource::<HudTapTracker>() app.init_resource::<HudTapTracker>()
.add_message::<bevy::input::touch::TouchInput>() .add_message::<TouchInput>()
.add_systems( .add_systems(
Update, Update,
toggle_hud_on_tap toggle_hud_on_tap
@@ -1140,7 +1139,7 @@ fn handle_help_button(
fn handle_hint_button( fn handle_hint_button(
interaction_query: Query<&Interaction, (With<HintButton>, Changed<Interaction>)>, interaction_query: Query<&Interaction, (With<HintButton>, Changed<Interaction>)>,
paused: Option<Res<crate::PausedResource>>, paused: Option<Res<PausedResource>>,
game: Option<Res<GameStateResource>>, game: Option<Res<GameStateResource>>,
solver_config: Option<Res<crate::input_plugin::HintSolverConfig>>, solver_config: Option<Res<crate::input_plugin::HintSolverConfig>>,
mut pending_hint: Option<ResMut<crate::pending_hint::PendingHintTask>>, mut pending_hint: Option<ResMut<crate::pending_hint::PendingHintTask>>,
@@ -1818,7 +1817,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 +2274,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 +2283,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 +2310,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 +2324,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 +2333,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 {
@@ -2658,8 +2657,9 @@ fn resize_action_bar_labels(
} }
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
#[allow(clippy::too_many_arguments)]
fn toggle_hud_on_tap( fn toggle_hud_on_tap(
mut touch_events: MessageReader<bevy::input::touch::TouchInput>, mut touch_events: MessageReader<TouchInput>,
drag: Res<DragState>, drag: Res<DragState>,
scrims: Query<(), With<ModalScrim>>, scrims: Query<(), With<ModalScrim>>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
@@ -2697,13 +2697,14 @@ fn toggle_hud_on_tap(
// regardless of whether we toggle. // regardless of whether we toggle.
let on_button = tracker.started_on_button || game_consumed.0; let on_button = tracker.started_on_button || game_consumed.0;
game_consumed.0 = false; game_consumed.0 = false;
if let Some(start) = tracker.start_pos.take() { if let Some(start) = tracker.start_pos.take()
if !on_button && (event.position - start).length() < HUD_TAP_SLOP_PX { && !on_button
*hud_vis = match *hud_vis { && (event.position - start).length() < HUD_TAP_SLOP_PX
HudVisibility::Visible => HudVisibility::Hidden, {
HudVisibility::Hidden => HudVisibility::Visible, *hud_vis = match *hud_vis {
}; HudVisibility::Visible => HudVisibility::Hidden,
} HudVisibility::Hidden => HudVisibility::Visible,
};
} }
tracker.started_on_button = false; tracker.started_on_button = false;
} }
@@ -2726,7 +2727,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 +2748,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 +2764,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 +2785,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 +2795,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 +2916,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 +2931,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 +2946,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 +2984,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 +3038,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 +3048,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 +3056,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 +3067,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 +3108,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 +3129,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 +3155,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 +3231,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),
+67 -63
View File
@@ -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),
@@ -2424,10 +2428,10 @@ mod tests {
app.init_resource::<HintSolverConfig>(); app.init_resource::<HintSolverConfig>();
app.init_resource::<crate::pending_hint::PendingHintTask>(); app.init_resource::<crate::pending_hint::PendingHintTask>();
app.init_resource::<ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
app.insert_resource(crate::layout::LayoutResource( app.insert_resource(LayoutResource(
crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true), 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.
+1 -1
View File
@@ -745,7 +745,7 @@ mod tests {
); );
// The HUD band top clearance (distance from window top to card top) // The HUD band top clearance (distance from window top to card top)
// must match as well — this is the quantity directly visible in Bug 2. // must match as well — this is the quantity directly visible in Bug 2.
let card_top = |layout: &super::Layout| { let card_top = |layout: &Layout| {
layout.pile_positions[&KlondikePile::Stock].y + layout.card_size.y / 2.0 layout.pile_positions[&KlondikePile::Stock].y + layout.card_size.y / 2.0
}; };
assert!( assert!(
+2 -2
View File
@@ -862,7 +862,7 @@ fn handle_display_name_confirm(
.leaderboard_display_name .leaderboard_display_name
.clone() .clone()
.unwrap_or_else(|| { .unwrap_or_else(|| {
if let solitaire_data::settings::SyncBackend::SolitaireServer { if let SyncBackend::SolitaireServer {
ref username, ref username,
.. ..
} = settings.0.sync_backend } = settings.0.sync_backend
@@ -1091,7 +1091,7 @@ mod tests {
.add_plugins(crate::achievement_plugin::AchievementPlugin::headless()) .add_plugins(crate::achievement_plugin::AchievementPlugin::headless())
.add_plugins(SyncPlugin::new(NoOpProvider)) .add_plugins(SyncPlugin::new(NoOpProvider))
.add_plugins(LeaderboardPlugin); .add_plugins(LeaderboardPlugin);
app.init_resource::<bevy::input::ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
app.update(); app.update();
app app
} }
+21 -21
View File
@@ -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();
+4 -5
View File
@@ -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 [
+6 -7
View File
@@ -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,
+2 -2
View File
@@ -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,
+9 -10
View File
@@ -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 [
+29 -21
View File
@@ -1,10 +1,8 @@
use super::ReplayPlaybackState; use super::ReplayPlaybackState;
use chrono::Datelike; use chrono::Datelike;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
use solitaire_core::klondike_adapter::SavedKlondikePile; use solitaire_core::{Card, Rank, Suit};
use solitaire_data::ReplayMove; use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
/// Pure helper — formats the `GAME #YYYY-DDD` caption for the given /// Pure helper — formats the `GAME #YYYY-DDD` caption for the given
/// state. Returns `None` for `Inactive` / `Completed` (the replay is /// state. Returns `None` for `Inactive` / `Completed` (the replay is
@@ -60,12 +58,6 @@ pub(crate) fn format_pile(p: &KlondikePile) -> String {
} }
} }
pub(crate) fn format_saved_pile(p: &SavedKlondikePile) -> String {
KlondikePile::try_from(*p)
.map(|pile| format_pile(&pile))
.unwrap_or_else(|_| "unknown pile".to_string())
}
fn foundation_number(foundation: Foundation) -> u8 { fn foundation_number(foundation: Foundation) -> u8 {
match foundation { match foundation {
Foundation::Foundation1 => 1, Foundation::Foundation1 => 1,
@@ -87,20 +79,36 @@ fn tableau_number(tableau: Tableau) -> u8 {
} }
} }
/// Pure helper — formats a [`ReplayMove`] as the body of a /// Pure helper — formats a [`KlondikeInstruction`] as the body of a
/// move-log row. `StockClick` reads as `"stock cycle"`; `Move` /// move-log row. `RotateStock` reads as `"stock cycle"`; a `Dst*`
/// reads as `"{from} → {to}"` using [`format_pile`] for both /// instruction reads as `"{from} → {to}"` using [`format_pile`] for
/// endpoints. The `count` field is omitted from the row body — /// each nameable endpoint. The card count is omitted from the row
/// at row scale it adds visual noise without meaningful /// body — at row scale it adds visual noise without meaningful
/// information for the typical 1-card moves. /// information for the typical 1-card moves.
pub(crate) fn format_move_body(m: &ReplayMove) -> String { ///
match m { /// The destination pile is always recoverable directly from the
ReplayMove::StockClick => "stock cycle".to_string(), /// instruction. The source pile is shown when it is statically
ReplayMove::Move { from, to, .. } => { /// nameable (a `DstFoundation` carries a [`KlondikePile`] source);
/// a `DstTableau`'s source is the runtime-only `KlondikePileStack`
/// type — not re-exported across the `solitaire_core` boundary and so
/// not pattern-matchable here — so its row renders `"→ {to}"` without
/// a leading source label. Faithful full-coordinate decoding lives in
/// [`GameState::instruction_to_piles`] on the playback path; the
/// move-log is a display-only digest.
pub(crate) fn format_move_body(instruction: &KlondikeInstruction) -> String {
match instruction {
KlondikeInstruction::RotateStock => "stock cycle".to_string(),
KlondikeInstruction::DstFoundation(dst) => {
format!( format!(
"{} \u{2192} {}", "{} \u{2192} {}",
format_saved_pile(from), format_pile(&dst.src),
format_saved_pile(to) format_pile(&KlondikePile::Foundation(dst.foundation))
)
}
KlondikeInstruction::DstTableau(dst) => {
format!(
"\u{2192} {}",
format_pile(&KlondikePile::Tableau(dst.tableau))
) )
} }
} }
+21 -3
View File
@@ -8,6 +8,7 @@ use crate::replay_playback::{
ReplayPlaybackState, step_backwards_replay_playback, step_replay_playback, ReplayPlaybackState, step_backwards_replay_playback, step_replay_playback,
stop_replay_playback, toggle_pause_replay_playback, stop_replay_playback, toggle_pause_replay_playback,
}; };
use crate::resources::GameStateResource;
/// Per-arrow-key time-since-last-fire accumulators that drive the /// Per-arrow-key time-since-last-fire accumulators that drive the
/// continuous-scrub repeat behaviour for held arrow keys. Each /// continuous-scrub repeat behaviour for held arrow keys. Each
@@ -1033,6 +1034,7 @@ pub(crate) fn handle_pause_button(
/// guard lives inside `step_replay_playback`. /// guard lives inside `step_replay_playback`.
pub(crate) fn handle_step_button( pub(crate) fn handle_step_button(
mut state: ResMut<ReplayPlaybackState>, mut state: ResMut<ReplayPlaybackState>,
game: Option<Res<GameStateResource>>,
mut moves_writer: MessageWriter<MoveRequestEvent>, mut moves_writer: MessageWriter<MoveRequestEvent>,
mut draws_writer: MessageWriter<DrawRequestEvent>, mut draws_writer: MessageWriter<DrawRequestEvent>,
buttons: Query<&Interaction, (With<ReplayStepButton>, Changed<Interaction>)>, buttons: Query<&Interaction, (With<ReplayStepButton>, Changed<Interaction>)>,
@@ -1040,7 +1042,12 @@ pub(crate) fn handle_step_button(
if !buttons.iter().any(|i| *i == Interaction::Pressed) { if !buttons.iter().any(|i| *i == Interaction::Pressed) {
return; return;
} }
step_replay_playback(&mut state, &mut moves_writer, &mut draws_writer); step_replay_playback(
&mut state,
game.as_deref(),
&mut moves_writer,
&mut draws_writer,
);
} }
/// Repaints the Pause / Resume button's label whenever /// Repaints the Pause / Resume button's label whenever
@@ -1112,6 +1119,7 @@ pub(crate) fn handle_pause_keyboard(
pub(crate) fn handle_arrow_keyboard( pub(crate) fn handle_arrow_keyboard(
keys: Option<Res<ButtonInput<KeyCode>>>, keys: Option<Res<ButtonInput<KeyCode>>>,
time: Res<Time>, time: Res<Time>,
game: Option<Res<GameStateResource>>,
mut hold: ResMut<ReplayScrubKeyHold>, mut hold: ResMut<ReplayScrubKeyHold>,
mut state: ResMut<ReplayPlaybackState>, mut state: ResMut<ReplayPlaybackState>,
mut moves_writer: MessageWriter<MoveRequestEvent>, mut moves_writer: MessageWriter<MoveRequestEvent>,
@@ -1136,12 +1144,22 @@ pub(crate) fn handle_arrow_keyboard(
// Right (forward step) — initial press fires immediately; // Right (forward step) — initial press fires immediately;
// held repeats fire when the accumulator crosses the interval. // held repeats fire when the accumulator crosses the interval.
if keys.just_pressed(KeyCode::ArrowRight) { if keys.just_pressed(KeyCode::ArrowRight) {
step_replay_playback(&mut state, &mut moves_writer, &mut draws_writer); step_replay_playback(
&mut state,
game.as_deref(),
&mut moves_writer,
&mut draws_writer,
);
hold.right_held_secs = 0.0; hold.right_held_secs = 0.0;
} else if keys.pressed(KeyCode::ArrowRight) { } else if keys.pressed(KeyCode::ArrowRight) {
hold.right_held_secs += dt; hold.right_held_secs += dt;
if hold.right_held_secs >= SCRUB_REPEAT_INTERVAL_SECS { if hold.right_held_secs >= SCRUB_REPEAT_INTERVAL_SECS {
step_replay_playback(&mut state, &mut moves_writer, &mut draws_writer); step_replay_playback(
&mut state,
game.as_deref(),
&mut moves_writer,
&mut draws_writer,
);
hold.right_held_secs = 0.0; hold.right_held_secs = 0.0;
} }
} else { } else {
+22 -24
View File
@@ -1,24 +1,25 @@
use super::*; use super::*;
use chrono::NaiveDate; use chrono::NaiveDate;
use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::{DrawStockConfig, game_state::GameMode};
use solitaire_core::card::{Rank, Suit}; use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
use solitaire_core::{DrawMode, game_state::GameMode}; use solitaire_core::{Rank, Suit};
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau}; use solitaire_data::Replay;
use solitaire_data::{Replay, ReplayMove};
/// Build a minimal but well-formed [`Replay`] with `move_count` no-op /// Build a minimal but well-formed [`Replay`] with `move_count` no-op
/// `StockClick` entries. Tests only ever read `replay.moves.len()` /// `RotateStock` entries. Tests only ever read `replay.moves.len()`
/// (denominator of the progress indicator), so the move kind is /// (denominator of the progress indicator), so the move kind is
/// irrelevant beyond producing the right count. /// irrelevant beyond producing the right count.
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,
NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"), NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
(0..move_count).map(|_| ReplayMove::StockClick).collect(), (0..move_count)
.map(|_| KlondikeInstruction::RotateStock)
.collect(),
) )
} }
@@ -1123,20 +1124,17 @@ fn format_pile_uses_one_indexed_lowercase_names() {
); );
} }
/// Move-body formatter renders `StockClick` as a label and /// Move-body formatter renders `RotateStock` as a label. The
/// `Move` as a `from → to` arrow. The `count` field is /// `Dst*` variants render as a `→ to` arrow, but their pile-stack
/// deliberately omitted — at row scale it adds noise. /// source types are runtime-only and not constructible from this
/// crate, so only the stock-cycle label is asserted here; the
/// arrow path is exercised end-to-end through the move-log
/// integration tests.
#[test] #[test]
fn format_move_body_handles_both_variants() { fn format_move_body_handles_stock_cycle() {
assert_eq!(format_move_body(&ReplayMove::StockClick), "stock cycle");
assert_eq!( assert_eq!(
format_move_body(&ReplayMove::Move { format_move_body(&KlondikeInstruction::RotateStock),
from: SavedKlondikePile::Stock, "stock cycle"
to: SavedKlondikePile::Tableau(SavedTableau(4)),
count: 1,
}),
"waste \u{2192} tableau 5",
"Move variant must render as `{{from}} → {{to}}` with 1-indexed pile numbers",
); );
} }
@@ -2314,8 +2312,8 @@ 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, DrawStockConfig::DrawOne,
solitaire_core::game_state::GameMode::Classic, GameMode::Classic,
); );
assert_eq!(format_foundations_row(&game), "F: -- -- -- --"); assert_eq!(format_foundations_row(&game), "F: -- -- -- --");
} }
@@ -2326,8 +2324,8 @@ 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, DrawStockConfig::DrawOne,
solitaire_core::game_state::GameMode::Classic, GameMode::Classic,
); );
let text = format_stock_waste_row(&game); let text = format_stock_waste_row(&game);
assert!( assert!(
+10 -6
View File
@@ -8,8 +8,7 @@ use super::*;
use crate::layout::LayoutResource; use crate::layout::LayoutResource;
use crate::replay_playback::ReplayPlaybackState; use crate::replay_playback::ReplayPlaybackState;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use solitaire_core::KlondikePile; use solitaire_core::{KlondikeInstruction, KlondikePile};
use solitaire_data::ReplayMove;
/// Overwrites the banner label whenever the resource changes — covers the /// Overwrites the banner label whenever the resource changes — covers the
/// `Playing → Completed` transition by swapping "▌ replay" for /// `Playing → Completed` transition by swapping "▌ replay" for
@@ -85,9 +84,15 @@ pub(crate) fn update_floating_progress_chip(
// the most-recently-applied move sits at `cursor - 1`. // the most-recently-applied move sits at `cursor - 1`.
let dest_pile = match state.as_ref() { let dest_pile = match state.as_ref() {
ReplayPlaybackState::Playing { replay, cursor, .. } if *cursor > 0 => { ReplayPlaybackState::Playing { replay, cursor, .. } if *cursor > 0 => {
// The destination pile is recoverable directly from the
// instruction — no live state needed. `RotateStock` has no
// destination (the chip hides over the stock pile).
match &replay.moves[cursor - 1] { match &replay.moves[cursor - 1] {
ReplayMove::Move { to, .. } => Some(*to), KlondikeInstruction::DstFoundation(dst) => {
ReplayMove::StockClick => None, Some(KlondikePile::Foundation(dst.foundation))
}
KlondikeInstruction::DstTableau(dst) => Some(KlondikePile::Tableau(dst.tableau)),
KlondikeInstruction::RotateStock => None,
} }
} }
_ => None, _ => None,
@@ -95,8 +100,7 @@ pub(crate) fn update_floating_progress_chip(
let Some(world_pos) = dest_pile let Some(world_pos) = dest_pile
.as_ref() .as_ref()
.and_then(|p| KlondikePile::try_from(*p).ok()) .and_then(|p| layout.0.pile_positions.get(p).copied())
.and_then(|p| layout.0.pile_positions.get(&p).copied())
else { else {
// Nothing to point at — hide every chip and exit. // Nothing to point at — hide every chip and exit.
for (_, mut visibility, _) in chips.iter_mut() { for (_, mut visibility, _) in chips.iter_mut() {
+75 -73
View File
@@ -40,8 +40,8 @@
//! flag is threaded through, no every-callsite gate is added. //! flag is threaded through, no every-callsite gate is added.
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::KlondikePile; use solitaire_core::KlondikeInstruction;
use solitaire_data::{Replay, ReplayMove}; use solitaire_data::Replay;
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent}; use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
use crate::game_plugin::{GameMutation, RecordingReplay}; use crate::game_plugin::{GameMutation, RecordingReplay};
@@ -94,7 +94,7 @@ pub const REPLAY_COMPLETION_LINGER_SECS: f32 = 5.0;
/// replay's recorded deal. /// replay's recorded deal.
/// 3. The tick system [`tick_replay_playback`] advances `cursor` once /// 3. The tick system [`tick_replay_playback`] advances `cursor` once
/// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event /// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event
/// for each [`ReplayMove`]. /// for each [`KlondikeInstruction`].
/// 4. When `cursor == replay.moves.len()`, the state transitions to /// 4. When `cursor == replay.moves.len()`, the state transitions to
/// [`Completed`](Self::Completed). It lingers for /// [`Completed`](Self::Completed). It lingers for
/// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by /// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by
@@ -251,6 +251,7 @@ pub fn toggle_pause_replay_playback(state: &mut ResMut<ReplayPlaybackState>) ->
/// normal advance loop takes. /// normal advance loop takes.
pub fn step_replay_playback( pub fn step_replay_playback(
state: &mut ResMut<ReplayPlaybackState>, state: &mut ResMut<ReplayPlaybackState>,
game: Option<&GameStateResource>,
moves_writer: &mut MessageWriter<MoveRequestEvent>, moves_writer: &mut MessageWriter<MoveRequestEvent>,
draws_writer: &mut MessageWriter<DrawRequestEvent>, draws_writer: &mut MessageWriter<DrawRequestEvent>,
) -> bool { ) -> bool {
@@ -266,31 +267,49 @@ pub fn step_replay_playback(
if *cursor >= replay.moves.len() { if *cursor >= replay.moves.len() {
return false; return false;
} }
match &replay.moves[*cursor] { let instruction = replay.moves[*cursor];
ReplayMove::Move { from, to, count } => { dispatch_instruction(instruction, *cursor, game, moves_writer, draws_writer);
let (Ok(from), Ok(to)) = (KlondikePile::try_from(*from), KlondikePile::try_from(*to))
else {
warn!(
"skipping replay move with invalid pile encoding at cursor {}",
*cursor
);
*cursor += 1;
return false;
};
moves_writer.write(MoveRequestEvent {
from,
to,
count: *count,
});
}
ReplayMove::StockClick => {
draws_writer.write(DrawRequestEvent);
}
}
*cursor += 1; *cursor += 1;
true true
} }
/// Translates one recorded [`KlondikeInstruction`] into the canonical
/// engine event that drives the live animation pipeline.
///
/// `RotateStock` fires a [`DrawRequestEvent`]; a `Dst*` instruction is
/// decoded back to its runtime `(from, to, count)` pile coordinates via
/// [`GameState::instruction_to_piles`] against the *current* live state
/// (decoded before the event mutates it, so the source pile's face-up
/// run length is the one in effect when the move applies) and fires a
/// [`MoveRequestEvent`]. A decode that returns `None` (e.g. a malformed
/// instruction loaded from disk) is skipped with a warning rather than
/// panicking — the cursor still advances so playback never stalls.
///
/// `game` is `None` only in headless fixtures that install no
/// [`GameStateResource`]; in that case only `RotateStock` (which needs
/// no live state) is dispatched and `Dst*` instructions are skipped.
fn dispatch_instruction(
instruction: KlondikeInstruction,
cursor: usize,
game: Option<&GameStateResource>,
moves_writer: &mut MessageWriter<MoveRequestEvent>,
draws_writer: &mut MessageWriter<DrawRequestEvent>,
) {
match instruction {
KlondikeInstruction::RotateStock => {
draws_writer.write(DrawRequestEvent);
}
_ => match game.and_then(|g| g.0.instruction_to_piles(instruction)) {
Some((from, to, count)) => {
moves_writer.write(MoveRequestEvent { from, to, count });
}
None => {
warn!("skipping replay move that did not decode to piles at cursor {cursor}");
}
},
}
}
/// Steps the replay **backwards** by exactly one move while paused. /// Steps the replay **backwards** by exactly one move while paused.
/// ///
/// Strategy: the live game's undo system is the source of truth for /// Strategy: the live game's undo system is the source of truth for
@@ -355,6 +374,7 @@ pub fn step_backwards_replay_playback(
fn tick_replay_playback( fn tick_replay_playback(
time: Res<Time>, time: Res<Time>,
settings: Option<Res<SettingsResource>>, settings: Option<Res<SettingsResource>>,
game: Option<Res<GameStateResource>>,
mut state: ResMut<ReplayPlaybackState>, mut state: ResMut<ReplayPlaybackState>,
mut moves_writer: MessageWriter<MoveRequestEvent>, mut moves_writer: MessageWriter<MoveRequestEvent>,
mut draws_writer: MessageWriter<DrawRequestEvent>, mut draws_writer: MessageWriter<DrawRequestEvent>,
@@ -378,27 +398,14 @@ fn tick_replay_playback(
if !*paused { if !*paused {
*secs_to_next -= dt; *secs_to_next -= dt;
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() { while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
match &replay.moves[*cursor] { let instruction = replay.moves[*cursor];
ReplayMove::Move { from, to, count } => { dispatch_instruction(
if let (Ok(from), Ok(to)) = instruction,
(KlondikePile::try_from(*from), KlondikePile::try_from(*to)) *cursor,
{ game.as_deref(),
moves_writer.write(MoveRequestEvent { &mut moves_writer,
from, &mut draws_writer,
to, );
count: *count,
});
} else {
warn!(
"skipping replay move with invalid pile encoding at cursor {}",
*cursor
);
}
}
ReplayMove::StockClick => {
draws_writer.write(DrawRequestEvent);
}
}
*cursor += 1; *cursor += 1;
*secs_to_next += interval; *secs_to_next += interval;
} }
@@ -555,9 +562,8 @@ mod tests {
use crate::game_plugin::GamePlugin; use crate::game_plugin::GamePlugin;
use bevy::time::TimeUpdateStrategy; use bevy::time::TimeUpdateStrategy;
use chrono::NaiveDate; use chrono::NaiveDate;
use solitaire_core::{KlondikePile, Tableau}; use solitaire_core::KlondikeInstruction;
use solitaire_core::{DrawMode, game_state::GameMode}; use solitaire_core::{DrawStockConfig, game_state::GameMode};
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
use std::time::Duration; use std::time::Duration;
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and /// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
@@ -592,25 +598,24 @@ mod tests {
} }
} }
/// A 3-move replay covering both `Move` and `StockClick` variants. /// A 3-move replay of `RotateStock` inputs. Pile-position types are
/// Seed 12345 is arbitrary — the test asserts on event counts and /// runtime-only and intentionally not constructible from the engine
/// move shapes, not on board positions. /// crate, so a `Dst*` fixture can't be hand-built here; `RotateStock`
/// exercises the dispatch path (it fires a `DrawRequestEvent` without
/// needing a live state to decode piles). Seed 12345 is arbitrary —
/// the test asserts on event counts, not board positions.
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,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"), NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![ vec![
ReplayMove::StockClick, KlondikeInstruction::RotateStock,
ReplayMove::Move { KlondikeInstruction::RotateStock,
from: SavedKlondikePile::Stock, KlondikeInstruction::RotateStock,
to: SavedKlondikePile::Tableau(SavedTableau(3)),
count: 1,
},
ReplayMove::StockClick,
], ],
) )
} }
@@ -748,20 +753,17 @@ mod tests {
let captured_moves = app.world().resource::<CapturedMoves>(); let captured_moves = app.world().resource::<CapturedMoves>();
let captured_draws = app.world().resource::<CapturedDraws>(); let captured_draws = app.world().resource::<CapturedDraws>();
// Sample replay: StockClick, Move { Waste -> Tableau(3), 1 }, StockClick. // Sample replay: three `RotateStock` inputs — each dispatches a
// `DrawRequestEvent` and never a `MoveRequestEvent`.
assert_eq!( assert_eq!(
captured_draws.0, 2, captured_draws.0, 3,
"expected 2 DrawRequestEvent (two StockClicks)", "expected 3 DrawRequestEvent (one per RotateStock)",
); );
assert_eq!( assert_eq!(
captured_moves.0.len(), captured_moves.0.len(),
1, 0,
"expected 1 MoveRequestEvent (the single Move variant)", "RotateStock inputs must not produce MoveRequestEvent",
); );
let m = &captured_moves.0[0];
assert!(matches!(m.from, KlondikePile::Stock));
assert!(matches!(m.to, KlondikePile::Tableau(Tableau::Tableau4)));
assert_eq!(m.count, 1);
} }
/// Driving past one interval on a single-move replay must /// Driving past one interval on a single-move replay must
@@ -771,12 +773,12 @@ 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,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"), NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![ReplayMove::StockClick], vec![KlondikeInstruction::RotateStock],
); );
start_playback(&mut app, one_move); start_playback(&mut app, one_move);
app.update(); app.update();
@@ -822,7 +824,7 @@ mod tests {
// Replay — their in-flight recording must not get clobbered. // Replay — their in-flight recording must not get clobbered.
{ {
let mut rec = app.world_mut().resource_mut::<RecordingReplay>(); let mut rec = app.world_mut().resource_mut::<RecordingReplay>();
rec.moves.push(ReplayMove::StockClick); rec.moves.push(KlondikeInstruction::RotateStock);
} }
start_playback(&mut app, sample_replay_three_moves()); start_playback(&mut app, sample_replay_three_moves());
app.update(); app.update();
@@ -880,12 +882,12 @@ 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,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"), NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![ReplayMove::StockClick; 10], vec![KlondikeInstruction::RotateStock; 10],
) )
} }
+1 -1
View File
@@ -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.
+4 -23
View File
@@ -291,30 +291,12 @@ mod android {
} }
fn query_insets() -> Result<SafeAreaInsets, String> { fn query_insets() -> Result<SafeAreaInsets, String> {
use bevy::android::ANDROID_APP; use solitaire_data::android_jni;
use jni::{JavaVM, objects::JObject};
let app = ANDROID_APP android_jni::with_activity_env(|env, activity| {
.get()
.ok_or_else(|| "ANDROID_APP not initialized".to_string())?;
// SAFETY: `vm_as_ptr()` returns the JavaVM* set up by the Android
// runtime; valid for the lifetime of the process.
let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }
.map_err(|e| format!("JavaVM::from_raw: {e}"))?;
let mut env = vm
.attach_current_thread_permanently()
.map_err(|e| format!("attach_current_thread: {e}"))?;
// SAFETY: `activity_as_ptr()` returns the NativeActivity jobject
// pointer — valid for the lifetime of the process.
let activity = unsafe { JObject::from_raw(app.activity_as_ptr() as _) };
(|| -> jni::errors::Result<SafeAreaInsets> {
// Window window = activity.getWindow(); // Window window = activity.getWindow();
let window = env let window = env
.call_method(&activity, "getWindow", "()Landroid/view/Window;", &[])? .call_method(activity, "getWindow", "()Landroid/view/Window;", &[])?
.l()?; .l()?;
// View decor = window.getDecorView(); // View decor = window.getDecorView();
@@ -366,8 +348,7 @@ mod android {
left, left,
right, right,
}) })
})() })
.map_err(|e| format!("safe-area JNI: {e}"))
} }
} }
+13 -13
View File
@@ -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;
@@ -163,7 +163,7 @@ impl Plugin for SelectionPlugin {
update_selection_highlight.after(GameMutation).run_if( update_selection_highlight.after(GameMutation).run_if(
resource_changed::<SelectionState> resource_changed::<SelectionState>
.or(resource_changed::<KeyboardDragState>) .or(resource_changed::<KeyboardDragState>)
.or(resource_changed::<crate::GameStateResource>), .or(resource_changed::<GameStateResource>),
), ),
), ),
); );
@@ -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: &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: &[(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,8 +598,8 @@ 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: &Card,
game: &solitaire_core::game_state::GameState, game: &GameState,
) -> Option<KlondikePile> { ) -> Option<KlondikePile> {
let source = game.pile_containing_card(card.clone())?; let source = game.pile_containing_card(card.clone())?;
for foundation in [ for foundation in [
@@ -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 -15
View File
@@ -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`].
@@ -379,8 +379,8 @@ impl Plugin for SettingsPlugin {
.add_message::<DeleteAccountRequestEvent>() .add_message::<DeleteAccountRequestEvent>()
.add_message::<ToggleSettingsRequestEvent>() .add_message::<ToggleSettingsRequestEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_message::<bevy::input::mouse::MouseWheel>() .add_message::<MouseWheel>()
.add_message::<bevy::input::touch::TouchInput>() .add_message::<TouchInput>()
// `WindowResized` / `WindowMoved` are real Bevy window events // `WindowResized` / `WindowMoved` are real Bevy window events
// and emitted by the windowing backend under `DefaultPlugins`, // and emitted by the windowing backend under `DefaultPlugins`,
// but we register them explicitly here so the geometry watcher // but we register them explicitly here so the geometry watcher
@@ -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(),
} }
} }
@@ -2662,7 +2662,7 @@ fn handle_scan_themes(
let themes_dir = user_theme_dir(); let themes_dir = user_theme_dir();
let zips: Vec<std::path::PathBuf> = match std::fs::read_dir(&themes_dir) { let zips: Vec<PathBuf> = match std::fs::read_dir(&themes_dir) {
Ok(entries) => entries Ok(entries) => entries
.flatten() .flatten()
.map(|e| e.path()) .map(|e| e.path())
@@ -3019,7 +3019,7 @@ mod tests {
unit: MouseScrollUnit::Line, unit: MouseScrollUnit::Line,
x: 0.0, x: 0.0,
y: -3.0, y: -3.0,
window: bevy::ecs::entity::Entity::PLACEHOLDER, window: Entity::PLACEHOLDER,
}); });
app.update(); app.update();
// ScrollPosition must remain at 0.0 — panel was closed. // ScrollPosition must remain at 0.0 — panel was closed.
@@ -3052,7 +3052,7 @@ mod tests {
unit: MouseScrollUnit::Line, unit: MouseScrollUnit::Line,
x: 0.0, x: 0.0,
y: -2.0, y: -2.0,
window: bevy::ecs::entity::Entity::PLACEHOLDER, window: Entity::PLACEHOLDER,
}); });
app.update(); app.update();
let offset = app let offset = app
@@ -3364,7 +3364,7 @@ mod tests {
fn fire_resize(app: &mut App, width: f32, height: f32) { fn fire_resize(app: &mut App, width: f32, height: f32) {
app.world_mut().write_message(WindowResized { app.world_mut().write_message(WindowResized {
window: bevy::ecs::entity::Entity::PLACEHOLDER, window: Entity::PLACEHOLDER,
width, width,
height, height,
}); });
@@ -3372,7 +3372,7 @@ mod tests {
fn fire_move(app: &mut App, x: i32, y: i32) { fn fire_move(app: &mut App, x: i32, y: i32) {
app.world_mut().write_message(WindowMoved { app.world_mut().write_message(WindowMoved {
window: bevy::ecs::entity::Entity::PLACEHOLDER, window: Entity::PLACEHOLDER,
position: IVec2::new(x, y), position: IVec2::new(x, y),
}); });
} }
@@ -3494,7 +3494,7 @@ mod tests {
unit: MouseScrollUnit::Line, unit: MouseScrollUnit::Line,
x: 0.0, x: 0.0,
y: 5.0, y: 5.0,
window: bevy::ecs::entity::Entity::PLACEHOLDER, window: Entity::PLACEHOLDER,
}); });
app.update(); app.update();
let offset = app let offset = app
+2 -2
View File
@@ -1010,8 +1010,8 @@ mod tests {
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(bevy::asset::AssetPlugin::default()) .add_plugins(AssetPlugin::default())
.init_asset::<bevy::image::Image>() .init_asset::<Image>()
.add_plugins(SplashPlugin); .add_plugins(SplashPlugin);
app.init_resource::<ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<ButtonInput<MouseButton>>(); app.init_resource::<ButtonInput<MouseButton>>();
+11 -11
View File
@@ -203,7 +203,7 @@ impl Plugin for StatsPlugin {
// `DefaultPlugins`; register it explicitly so the stats-scroll // `DefaultPlugins`; register it explicitly so the stats-scroll
// system also runs cleanly under `MinimalPlugins` in tests. // system also runs cleanly under `MinimalPlugins` in tests.
.add_message::<MouseWheel>() .add_message::<MouseWheel>()
.add_message::<bevy::input::touch::TouchInput>() .add_message::<TouchInput>()
// record_abandoned must read `move_count` BEFORE handle_new_game // record_abandoned must read `move_count` BEFORE handle_new_game
// clobbers it with a fresh game. These are NOT in StatsUpdate because // clobbers it with a fresh game. These are NOT in StatsUpdate because
// StatsUpdate (as a set) is ordered after GameMutation by external // StatsUpdate (as a set) is ordered after GameMutation by external
@@ -464,7 +464,7 @@ fn repaint_replay_selector_detail(
/// Pure helper: render the detail line for the selected replay. Returns /// Pure helper: render the detail line for the selected replay. Returns
/// `"{duration} win on {date}"` plus a `" \u{2022} Shareable"` badge /// `"{duration} win on {date}"` plus a `" \u{2022} Shareable"` badge
/// when a share URL is present. Empty when the history slice is empty. /// when a share URL is present. Empty when the history slice is empty.
pub fn replay_selector_detail(replays: &[solitaire_data::Replay], index: usize) -> String { pub fn replay_selector_detail(replays: &[Replay], index: usize) -> String {
let Some(r) = replays.get(index.min(replays.len().saturating_sub(1))) else { let Some(r) = replays.get(index.min(replays.len().saturating_sub(1))) else {
return String::new(); return String::new();
}; };
@@ -1325,9 +1325,9 @@ mod tests {
fn draw_three_win_increments_draw_three_wins_only() { fn draw_three_win_increments_draw_three_wins_only() {
let mut app = headless_app(); let mut app = headless_app();
app.world_mut() app.world_mut()
.resource_mut::<crate::resources::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,
@@ -1371,7 +1371,7 @@ mod tests {
let mut app = headless_app(); let mut app = headless_app();
app.world_mut() app.world_mut()
.resource_mut::<crate::resources::GameStateResource>() .resource_mut::<GameStateResource>()
.0 .0
.set_test_move_count(3); .set_test_move_count(3);
@@ -1501,7 +1501,7 @@ mod tests {
fn zen_win_event_updates_zen_best_score_only() { fn zen_win_event_updates_zen_best_score_only() {
let mut app = headless_app(); let mut app = headless_app();
app.world_mut() app.world_mut()
.resource_mut::<crate::resources::GameStateResource>() .resource_mut::<GameStateResource>()
.0 .0
.mode = solitaire_core::game_state::GameMode::Zen; .mode = solitaire_core::game_state::GameMode::Zen;
@@ -1697,7 +1697,7 @@ mod tests {
stats.0.win_streak_current = 3; stats.0.win_streak_current = 3;
} }
app.world_mut() app.world_mut()
.resource_mut::<crate::resources::GameStateResource>() .resource_mut::<GameStateResource>()
.0 .0
.set_test_move_count(1); .set_test_move_count(1);
@@ -1723,7 +1723,7 @@ mod tests {
stats.0.win_streak_current = 1; stats.0.win_streak_current = 1;
} }
app.world_mut() app.world_mut()
.resource_mut::<crate::resources::GameStateResource>() .resource_mut::<GameStateResource>()
.0 .0
.set_test_move_count(1); .set_test_move_count(1);
@@ -1948,11 +1948,11 @@ mod tests {
/// ///
/// Uses a fixed seed, DrawOne mode, Classic game, 2026-05-08 date. /// Uses a fixed seed, DrawOne mode, Classic game, 2026-05-08 date.
/// `time_seconds` and `share_url` are the only varying fields across tests. /// `time_seconds` and `share_url` are the only varying fields across tests.
fn make_test_replay(time_seconds: u64, share_url: Option<String>) -> solitaire_data::Replay { fn make_test_replay(time_seconds: u64, share_url: Option<String>) -> Replay {
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 = 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,
+6 -6
View File
@@ -496,7 +496,7 @@ mod tests {
.add_plugins(crate::achievement_plugin::AchievementPlugin::headless()) .add_plugins(crate::achievement_plugin::AchievementPlugin::headless())
.add_plugins(SyncPlugin::new(provider)); .add_plugins(SyncPlugin::new(provider));
// MinimalPlugins does not register keyboard input. // MinimalPlugins does not register keyboard input.
app.init_resource::<bevy::input::ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
app.update(); app.update();
app app
} }
@@ -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,
@@ -629,8 +629,8 @@ mod tests {
replays: vec![initial], replays: vec![initial],
}; };
save_replay_history_to(&path, &history).expect("seed history on disk"); save_replay_history_to(&path, &history).expect("seed history on disk");
app.insert_resource(crate::stats_plugin::ReplayHistoryResource(history)); app.insert_resource(ReplayHistoryResource(history));
app.insert_resource(crate::stats_plugin::LatestReplayPath(Some(path.clone()))); app.insert_resource(LatestReplayPath(Some(path.clone())));
// Pre-resolved task carrying the URL the production path would // Pre-resolved task carrying the URL the production path would
// get back from the server. // get back from the server.
@@ -659,7 +659,7 @@ mod tests {
// In-memory contract: replays[0].share_url is now Some(url). // In-memory contract: replays[0].share_url is now Some(url).
let live = app let live = app
.world() .world()
.resource::<crate::stats_plugin::ReplayHistoryResource>(); .resource::<ReplayHistoryResource>();
assert_eq!( assert_eq!(
live.0.replays.first().and_then(|r| r.share_url.clone()), live.0.replays.first().and_then(|r| r.share_url.clone()),
Some(url.clone()), Some(url.clone()),
+6 -8
View File
@@ -7,13 +7,11 @@
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;
#[cfg(test)] use crate::layout::{Layout, LayoutResource, LayoutSystem, TABLE_COLOUR, compute_layout};
use crate::layout::TABLE_COLOUR;
use crate::layout::{Layout, LayoutResource, LayoutSystem, compute_layout};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::safe_area::SafeAreaInsets; use crate::safe_area::SafeAreaInsets;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
@@ -177,9 +175,9 @@ fn setup_table(
Camera2d, Camera2d,
Camera { Camera {
clear_color: ClearColorConfig::Custom(Color::srgb( clear_color: ClearColorConfig::Custom(Color::srgb(
crate::layout::TABLE_COLOUR[0], TABLE_COLOUR[0],
crate::layout::TABLE_COLOUR[1], TABLE_COLOUR[1],
crate::layout::TABLE_COLOUR[2], TABLE_COLOUR[2],
)), )),
..default() ..default()
}, },
@@ -520,7 +518,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();
+6 -6
View File
@@ -236,7 +236,7 @@ pub fn import_theme_into(zip_path: &Path, target_root: &Path) -> Result<ThemeId,
/// Sums every entry's declared uncompressed size and rejects archives /// Sums every entry's declared uncompressed size and rejects archives
/// that overflow [`MAX_ARCHIVE_BYTES`]. Iterates the central /// that overflow [`MAX_ARCHIVE_BYTES`]. Iterates the central
/// directory only — does not actually decompress anything. /// directory only — does not actually decompress anything.
fn enforce_archive_size_limit<R: io::Read + io::Seek>( fn enforce_archive_size_limit<R: Read + io::Seek>(
archive: &mut zip::ZipArchive<R>, archive: &mut zip::ZipArchive<R>,
) -> Result<(), ImportError> { ) -> Result<(), ImportError> {
let mut total: u64 = 0; let mut total: u64 = 0;
@@ -257,7 +257,7 @@ fn enforce_archive_size_limit<R: io::Read + io::Seek>(
/// (after normalisation) escapes its root. Catches `..`, absolute /// (after normalisation) escapes its root. Catches `..`, absolute
/// paths, drive prefixes on Windows, and the awkward case where /// paths, drive prefixes on Windows, and the awkward case where
/// `enclosed_name` returns `None` because the entry is suspicious. /// `enclosed_name` returns `None` because the entry is suspicious.
fn enforce_zip_slip_safe<R: io::Read + io::Seek>( fn enforce_zip_slip_safe<R: Read + io::Seek>(
archive: &mut zip::ZipArchive<R>, archive: &mut zip::ZipArchive<R>,
) -> Result<(), ImportError> { ) -> Result<(), ImportError> {
for i in 0..archive.len() { for i in 0..archive.len() {
@@ -291,7 +291,7 @@ fn is_safe_relative_path(p: &Path) -> bool {
/// `theme.ron` entry at its root. /// `theme.ron` entry at its root.
/// - [`ImportError::ManifestParse`] when the bytes don't form valid /// - [`ImportError::ManifestParse`] when the bytes don't form valid
/// RON for `ThemeManifest`. /// RON for `ThemeManifest`.
fn read_manifest<R: io::Read + io::Seek>( fn read_manifest<R: Read + io::Seek>(
archive: &mut zip::ZipArchive<R>, archive: &mut zip::ZipArchive<R>,
) -> Result<ThemeManifest, ImportError> { ) -> Result<ThemeManifest, ImportError> {
// We can't use `?` directly across `by_name` because a missing // We can't use `?` directly across `by_name` because a missing
@@ -318,7 +318,7 @@ fn read_manifest<R: io::Read + io::Seek>(
/// ///
/// Returns [`ImportError::MissingFile`] when the archive has no entry /// Returns [`ImportError::MissingFile`] when the archive has no entry
/// matching the path. /// matching the path.
fn read_archive_entry<R: io::Read + io::Seek>( fn read_archive_entry<R: Read + io::Seek>(
archive: &mut zip::ZipArchive<R>, archive: &mut zip::ZipArchive<R>,
path: &Path, path: &Path,
) -> Result<Vec<u8>, ImportError> { ) -> Result<Vec<u8>, ImportError> {
@@ -351,7 +351,7 @@ fn archive_key(path: &Path) -> String {
/// parent directories as needed. The destination path is rebuilt from /// parent directories as needed. The destination path is rebuilt from
/// the safe components we already vetted in /// the safe components we already vetted in
/// [`enforce_zip_slip_safe`], not from the raw entry name. /// [`enforce_zip_slip_safe`], not from the raw entry name.
fn write_archive_entry<R: io::Read + io::Seek>( fn write_archive_entry<R: Read + io::Seek>(
archive: &mut zip::ZipArchive<R>, archive: &mut zip::ZipArchive<R>,
name: &str, name: &str,
staging: &Path, staging: &Path,
@@ -384,7 +384,7 @@ fn write_archive_entry<R: io::Read + io::Seek>(
/// Variant of [`write_archive_entry`] keyed by `Path` for the /// Variant of [`write_archive_entry`] keyed by `Path` for the
/// manifest-declared face/back paths. /// manifest-declared face/back paths.
fn write_archive_entry_pathbuf<R: io::Read + io::Seek>( fn write_archive_entry_pathbuf<R: Read + io::Seek>(
archive: &mut zip::ZipArchive<R>, archive: &mut zip::ZipArchive<R>,
path: &Path, path: &Path,
staging: &Path, staging: &Path,
+1 -1
View File
@@ -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};
+2 -2
View File
@@ -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,
@@ -484,7 +484,7 @@ mod tests {
let mut image_set = empty_card_image_set(); let mut image_set = empty_card_image_set();
// Snapshot the legacy back ids so we can prove they don't // Snapshot the legacy back ids so we can prove they don't
// change when a theme is applied. // change when a theme is applied.
let legacy_ids_before: [bevy::asset::AssetId<bevy::image::Image>; 5] = let legacy_ids_before: [AssetId<Image>; 5] =
std::array::from_fn(|i| image_set.backs[i].id()); std::array::from_fn(|i| image_set.backs[i].id());
let theme = empty_theme(); let theme = empty_theme();
assert!(image_set.theme_back.is_none(), "theme_back starts empty"); assert!(image_set.theme_back.is_none(), "theme_back starts empty");
+4 -4
View File
@@ -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,
@@ -521,7 +521,7 @@ mod tests {
// the session timer or the running win count. // the session timer or the running win count.
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
fn tmp_ta_path(name: &str) -> std::path::PathBuf { fn tmp_ta_path(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("engine_test_ta_{name}.json")) std::env::temp_dir().join(format!("engine_test_ta_{name}.json"))
} }
@@ -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.
+1 -1
View File
@@ -1324,7 +1324,7 @@ mod tests {
let row = world.spawn((FocusRow, Node::default())).id(); let row = world.spawn((FocusRow, Node::default())).id();
world.entity_mut(scrim).add_child(row); world.entity_mut(scrim).add_child(row);
let make_swatch = |w: &mut World, marker: fn(&mut bevy::ecs::world::EntityWorldMut)| { let make_swatch = |w: &mut World, marker: fn(&mut EntityWorldMut)| {
let mut e = w.spawn(( let mut e = w.spawn((
Button, Button,
Node::default(), Node::default(),
+3 -3
View File
@@ -982,9 +982,9 @@ mod tests {
outline_width: 0.0, outline_width: 0.0,
outline_offset: 0.0, outline_offset: 0.0,
unrounded_size: card_size, unrounded_size: card_size,
border: bevy::sprite::BorderRect::default(), border: BorderRect::default(),
border_radius: bevy::ui::ResolvedBorderRadius::default(), border_radius: ResolvedBorderRadius::default(),
padding: bevy::sprite::BorderRect::default(), padding: BorderRect::default(),
inverse_scale_factor: 1.0, inverse_scale_factor: 1.0,
}; };
// `is_empty` guard inside Bevy treats zero-size // `is_empty` guard inside Bevy treats zero-size
+7 -7
View File
@@ -249,12 +249,12 @@ pub const BORDER_SUBTLE_HC: Color = Color::srgba(0.627, 0.627, 0.627, 1.0);
pub struct HighContrastBorder { pub struct HighContrastBorder {
/// Border colour to use when high-contrast mode is *off* — the /// Border colour to use when high-contrast mode is *off* — the
/// site's normal idle / active-state colour. /// site's normal idle / active-state colour.
pub default_color: bevy::prelude::Color, pub default_color: Color,
} }
impl HighContrastBorder { impl HighContrastBorder {
/// Convenience constructor — `HighContrastBorder::with_default(BORDER_SUBTLE)`. /// Convenience constructor — `HighContrastBorder::with_default(BORDER_SUBTLE)`.
pub const fn with_default(default_color: bevy::prelude::Color) -> Self { pub const fn with_default(default_color: Color) -> Self {
Self { default_color } Self { default_color }
} }
} }
@@ -282,18 +282,18 @@ impl HighContrastBorder {
pub struct HighContrastBackground { pub struct HighContrastBackground {
/// Background colour to use when high-contrast mode is *off* — /// Background colour to use when high-contrast mode is *off* —
/// the site's normal idle / active-state colour. /// the site's normal idle / active-state colour.
pub default_color: bevy::prelude::Color, pub default_color: Color,
/// Background colour to use when high-contrast mode is *on*. /// Background colour to use when high-contrast mode is *on*.
/// Defaults to [`BORDER_SUBTLE_HC`] via [`with_default`]. /// Defaults to [`BORDER_SUBTLE_HC`] via [`with_default`].
/// ///
/// [`with_default`]: HighContrastBackground::with_default /// [`with_default`]: HighContrastBackground::with_default
pub hc_color: bevy::prelude::Color, pub hc_color: Color,
} }
impl HighContrastBackground { impl HighContrastBackground {
/// Convenience constructor — HC colour defaults to /// Convenience constructor — HC colour defaults to
/// [`BORDER_SUBTLE_HC`]. /// [`BORDER_SUBTLE_HC`].
pub const fn with_default(default_color: bevy::prelude::Color) -> Self { pub const fn with_default(default_color: Color) -> Self {
Self { Self {
default_color, default_color,
hc_color: BORDER_SUBTLE_HC, hc_color: BORDER_SUBTLE_HC,
@@ -305,8 +305,8 @@ impl HighContrastBackground {
/// marker which bumps `STATE_SUCCESS` → `STATE_SUCCESS_HC` rather /// marker which bumps `STATE_SUCCESS` → `STATE_SUCCESS_HC` rather
/// than to a neutral gray. /// than to a neutral gray.
pub const fn with_hc( pub const fn with_hc(
default_color: bevy::prelude::Color, default_color: Color,
hc_color: bevy::prelude::Color, hc_color: Color,
) -> Self { ) -> Self {
Self { Self {
default_color, default_color,
+2 -2
View File
@@ -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,
+19 -24
View File
@@ -12,7 +12,7 @@
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::game_state::GameMode; use solitaire_core::game_state::GameMode;
use solitaire_core::klondike_adapter::compute_time_bonus; use solitaire_core::scoring::compute_time_bonus;
use solitaire_data::AnimSpeed; use solitaire_data::AnimSpeed;
use crate::achievement_plugin::display_name_for; use crate::achievement_plugin::display_name_for;
@@ -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 {
@@ -556,7 +551,7 @@ fn spawn_win_summary_after_delay(
// speed the duration is zero anyway, suppressing the shake. // speed the duration is zero anyway, suppressing the shake.
let speed = settings let speed = settings
.as_ref() .as_ref()
.map_or(solitaire_data::AnimSpeed::Normal, |s| s.0.animation_speed); .map_or(AnimSpeed::Normal, |s| s.0.animation_speed);
let scaled = scaled_duration(SHAKE_DURATION_SECS, speed); let scaled = scaled_duration(SHAKE_DURATION_SECS, speed);
shake.remaining = scaled; shake.remaining = scaled;
shake.total = scaled; shake.total = scaled;
@@ -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 {
+3
View File
@@ -32,3 +32,6 @@ dotenvy = { workspace = true }
[dev-dependencies] [dev-dependencies]
tower = { version = "0.5", features = ["util"] } tower = { version = "0.5", features = ["util"] }
[lints]
workspace = true
+1 -1
View File
@@ -54,7 +54,7 @@ struct UserIdKeyExtractor {
impl KeyExtractor for UserIdKeyExtractor { impl KeyExtractor for UserIdKeyExtractor {
type Key = String; type Key = String;
fn extract<T>(&self, req: &axum::http::Request<T>) -> Result<Self::Key, GovernorError> { fn extract<T>(&self, req: &Request<T>) -> Result<Self::Key, GovernorError> {
if let Some(user_id) = self.try_extract_user_id(req.headers()) { if let Some(user_id) = self.try_extract_user_id(req.headers()) {
return Ok(user_id); return Ok(user_id);
} }
+3 -3
View File
@@ -565,7 +565,7 @@ async fn register_login_push_pull_full_roundtrip() {
}, },
achievements: vec![], achievements: vec![],
progress: PlayerProgress::default(), progress: PlayerProgress::default(),
last_modified: chrono::Utc::now(), last_modified: Utc::now(),
}; };
let push_resp = post_authed( let push_resp = post_authed(
@@ -1299,7 +1299,7 @@ async fn expired_access_token_returns_401() {
exp: usize, exp: usize,
kind: String, kind: String,
} }
let exp = (chrono::Utc::now() - chrono::Duration::hours(2)).timestamp() as usize; let exp = (Utc::now() - chrono::Duration::hours(2)).timestamp() as usize;
let expired_token = encode( let expired_token = encode(
&Header::default(), &Header::default(),
&ExpiredClaims { &ExpiredClaims {
@@ -1375,7 +1375,7 @@ async fn refresh_with_expired_refresh_token_returns_401() {
exp: usize, exp: usize,
kind: String, kind: String,
} }
let exp = (chrono::Utc::now() - chrono::Duration::hours(2)).timestamp() as usize; let exp = (Utc::now() - chrono::Duration::hours(2)).timestamp() as usize;
let expired_token = encode( let expired_token = encode(
&Header::default(), &Header::default(),
&ExpiredRefreshClaims { &ExpiredRefreshClaims {
+3
View File
@@ -9,3 +9,6 @@ serde = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
[lints]
workspace = true
+7 -7
View File
@@ -755,14 +755,14 @@ mod tests {
fn progress_total_xp_takes_max() { fn progress_total_xp_takes_max() {
let mut local = default_payload(); let mut local = default_payload();
local.progress.total_xp = 1500; local.progress.total_xp = 1500;
local.progress.level = crate::progress::level_for_xp(1500); local.progress.level = level_for_xp(1500);
let mut remote = default_payload(); let mut remote = default_payload();
remote.progress.total_xp = 2500; remote.progress.total_xp = 2500;
remote.progress.level = crate::progress::level_for_xp(2500); remote.progress.level = level_for_xp(2500);
let (merged, _) = merge(&local, &remote); let (merged, _) = merge(&local, &remote);
assert_eq!(merged.progress.total_xp, 2500); assert_eq!(merged.progress.total_xp, 2500);
assert_eq!(merged.progress.level, crate::progress::level_for_xp(2500)); assert_eq!(merged.progress.level, level_for_xp(2500));
} }
#[test] #[test]
@@ -815,7 +815,7 @@ mod tests {
let (merged, _) = merge(&local, &remote); let (merged, _) = merge(&local, &remote);
assert_eq!(merged.progress.total_xp, 5500); assert_eq!(merged.progress.total_xp, 5500);
assert_eq!(merged.progress.level, crate::progress::level_for_xp(5500)); assert_eq!(merged.progress.level, level_for_xp(5500));
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -1051,11 +1051,11 @@ mod tests {
// drop the oldest 50 so the cap is preserved. // drop the oldest 50 so the cap is preserved.
let start = nd(2024, 1, 1); let start = nd(2024, 1, 1);
let local_dates: Vec<NaiveDate> = (0..DAILY_CHALLENGE_HISTORY_CAP as i64) let local_dates: Vec<NaiveDate> = (0..DAILY_CHALLENGE_HISTORY_CAP as i64)
.map(|i| start + chrono::Duration::days(i)) .map(|i| start + Duration::days(i))
.collect(); .collect();
let remote_dates: Vec<NaiveDate> = (DAILY_CHALLENGE_HISTORY_CAP as i64 let remote_dates: Vec<NaiveDate> = (DAILY_CHALLENGE_HISTORY_CAP as i64
..DAILY_CHALLENGE_HISTORY_CAP as i64 + 50) ..DAILY_CHALLENGE_HISTORY_CAP as i64 + 50)
.map(|i| start + chrono::Duration::days(i)) .map(|i| start + Duration::days(i))
.collect(); .collect();
let mut local = default_payload(); let mut local = default_payload();
@@ -1073,7 +1073,7 @@ mod tests {
// is therefore start + 50 days. // is therefore start + 50 days.
assert_eq!( assert_eq!(
merged.progress.daily_challenge_history.first().copied(), merged.progress.daily_challenge_history.first().copied(),
Some(start + chrono::Duration::days(50)) Some(start + Duration::days(50))
); );
// Most recent retained is the last remote date. // Most recent retained is the last remote date.
assert_eq!( assert_eq!(
+3
View File
@@ -28,3 +28,6 @@ web-sys = { version = "0.3", features = ["console"] }
[features] [features]
default = ["console_error_panic_hook"] default = ["console_error_panic_hook"]
[lints]
workspace = true
+113 -196
View File
@@ -3,8 +3,8 @@
//! The web replay player at `<server>/replays/<id>` fetches a [`Replay`] //! The web replay player at `<server>/replays/<id>` fetches a [`Replay`]
//! JSON via `GET /api/replays/:id`, hands it to [`ReplayPlayer::new`], //! JSON via `GET /api/replays/:id`, hands it to [`ReplayPlayer::new`],
//! and then advances frame-by-frame with [`ReplayPlayer::step`]. Each //! and then advances frame-by-frame with [`ReplayPlayer::step`]. Each
//! step applies one [`ReplayMove`] to the underlying `GameState` and //! step applies one [`KlondikeInstruction`] to the underlying `GameState`
//! returns the resulting pile snapshot as JSON for the JS layer to //! and returns the resulting pile snapshot as JSON for the JS layer to
//! render. //! render.
//! //!
//! The state machine is the same Rust [`solitaire_core::GameState`] //! The state machine is the same Rust [`solitaire_core::GameState`]
@@ -19,41 +19,31 @@
//! is the contract. //! is the contract.
use chrono::NaiveDate; use chrono::NaiveDate;
use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::{Foundation, KlondikeInstruction, 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::{
SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, tableau_from_index,
};
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
/// Mirrors the variants of `solitaire_data::ReplayMove` v2 (atomic /// Mirrors `solitaire_data::Replay` v3.
/// player inputs, post-StockClick refinement). Only the JSON shape ///
/// matters for cross-crate compatibility. /// `moves` is a list of upstream [`KlondikeInstruction`]s — the same
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] /// move-currency `solitaire_core` persists. A stock click is
pub enum ReplayMove { /// `KlondikeInstruction::RotateStock`; a card move is a
Move { /// `DstFoundation` / `DstTableau` instruction. Pile-position types are
from: SavedKlondikePile, /// runtime-only and intentionally not part of the wire format.
to: SavedKlondikePile,
count: usize,
},
StockClick,
}
/// Mirrors `solitaire_data::Replay` v2.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Replay { 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,
pub recorded_at: NaiveDate, pub recorded_at: NaiveDate,
pub moves: Vec<ReplayMove>, pub moves: Vec<KlondikeInstruction>,
} }
/// JS-friendly snapshot of a `GameState` at a particular replay step. /// JS-friendly snapshot of a `GameState` at a particular replay step.
@@ -77,9 +67,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 +79,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",
@@ -112,7 +100,7 @@ impl From<&(solitaire_core::card::Card, bool)> for CardSnapshot {
#[wasm_bindgen] #[wasm_bindgen]
pub struct ReplayPlayer { pub struct ReplayPlayer {
game: GameState, game: GameState,
moves: Vec<ReplayMove>, moves: Vec<KlondikeInstruction>,
step_idx: usize, step_idx: usize,
} }
@@ -147,17 +135,8 @@ impl ReplayPlayer {
if self.step_idx >= self.moves.len() { if self.step_idx >= self.moves.len() {
return Ok(None); return Ok(None);
} }
let mv = self.moves[self.step_idx].clone(); let instruction = self.moves[self.step_idx];
match mv { self.game.apply_instruction(instruction)?;
ReplayMove::Move { from, to, count } => self.game.move_cards(
from.try_into()
.map_err(|_| MoveError::RuleViolation("invalid replay pile".into()))?,
to.try_into()
.map_err(|_| MoveError::RuleViolation("invalid replay pile".into()))?,
count,
)?,
ReplayMove::StockClick => self.game.draw()?,
}
self.step_idx += 1; self.step_idx += 1;
Ok(Some(self.snapshot())) Ok(Some(self.snapshot()))
} }
@@ -184,7 +163,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 +297,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,11 +312,11 @@ 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>,
pub move_history: Vec<SavedInstruction>, pub move_history: Vec<KlondikeInstruction>,
pub invariants: DebugInvariantReport, pub invariants: DebugInvariantReport,
pub state_json: String, pub state_json: String,
} }
@@ -389,24 +369,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 +391,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 +428,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 +438,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 +469,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
@@ -568,90 +550,15 @@ impl SolitaireGame {
legal_moves_for_game(&self.game) legal_moves_for_game(&self.game)
} }
fn move_history_native(&self) -> Vec<SavedInstruction> { fn move_history_native(&self) -> Vec<KlondikeInstruction> {
self.game.instruction_history() self.game.instruction_history()
} }
fn replay_moves_native(&self) -> Result<Vec<ReplayMove>, String> { fn replay_moves_native(&self) -> Vec<KlondikeInstruction> {
let mut replay_game = // The session's forward instruction history *is* the replayable
GameState::new_with_mode(self.game.seed, self.game.draw_mode(), self.game.mode); // move list: each entry replays cleanly via `apply_instruction`
let mut replay_moves = Vec::new(); // against a fresh game with the same seed/draw mode/mode.
self.game.instruction_history()
for instruction in self.game.instruction_history() {
let replay_move = match instruction {
SavedInstruction::RotateStock => ReplayMove::StockClick,
SavedInstruction::DstFoundation(dst) => ReplayMove::Move {
from: dst.src,
to: SavedKlondikePile::Foundation(dst.foundation),
count: 1,
},
SavedInstruction::DstTableau(dst) => {
let (from, count) = match dst.src {
SavedKlondikePileStack::Stock => (SavedKlondikePile::Stock, 1),
SavedKlondikePileStack::Foundation(foundation) => {
(SavedKlondikePile::Foundation(foundation), 1)
}
SavedKlondikePileStack::Tableau(tableau_stack) => {
let tableau =
tableau_from_index(tableau_stack.tableau.0 as usize).ok_or_else(
|| {
format!(
"invalid tableau index in move history: {}",
tableau_stack.tableau.0
)
},
)?;
let face_up_count = replay_game
.pile(KlondikePile::Tableau(tableau))
.iter()
.rev()
.take_while(|(_, face_up)| *face_up)
.count();
let skip = tableau_stack.skip_cards.0 as usize;
let count = face_up_count.checked_sub(skip).ok_or_else(|| {
format!(
"invalid tableau skip in move history: face_up={face_up_count}, skip={skip}"
)
})?;
if count == 0 {
return Err(
"invalid tableau move in move history: zero-card move".into()
);
}
(SavedKlondikePile::Tableau(tableau_stack.tableau), count)
}
};
ReplayMove::Move {
from,
to: SavedKlondikePile::Tableau(dst.tableau),
count,
}
}
};
match &replay_move {
ReplayMove::StockClick => replay_game
.draw()
.map_err(|e| format!("failed to apply stock click while exporting replay: {e}"))?,
ReplayMove::Move { from, to, count } => {
let src: KlondikePile = (*from)
.try_into()
.map_err(|e| format!("invalid replay source pile: {e}"))?;
let dst: KlondikePile = (*to)
.try_into()
.map_err(|e| format!("invalid replay destination pile: {e}"))?;
replay_game.move_cards(src, dst, *count).map_err(|e| {
format!(
"failed to apply move while exporting replay ({from:?} -> {to:?}, count={count}): {e}"
)
})?;
}
}
replay_moves.push(replay_move);
}
Ok(replay_moves)
} }
fn debug_snapshot_native(&self) -> DebugSnapshot { fn debug_snapshot_native(&self) -> DebugSnapshot {
@@ -726,9 +633,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),
@@ -828,15 +735,13 @@ impl SolitaireGame {
} }
} }
/// Returns replay moves encoded in the `solitaire_data::Replay` wire format. /// Returns replay moves encoded in the `solitaire_data::Replay` wire format
/// — a list of upstream [`KlondikeInstruction`]s.
/// ///
/// This derives move counts from the deterministic instruction history and /// This is the deterministic instruction history; together with `seed()`
/// validates that the resulting move stream replays cleanly from the current /// and the draw mode it replays cleanly via `apply_instruction`.
/// game's seed/draw mode.
pub fn replay_moves(&self) -> Result<JsValue, JsValue> { pub fn replay_moves(&self) -> Result<JsValue, JsValue> {
let moves = self let moves = self.replay_moves_native();
.replay_moves_native()
.map_err(|e| JsValue::from_str(&e.to_string()))?;
serde_wasm_bindgen::to_value(&moves).map_err(|e| JsValue::from_str(&e.to_string())) serde_wasm_bindgen::to_value(&moves).map_err(|e| JsValue::from_str(&e.to_string()))
} }
@@ -893,6 +798,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 +857,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 +876,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 +907,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 +924,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),
}; };
@@ -1016,10 +940,7 @@ mod tests {
} }
} }
let exported_moves = match game.replay_moves_native() { let exported_moves = game.replay_moves_native();
Ok(moves) => moves,
Err(err) => panic!("replay export failed: {err}"),
};
assert!( assert!(
!exported_moves.is_empty(), !exported_moves.is_empty(),
"progressed game must export a non-empty replay move list" "progressed game must export a non-empty replay move list"
@@ -1029,24 +950,20 @@ mod tests {
Ok(value) => value, Ok(value) => value,
Err(err) => panic!("failed to serialise exported replay moves: {err}"), Err(err) => panic!("failed to serialise exported replay moves: {err}"),
}; };
let array = match moves_json.as_array() {
Some(values) => values,
None => panic!("exported replay moves must serialise as a JSON array"),
};
assert!( assert!(
array.iter().all(|entry| { moves_json.is_array(),
entry.as_str() == Some("StockClick") || entry.get("Move").is_some() "exported replay moves must serialise as a JSON array"
}),
"replay move JSON must match ReplayMove wire shape"
); );
let parsed_back: Vec<ReplayMove> = match serde_json::from_value(moves_json) { let parsed_back: Vec<KlondikeInstruction> = match serde_json::from_value(moves_json) {
Ok(parsed) => parsed, Ok(parsed) => parsed,
Err(err) => panic!("failed to parse replay move JSON as ReplayMove list: {err}"), Err(err) => {
panic!("failed to parse replay move JSON as KlondikeInstruction list: {err}")
}
}; };
assert_eq!( assert_eq!(
parsed_back, exported_moves, parsed_back, exported_moves,
"replay move JSON must round-trip through ReplayMove" "replay move JSON must round-trip through KlondikeInstruction"
); );
let recorded_at = match NaiveDate::from_ymd_opt(2026, 6, 1) { let recorded_at = match NaiveDate::from_ymd_opt(2026, 6, 1) {
@@ -1054,12 +971,12 @@ mod tests {
None => panic!("invalid recorded_at date in test"), None => panic!("invalid recorded_at date in test"),
}; };
let replay = Replay { let replay = Replay {
schema_version: 2, schema_version: 3,
seed, seed,
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 +1015,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 +1029,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 +1042,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 +1077,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 +1115,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 +1141,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 +1171,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();
+3
View File
@@ -21,3 +21,6 @@ console_error_panic_hook = "0.1"
# renderer to WebGL2 compatibility limits, which is wrong for native builds. # renderer to WebGL2 compatibility limits, which is wrong for native builds.
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
bevy = { workspace = true, features = ["webgl2"] } bevy = { workspace = true, features = ["webgl2"] }
[lints]
workspace = true