//! 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, now delegated to upstream). use card_game::{Card as KlCard, Rank as KlRank, Suit as KlSuit}; use klondike::{ DrawStockConfig, DstFoundation, DstTableau, Foundation, KlondikeConfig, KlondikeInstruction, KlondikePile, KlondikePileStack, MoveFromFoundationConfig, ScoringConfig, SkipCards, Tableau, TableauStack, }; use serde::{Deserialize, Serialize}; use crate::game_state::{DrawMode, GameMode}; /// 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: &KlondikePile, to: &KlondikePile) -> i32 { let sc = &self.config.scoring; 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(&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 } } /// 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( &self, 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(&self, 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) } } } // ── Type-conversion utilities ───────────────────────────────────────────── /// Convert [`card_game::Suit`] back to our [`crate::card::Suit`]. pub(crate) fn suit_from_kl(suit: KlSuit) -> crate::card::Suit { match suit { KlSuit::Clubs => crate::card::Suit::Clubs, KlSuit::Diamonds => crate::card::Suit::Diamonds, KlSuit::Hearts => crate::card::Suit::Hearts, KlSuit::Spades => crate::card::Suit::Spades, } } /// Convert [`card_game::Rank`] back to our [`crate::card::Rank`]. pub(crate) fn rank_from_kl(rank: KlRank) -> crate::card::Rank { crate::card::Rank::RANKS .into_iter() .find(|r| r.value() == rank as u8) .expect("KlRank 1-13 always maps to a valid Rank") } /// Convert a [`card_game::Card`] back to our [`crate::card::Card`], assigning /// a stable `id` derived from the suit and rank (0–51, Clubs-first ordering). /// /// The id is consistent for the same logical card across all reconstructions. pub fn card_from_kl(card: &KlCard) -> crate::card::Card { let suit = suit_from_kl(card.suit()); let rank = rank_from_kl(card.rank()); let suit_index = crate::card::Suit::SUITS .iter() .position(|s| *s == suit) .expect("suit always in SUITS") as u32; let id = suit_index * 13 + (rank.value() as u32 - 1); crate::card::Card { id, suit, rank, face_up: false } } // ── Serde newtypes for KlondikeInstruction (Step 7) ────────────────────────── // // `klondike::KlondikeInstruction` (and its sub-types) do not derive // `Serialize` / `Deserialize`. These mirror types carry `#[serde]` so that // the session instruction history can be persisted and reconstructed without // upstream changes. // // Conversion: `From for SavedInstruction` and the // fallible inverse `TryFrom for KlondikeInstruction`. // Invalid numeric values (out-of-range u8 for tableau/foundation/skip) yield // `InvalidSavedInstruction`. /// 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 { match s.0 { 0 => Ok(Tableau::Tableau1), 1 => Ok(Tableau::Tableau2), 2 => Ok(Tableau::Tableau3), 3 => Ok(Tableau::Tableau4), 4 => Ok(Tableau::Tableau5), 5 => Ok(Tableau::Tableau6), 6 => Ok(Tableau::Tableau7), n => Err(InvalidSavedInstruction::Tableau(n)), } } } impl TryFrom for Foundation { type Error = InvalidSavedInstruction; fn try_from(s: SavedFoundation) -> Result { match s.0 { 0 => Ok(Foundation::Foundation1), 1 => Ok(Foundation::Foundation2), 2 => Ok(Foundation::Foundation3), 3 => Ok(Foundation::Foundation4), n => Err(InvalidSavedInstruction::Foundation(n)), } } } impl TryFrom for SkipCards { type Error = InvalidSavedInstruction; fn try_from(s: SavedSkipCards) -> Result { match s.0 { 0 => Ok(SkipCards::Skip0), 1 => Ok(SkipCards::Skip1), 2 => Ok(SkipCards::Skip2), 3 => Ok(SkipCards::Skip3), 4 => Ok(SkipCards::Skip4), 5 => Ok(SkipCards::Skip5), 6 => Ok(SkipCards::Skip6), 7 => Ok(SkipCards::Skip7), 8 => Ok(SkipCards::Skip8), 9 => Ok(SkipCards::Skip9), 10 => Ok(SkipCards::Skip10), 11 => Ok(SkipCards::Skip11), 12 => Ok(SkipCards::Skip12), n => Err(InvalidSavedInstruction::SkipCards(n)), } } } 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 }