//! Adapter bridging `solitaire_core` types to the upstream `klondike` crate. //! //! [`KlondikeAdapter`] is a pure helper namespace for: //! - building [`KlondikeConfig`] from Ferrous settings //! - translating between local and upstream types //! - applying Ferrous-specific scoring policy on top of upstream defaults //! //! All `From` / `TryFrom` conversions between `solitaire_core` product types and //! upstream `card_game` / `klondike` types live here so that the product modules //! (`card`, `pile`, etc.) remain free of upstream dependencies. use klondike::{ DrawStockConfig, DstFoundation, DstTableau, Foundation, KlondikeConfig, KlondikeInstruction, KlondikePile, KlondikePileStack, MoveFromFoundationConfig, ScoringConfig, SkipCards, Tableau, TableauStack, }; use serde::{Deserialize, Serialize}; use crate::game_state::GameMode; /// Whether cards are drawn one at a time or three at a time from the stock. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum DrawMode { /// Draw one card from stock per turn. DrawOne, /// Draw three cards from stock per turn; only the top is playable. DrawThree, } /// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate. /// /// This type is intentionally zero-sized: it does not carry mutable runtime /// state, and exists only as a namespace for configuration, conversion, and /// scoring helpers. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct KlondikeAdapter; impl KlondikeAdapter { /// Build a [`KlondikeConfig`] from draw mode and foundation house-rule setting. pub fn config_for(draw_mode: DrawMode, take_from_foundation: bool) -> KlondikeConfig { 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, } } // ── 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`]. pub fn tableau_from_index(index: usize) -> Option { match index { 0 => Some(Tableau::Tableau1), 1 => Some(Tableau::Tableau2), 2 => Some(Tableau::Tableau3), 3 => Some(Tableau::Tableau4), 4 => Some(Tableau::Tableau5), 5 => Some(Tableau::Tableau6), 6 => Some(Tableau::Tableau7), _ => None, } } /// Convert a zero-based foundation slot (0..=3) into [`Foundation`]. pub fn foundation_from_slot(slot: u8) -> Option { match slot { 0 => Some(Foundation::Foundation1), 1 => Some(Foundation::Foundation2), 2 => Some(Foundation::Foundation3), 3 => Some(Foundation::Foundation4), _ => None, } } /// Convert a tableau skip count (0..=12) into [`SkipCards`]. pub fn skip_cards_from_count(skip: usize) -> Option { match skip { 0 => Some(SkipCards::Skip0), 1 => Some(SkipCards::Skip1), 2 => Some(SkipCards::Skip2), 3 => Some(SkipCards::Skip3), 4 => Some(SkipCards::Skip4), 5 => Some(SkipCards::Skip5), 6 => Some(SkipCards::Skip6), 7 => Some(SkipCards::Skip7), 8 => Some(SkipCards::Skip8), 9 => Some(SkipCards::Skip9), 10 => Some(SkipCards::Skip10), 11 => Some(SkipCards::Skip11), 12 => Some(SkipCards::Skip12), _ => 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` (schema v4). // // The mirror types are retained for three reasons: // 1. Schema v3 migration: `AnyInstruction` in `game_state.rs` uses // `TryFrom 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 0–6)")] Tableau(u8), #[error("invalid foundation index {0} (expected 0–3)")] Foundation(u8), #[error("invalid skip_cards value {0} (expected 0–12)")] SkipCards(u8), } // ── From impls: KlondikeInstruction → Saved* ───────────────────────────────── impl From for SavedTableau { fn from(t: Tableau) -> Self { Self(t as u8) } } impl From for SavedFoundation { fn from(f: Foundation) -> Self { Self(f as u8) } } impl From for SavedSkipCards { fn from(s: SkipCards) -> Self { Self(s as u8) } } impl From 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 for SavedTableauStack { fn from(ts: TableauStack) -> Self { Self { tableau: ts.tableau.into(), skip_cards: ts.skip_cards.into(), } } } impl From 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 for SavedDstFoundation { fn from(df: DstFoundation) -> Self { Self { src: df.src.into(), foundation: df.foundation.into(), } } } impl From for SavedDstTableau { fn from(dt: DstTableau) -> Self { Self { src: dt.src.into(), tableau: dt.tableau.into(), } } } impl From 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 for Tableau { type Error = InvalidSavedInstruction; fn try_from(s: SavedTableau) -> Result { tableau_from_index(s.0 as usize).ok_or(InvalidSavedInstruction::Tableau(s.0)) } } impl TryFrom for Foundation { type Error = InvalidSavedInstruction; fn try_from(s: SavedFoundation) -> Result { foundation_from_slot(s.0).ok_or(InvalidSavedInstruction::Foundation(s.0)) } } impl TryFrom for SkipCards { type Error = InvalidSavedInstruction; fn try_from(s: SavedSkipCards) -> Result { skip_cards_from_count(s.0 as usize).ok_or(InvalidSavedInstruction::SkipCards(s.0)) } } impl TryFrom for KlondikePile { type Error = InvalidSavedInstruction; fn try_from(s: SavedKlondikePile) -> Result { 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 for TableauStack { type Error = InvalidSavedInstruction; fn try_from(s: SavedTableauStack) -> Result { Ok(TableauStack { tableau: s.tableau.try_into()?, skip_cards: s.skip_cards.try_into()?, }) } } impl TryFrom for KlondikePileStack { type Error = InvalidSavedInstruction; fn try_from(s: SavedKlondikePileStack) -> Result { 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 for DstFoundation { type Error = InvalidSavedInstruction; fn try_from(s: SavedDstFoundation) -> Result { Ok(DstFoundation { src: s.src.try_into()?, foundation: s.foundation.try_into()?, }) } } impl TryFrom for DstTableau { type Error = InvalidSavedInstruction; fn try_from(s: SavedDstTableau) -> Result { Ok(DstTableau { src: s.src.try_into()?, tableau: s.tableau.try_into()?, }) } } impl TryFrom for KlondikeInstruction { type Error = InvalidSavedInstruction; fn try_from(s: SavedInstruction) -> Result { 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 }