9260ca7994
Build and Deploy / build-and-push (push) Failing after 1m24s
- 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>
504 lines
18 KiB
Rust
504 lines
18 KiB
Rust
//! 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<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 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<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
|
||
}
|