diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..326e199 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[registries.Quaternions] +index = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/" diff --git a/Cargo.lock b/Cargo.lock index 80b5c33..531855c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -364,6 +364,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/" +checksum = "813440870d646c57c222c1d713dc4e3ddcb2919c3801564d767d85d7bf2afee4" + [[package]] name = "as-raw-xcb-connection" version = "1.0.1" @@ -901,7 +907,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9cf7a3ee41342dd7b5a5d82e200d0e8efb933169247fce853b4ad633d51e87d" dependencies = [ - "arrayvec", + "arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)", "bevy_ecs_macros", "bevy_platform", "bevy_ptr", @@ -1138,7 +1144,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e931fa969f89c83498b22c97432383afe90e90fd1a5e04fa07be8da4d3bcac84" dependencies = [ "approx", - "arrayvec", + "arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)", "bevy_reflect", "derive_more", "glam 0.30.10", @@ -1703,7 +1709,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" dependencies = [ "arrayref", - "arrayvec", + "arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)", "cc", "cfg-if", "constant_time_eq", @@ -1879,6 +1885,15 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "card_game" +version = "0.3.0" +source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/" +checksum = "38b68e4fb32f8a1f92edf8488c012f6d8af71491a2f9f8a855362d7eaf1a2d0c" +dependencies = [ + "arrayvec 0.7.6 (sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/)", +] + [[package]] name = "cbc" version = "0.1.2" @@ -1939,6 +1954,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "18758054972164c3264f7c8386f5fc6da6114cb46b619fd365d4e3b2dc3ae487" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.44" @@ -4326,13 +4352,23 @@ dependencies = [ "triple_buffer", ] +[[package]] +name = "klondike" +version = "0.2.0" +source = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/" +checksum = "0bce541f9b14e9d9d8c9b17d5df40bd0a017709b61d9be8ad5bab7b19a1a0152" +dependencies = [ + "card_game", + "rand 0.10.1", +] + [[package]] name = "kurbo" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb" dependencies = [ - "arrayvec", + "arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)", "euclid", "smallvec", ] @@ -4740,7 +4776,7 @@ version = "27.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" dependencies = [ - "arrayvec", + "arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)", "bit-set", "bitflags 2.11.1", "cfg-if", @@ -5947,6 +5983,16 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -5985,6 +6031,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_distr" version = "0.5.1" @@ -6980,6 +7032,7 @@ dependencies = [ name = "solitaire_core" version = "0.1.0" dependencies = [ + "klondike", "rand 0.9.4", "serde", "thiserror 2.0.18", @@ -7502,7 +7555,7 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" dependencies = [ - "arrayvec", + "arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)", "bitflags 1.3.2", "bytemuck", "lazy_static", @@ -7601,7 +7654,7 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b" dependencies = [ - "arrayvec", + "arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)", "grid", "serde", "slotmap", @@ -7870,7 +7923,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" dependencies = [ "arrayref", - "arrayvec", + "arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)", "bytemuck", "cfg-if", "log", @@ -7884,7 +7937,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" dependencies = [ "arrayref", - "arrayvec", + "arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)", "bytemuck", "cfg-if", "log", @@ -9044,7 +9097,7 @@ version = "27.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" dependencies = [ - "arrayvec", + "arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)", "bitflags 2.11.1", "cfg-if", "cfg_aliases", @@ -9068,7 +9121,7 @@ version = "27.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" dependencies = [ - "arrayvec", + "arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)", "bit-set", "bit-vec", "bitflags 2.11.1", @@ -9118,7 +9171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" dependencies = [ "android_system_properties", - "arrayvec", + "arrayvec 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)", "ash", "bit-set", "bitflags 2.11.1", diff --git a/Cargo.toml b/Cargo.toml index d8e9253..59e4eec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ solitaire_core = { path = "solitaire_core" } solitaire_sync = { path = "solitaire_sync" } solitaire_data = { path = "solitaire_data" } solitaire_engine = { path = "solitaire_engine" } +klondike = { version = "0.2.0", registry = "Quaternions" } # Bevy with `default-features = false` to avoid the unused # `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain. diff --git a/solitaire_core/Cargo.toml b/solitaire_core/Cargo.toml index d2468b4..57a700b 100644 --- a/solitaire_core/Cargo.toml +++ b/solitaire_core/Cargo.toml @@ -8,3 +8,4 @@ edition.workspace = true serde = { workspace = true } thiserror = { workspace = true } rand = { workspace = true } +klondike = { workspace = true } diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index 9be029a..9c79c75 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -3,10 +3,8 @@ use crate::deck::{Deck, deal_klondike}; use crate::error::MoveError; use crate::pile::{Pile, PileType}; use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence}; -use crate::scoring::{ - compute_time_bonus as scoring_time_bonus, score_flip, score_move, score_recycle, - score_undo as scoring_undo, -}; +use crate::klondike_adapter::KlondikeAdapter; +use crate::scoring::compute_time_bonus as scoring_time_bonus; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, VecDeque}; @@ -166,6 +164,8 @@ pub struct GameState { #[serde(default = "schema_v1")] pub schema_version: u32, #[serde(skip)] + pub adapter: KlondikeAdapter, + #[serde(skip)] undo_stack: VecDeque, } @@ -208,6 +208,7 @@ impl GameState { recycle_count: 0, take_from_foundation: true, schema_version: GAME_STATE_SCHEMA_VERSION, + adapter: KlondikeAdapter::new(draw_mode, true), undo_stack: VecDeque::new(), } } @@ -276,7 +277,7 @@ impl GameState { self.recycle_count = self.recycle_count.saturating_add(1); if self.mode != GameMode::Zen { let penalty = - score_recycle(self.recycle_count, self.draw_mode == DrawMode::DrawThree); + KlondikeAdapter::score_for_recycle(self.recycle_count, self.draw_mode == DrawMode::DrawThree); self.score = (self.score + penalty).max(0); } self.move_count = self.move_count.saturating_add(1); @@ -413,7 +414,7 @@ impl GameState { let score_delta = if self.mode == GameMode::Zen { 0 } else { - score_move(&from, &to) + self.adapter.score_for_move(&from, &to) }; self.push_snapshot(); @@ -446,7 +447,7 @@ impl GameState { .append(&mut moved); let flip_bonus = if flipped && self.mode != GameMode::Zen { - score_flip() + self.adapter.score_for_flip() } else { 0 }; @@ -480,7 +481,7 @@ impl GameState { self.score = if self.mode == GameMode::Zen { 0 } else { - (snapshot.score + scoring_undo()).max(0) + (snapshot.score + KlondikeAdapter::score_for_undo()).max(0) }; self.move_count = snapshot.move_count; self.is_won = false; @@ -726,6 +727,7 @@ impl GameState { mod tests { use super::*; use crate::card::{Card, Rank, Suit}; + use crate::klondike_adapter::KlondikeAdapter; fn new_game() -> GameState { GameState::new(42, DrawMode::DrawOne) @@ -1126,7 +1128,7 @@ mod tests { let score_before = g.score; g.draw().unwrap(); g.undo().unwrap(); - let expected = (score_before + scoring_undo()).max(0); + let expected = (score_before + KlondikeAdapter::score_for_undo()).max(0); assert_eq!(g.score, expected); } diff --git a/solitaire_core/src/klondike_adapter.rs b/solitaire_core/src/klondike_adapter.rs new file mode 100644 index 0000000..d2b28b3 --- /dev/null +++ b/solitaire_core/src/klondike_adapter.rs @@ -0,0 +1,139 @@ +//! Adapter bridging `solitaire_core` types to the upstream `klondike` crate. +//! +//! # Current scope (integration steps 1–4) +//! +//! [`KlondikeAdapter`] owns the authoritative [`KlondikeConfig`] and exposes +//! scoring helpers backed by [`ScoringConfig::DEFAULT`] (Windows XP Standard +//! values). [`GameState`] delegates scoring here so that klondike remains the +//! single source of truth for scoring constants. +//! +//! # Not yet implemented +//! +//! - Live [`klondike::Klondike`] shadow state (requires pile-mapping, step 2). +//! - Move validation via klondike's rule engine (step 2). +//! - DFS solver via [`klondike::KlondikeState`] (step 6). + +use klondike::{DrawStockConfig, KlondikeConfig, MoveFromFoundationConfig, ScoringConfig}; + +use crate::game_state::DrawMode; +use crate::pile::PileType; + +/// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate. +/// +/// Holds a [`KlondikeConfig`] reflecting the current game settings and exposes +/// scoring helpers that read from [`ScoringConfig::DEFAULT`] (WXP values). +/// [`GameState`] uses this instead of calling `scoring.rs` functions directly. +#[derive(Clone, Debug)] +pub struct KlondikeAdapter { + config: KlondikeConfig, +} + +impl PartialEq for KlondikeAdapter { + fn eq(&self, other: &Self) -> bool { + self.config.draw_stock == other.config.draw_stock + && self.config.move_from_foundation == other.config.move_from_foundation + } +} +impl Eq for KlondikeAdapter {} + +impl Default for KlondikeAdapter { + /// Returns an adapter with Draw-1 and `take_from_foundation = true`, + /// matching `GameState`'s own defaults. Used by `#[serde(skip)]` + /// field initialisation on deserialisation. + fn default() -> Self { + Self::new(DrawMode::DrawOne, true) + } +} + +impl KlondikeAdapter { + /// Create an adapter from the game's draw mode and foundation house-rule setting. + /// + /// `take_from_foundation = true` maps to [`MoveFromFoundationConfig::Allowed`]; + /// `false` maps to [`MoveFromFoundationConfig::Disallowed`]. + pub fn new(draw_mode: DrawMode, take_from_foundation: bool) -> Self { + let config = KlondikeConfig { + draw_stock: match draw_mode { + DrawMode::DrawOne => DrawStockConfig::DrawOne, + DrawMode::DrawThree => DrawStockConfig::DrawThree, + }, + move_from_foundation: if take_from_foundation { + MoveFromFoundationConfig::Allowed + } else { + MoveFromFoundationConfig::Disallowed + }, + scoring: ScoringConfig::DEFAULT, + }; + Self { config } + } + + /// Returns a reference to the underlying [`KlondikeConfig`]. + /// + /// Used by the solver and pile-mapping code added in later integration steps. + pub fn klondike_config(&self) -> &KlondikeConfig { + &self.config + } + + /// Update the foundation house-rule flag, keeping [`KlondikeConfig`] in sync. + pub fn set_take_from_foundation(&mut self, allowed: bool) { + self.config.move_from_foundation = if allowed { + MoveFromFoundationConfig::Allowed + } else { + MoveFromFoundationConfig::Disallowed + }; + } + + // ── 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(&self, from: &PileType, to: &PileType) -> i32 { + let sc = &self.config.scoring; + match (from, to) { + (_, PileType::Foundation(_)) => sc.move_to_foundation, + (PileType::Waste, PileType::Tableau(_)) => sc.move_to_tableau, + (PileType::Foundation(_), PileType::Tableau(_)) => sc.move_from_foundation, + _ => 0, + } + } + + /// Score delta for exposing a face-down tableau card: +5. + pub fn score_for_flip(&self) -> i32 { + self.config.scoring.flip_up_bonus + } + + /// Score delta for undo: −15. + /// + /// [`card_game::Session`] handles this via `SessionConfig::undo_penalty` + /// (default −15). We mirror the constant here so `GameState` can apply it + /// in its snapshot-based undo path without owning a `Session`. + pub const 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 | + /// + /// `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 + } + } +} diff --git a/solitaire_core/src/lib.rs b/solitaire_core/src/lib.rs index d6042a9..4cadff9 100644 --- a/solitaire_core/src/lib.rs +++ b/solitaire_core/src/lib.rs @@ -3,6 +3,7 @@ pub mod card; pub mod deck; pub mod error; pub mod game_state; +pub mod klondike_adapter; pub mod pile; pub mod rules; pub mod scoring;