refactor(core): integrate card_game/klondike deps cleanly
Build and Deploy / build-and-push (push) Failing after 56s
Web E2E / web-e2e (push) Failing after 3m14s

Wire card_game 0.4.0 and klondike 0.3.0 as workspace deps in
solitaire_core and clean the integration seam across five areas:

- Move From<card_game::Suit/Rank> bridge impls out of card.rs and into
  klondike_adapter.rs so the product-type module is upstream-dep-free
- Add `use crate::card` alias to adapter; rename card_from_kl parameter
  to avoid shadowing; correct score_for_undo doc (it is Ferrous policy,
  not an upstream default — the solver explicitly passes undo_penalty=0)
- Mark Pile as a read-only projection / data-transfer type in its doc
  comment so game logic isn't accidentally routed through it
- Add GameState::session() read accessor exposing the underlying
  Session<Klondike> for replay history and solver use by external crates;
  update solver.rs to use the accessor instead of the pub(crate) field
- Re-export Foundation, Klondike, KlondikePile, Session, Tableau from
  solitaire_core::lib so downstream crates (engine, wasm) can import
  from one place without a direct klondike/card_game dep
- Add proptest property tests: card conservation (52 unique IDs always
  present), deal determinism, undo pile-layout invariant, legal moves
  always succeed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-08 10:46:29 -07:00
parent 8bd2fb89eb
commit 5e8735886f
7 changed files with 349 additions and 51 deletions
+52 -40
View File
@@ -1,19 +1,15 @@
//! Adapter bridging `solitaire_core` types to the upstream `klondike` crate.
//!
//! # Current scope (integration steps 14)
//!
//! [`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
//!
//! # 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).
//! 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 card_game::{Card as KlCard, Rank as KlRank, Suit as KlSuit};
use card_game::Card as KlCard;
use klondike::{
DrawStockConfig, DstFoundation, DstTableau, Foundation, KlondikeConfig, KlondikeInstruction,
KlondikePile, KlondikePileStack, MoveFromFoundationConfig, ScoringConfig, SkipCards, Tableau,
@@ -21,6 +17,7 @@ use klondike::{
};
use serde::{Deserialize, Serialize};
use crate::card;
use crate::game_state::{DrawMode, GameMode};
/// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate.
@@ -74,10 +71,10 @@ impl KlondikeAdapter {
/// 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 {
/// 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
}
@@ -161,6 +158,37 @@ impl KlondikeAdapter {
// ── Type-conversion utilities ─────────────────────────────────────────────
impl From<card_game::Suit> for card::Suit {
fn from(s: card_game::Suit) -> Self {
match s {
card_game::Suit::Clubs => Self::Clubs,
card_game::Suit::Diamonds => Self::Diamonds,
card_game::Suit::Hearts => Self::Hearts,
card_game::Suit::Spades => Self::Spades,
}
}
}
impl From<card_game::Rank> for card::Rank {
fn from(r: card_game::Rank) -> Self {
match r {
card_game::Rank::Ace => Self::Ace,
card_game::Rank::Two => Self::Two,
card_game::Rank::Three => Self::Three,
card_game::Rank::Four => Self::Four,
card_game::Rank::Five => Self::Five,
card_game::Rank::Six => Self::Six,
card_game::Rank::Seven => Self::Seven,
card_game::Rank::Eight => Self::Eight,
card_game::Rank::Nine => Self::Nine,
card_game::Rank::Ten => Self::Ten,
card_game::Rank::Jack => Self::Jack,
card_game::Rank::Queen => Self::Queen,
card_game::Rank::King => Self::King,
}
}
}
/// Convert a zero-based tableau index (0..=6) into [`Tableau`].
pub fn tableau_from_index(index: usize) -> Option<Tableau> {
match index {
@@ -206,37 +234,21 @@ pub fn skip_cards_from_count(skip: usize) -> Option<SkipCards> {
}
}
/// 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 (051, Clubs-first ordering).
/// Convert a [`card_game::Card`] to a [`card::Card`], assigning a stable `id`
/// derived from suit and rank (051, 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;
pub fn card_from_kl(kl_card: &KlCard) -> card::Card {
let suit: card::Suit = kl_card.suit().into();
let rank: card::Rank = kl_card.rank().into();
let suit_index = match suit {
card::Suit::Clubs => 0,
card::Suit::Diamonds => 1,
card::Suit::Hearts => 2,
card::Suit::Spades => 3,
};
let id = suit_index * 13 + (rank.value() as u32 - 1);
crate::card::Card {
card::Card {
id,
suit,
rank,