diff --git a/Cargo.lock b/Cargo.lock index 531855c..150cb24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7032,6 +7032,7 @@ dependencies = [ name = "solitaire_core" version = "0.1.0" dependencies = [ + "card_game", "klondike", "rand 0.9.4", "serde", diff --git a/Cargo.toml b/Cargo.toml index 59e4eec..fcb965e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ solitaire_sync = { path = "solitaire_sync" } solitaire_data = { path = "solitaire_data" } solitaire_engine = { path = "solitaire_engine" } klondike = { version = "0.2.0", registry = "Quaternions" } +card_game = { version = "0.3.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 57a700b..afe36f0 100644 --- a/solitaire_core/Cargo.toml +++ b/solitaire_core/Cargo.toml @@ -9,3 +9,4 @@ serde = { workspace = true } thiserror = { workspace = true } rand = { workspace = true } klondike = { workspace = true } +card_game = { workspace = true } diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index 9c79c75..c963768 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -275,11 +275,12 @@ impl GameState { stock.cards.push(card); } self.recycle_count = self.recycle_count.saturating_add(1); - if self.mode != GameMode::Zen { - let penalty = - KlondikeAdapter::score_for_recycle(self.recycle_count, self.draw_mode == DrawMode::DrawThree); - self.score = (self.score + penalty).max(0); - } + let penalty = KlondikeAdapter::score_for_recycle_with_mode( + self.recycle_count, + self.draw_mode == DrawMode::DrawThree, + self.mode, + ); + self.score = (self.score + penalty).max(0); self.move_count = self.move_count.saturating_add(1); return Ok(()); } @@ -411,11 +412,7 @@ impl GameState { start }; - let score_delta = if self.mode == GameMode::Zen { - 0 - } else { - self.adapter.score_for_move(&from, &to) - }; + let score_delta = self.adapter.score_for_move_with_mode(&from, &to, self.mode); self.push_snapshot(); // Execute move @@ -446,8 +443,8 @@ impl GameState { .cards .append(&mut moved); - let flip_bonus = if flipped && self.mode != GameMode::Zen { - self.adapter.score_for_flip() + let flip_bonus = if flipped { + self.adapter.score_for_flip_with_mode(self.mode) } else { 0 }; @@ -478,11 +475,7 @@ impl GameState { .pop_back() .ok_or(MoveError::UndoStackEmpty)?; self.piles = snapshot.piles; - self.score = if self.mode == GameMode::Zen { - 0 - } else { - (snapshot.score + KlondikeAdapter::score_for_undo()).max(0) - }; + self.score = KlondikeAdapter::apply_undo_score(snapshot.score, self.mode); self.move_count = snapshot.move_count; self.is_won = false; self.is_auto_completable = false; diff --git a/solitaire_core/src/klondike_adapter.rs b/solitaire_core/src/klondike_adapter.rs index d2b28b3..901758f 100644 --- a/solitaire_core/src/klondike_adapter.rs +++ b/solitaire_core/src/klondike_adapter.rs @@ -13,9 +13,10 @@ //! - Move validation via klondike's rule engine (step 2). //! - DFS solver via [`klondike::KlondikeState`] (step 6). +use card_game::{Card as KlCard, Deck as KlDeck, Rank as KlRank, Suit as KlSuit}; use klondike::{DrawStockConfig, KlondikeConfig, MoveFromFoundationConfig, ScoringConfig}; -use crate::game_state::DrawMode; +use crate::game_state::{DrawMode, GameMode}; use crate::pile::PileType; /// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate. @@ -136,4 +137,110 @@ impl KlondikeAdapter { 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: &PileType, to: &PileType, 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 (Step 2 — pile mapping) ──────────────────── +// +// These are used to translate between solitaire_core's Card/PileType and the +// card_game / klondike types when projecting KlondikeState into the pile +// snapshot that the engine reads. A live KlondikeState shadow requires an +// upstream `KlondikeState::from_piles()` constructor; these utilities are +// ready to wire in once that's available. + +/// Convert our [`crate::card::Suit`] to [`card_game::Suit`]. +pub fn suit_to_kl(suit: crate::card::Suit) -> KlSuit { + match suit { + crate::card::Suit::Clubs => KlSuit::Clubs, + crate::card::Suit::Diamonds => KlSuit::Diamonds, + crate::card::Suit::Hearts => KlSuit::Hearts, + crate::card::Suit::Spades => KlSuit::Spades, + } +} + +/// Convert [`card_game::Suit`] back to our [`crate::card::Suit`]. +pub 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 our [`crate::card::Rank`] to [`card_game::Rank`]. +pub fn rank_to_kl(rank: crate::card::Rank) -> KlRank { + KlRank::new(rank.value()).expect("rank value 1-13 always maps to a valid KlRank") +} + +/// Convert [`card_game::Rank`] back to our [`crate::card::Rank`]. +pub 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 our [`crate::card::Card`] to a [`card_game::Card`] (Deck1, same suit/rank). +/// +/// The `id` field is dropped; use [`card_to_kl`] only when the klondike engine +/// needs to evaluate the card's logical identity, not its animation entity. +pub fn card_to_kl(card: &crate::card::Card) -> KlCard { + KlCard::new(KlDeck::Deck1, suit_to_kl(card.suit), rank_to_kl(card.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). +/// +/// This id matches the id assigned in [`crate::deck::Deck::new`] and 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 } }