Files
Ferrous-Solitaire/solitaire_core/src/klondike_adapter.rs
T
funman300 9260ca7994
Build and Deploy / build-and-push (push) Failing after 1m24s
refactor: migrate PileType → KlondikePile across core/wasm/engine
- Replace PileType with typed KlondikePile (Foundation/Tableau variants)
  throughout solitaire_core, solitaire_wasm, and solitaire_engine;
  ReplayMove now uses SavedKlondikePile for serialisation stability
- Split replay_overlay.rs into replay_overlay/ module (mod, format,
  input, update, tests) for maintainability
- Add klondike dep to solitaire_engine and solitaire_data Cargo.toml
- Add TestPileState infrastructure to game_state.rs for engine unit tests
- Rebuild solitaire_wasm pkg (js + wasm artefacts updated)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 13:13:35 -07:00

504 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Adapter bridging `solitaire_core` types to the upstream `klondike` crate.
//!
//! # Current scope (integration steps 14)
//!
//! [`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 (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;
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<KlondikeInstruction> for SavedInstruction` and the
// fallible inverse `TryFrom<SavedInstruction> 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 06)")]
Tableau(u8),
#[error("invalid foundation index {0} (expected 03)")]
Foundation(u8),
#[error("invalid skip_cards value {0} (expected 012)")]
SkipCards(u8),
}
// ── From impls: KlondikeInstruction → Saved* ─────────────────────────────────
impl From<Tableau> for SavedTableau {
fn from(t: Tableau) -> Self {
Self(t as u8)
}
}
impl From<Foundation> for SavedFoundation {
fn from(f: Foundation) -> Self {
Self(f as u8)
}
}
impl From<SkipCards> for SavedSkipCards {
fn from(s: SkipCards) -> Self {
Self(s as u8)
}
}
impl From<KlondikePile> 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<TableauStack> for SavedTableauStack {
fn from(ts: TableauStack) -> Self {
Self { tableau: ts.tableau.into(), skip_cards: ts.skip_cards.into() }
}
}
impl From<KlondikePileStack> 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<DstFoundation> for SavedDstFoundation {
fn from(df: DstFoundation) -> Self {
Self { src: df.src.into(), foundation: df.foundation.into() }
}
}
impl From<DstTableau> for SavedDstTableau {
fn from(dt: DstTableau) -> Self {
Self { src: dt.src.into(), tableau: dt.tableau.into() }
}
}
impl From<KlondikeInstruction> 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<SavedTableau> for Tableau {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedTableau) -> Result<Self, Self::Error> {
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<SavedFoundation> for Foundation {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedFoundation) -> Result<Self, Self::Error> {
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<SavedSkipCards> for SkipCards {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedSkipCards) -> Result<Self, Self::Error> {
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<SavedKlondikePile> for KlondikePile {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedKlondikePile) -> Result<Self, Self::Error> {
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<SavedTableauStack> for TableauStack {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedTableauStack) -> Result<Self, Self::Error> {
Ok(TableauStack {
tableau: s.tableau.try_into()?,
skip_cards: s.skip_cards.try_into()?,
})
}
}
impl TryFrom<SavedKlondikePileStack> for KlondikePileStack {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedKlondikePileStack) -> Result<Self, Self::Error> {
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<SavedDstFoundation> for DstFoundation {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedDstFoundation) -> Result<Self, Self::Error> {
Ok(DstFoundation { src: s.src.try_into()?, foundation: s.foundation.try_into()? })
}
}
impl TryFrom<SavedDstTableau> for DstTableau {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedDstTableau) -> Result<Self, Self::Error> {
Ok(DstTableau { src: s.src.try_into()?, tableau: s.tableau.try_into()? })
}
}
impl TryFrom<SavedInstruction> for KlondikeInstruction {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedInstruction) -> Result<Self, Self::Error> {
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
}