2d0359c2ee
Move both crates off the damaged "hacked" rev 99b49e62 onto mainline master (card_game 0.4.0->0.4.1, klondike 0.3.0->0.4.0) to pick up the new serialize implementation. Mainline drops the serde derives from Deck/Suit/Rank (only Card is serde now, as a compact transparent NonZeroU8) and gives KlondikeInstruction a hand-written serde impl. Adapt the repo: - Rank::value() was removed; the enum discriminant is the 1..=13 value, so use `rank as u32/u8` in the three card_to_id helpers (wasm, radial_menu, feedback_anim). - Drop the vestigial Serialize/Deserialize derive on theme::CardKey; theme manifests address faces by manifest_name strings, never by serialising CardKey, and Suit/Rank no longer implement serde. GameState's own instruction-mirror serde (schema v3/v4) is insulated from the klondike serde change, so the on-disk save format is unchanged. cargo test --workspace and cargo clippy --workspace -- -D warnings pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
479 lines
17 KiB
Rust
479 lines
17 KiB
Rust
//! Adapter bridging `solitaire_core` types to the upstream `klondike` crate.
|
||
//!
|
||
//! [`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
|
||
//!
|
||
//! 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 klondike::{
|
||
DrawStockConfig, DstFoundation, DstTableau, Foundation, KlondikeConfig, KlondikeInstruction,
|
||
KlondikePile, KlondikePileStack, MoveFromFoundationConfig, ScoringConfig, SkipCards, Tableau,
|
||
TableauStack,
|
||
};
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::game_state::GameMode;
|
||
|
||
/// Whether cards are drawn one at a time or three at a time from the stock.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum DrawMode {
|
||
/// Draw one card from stock per turn.
|
||
DrawOne,
|
||
/// Draw three cards from stock per turn; only the top is playable.
|
||
DrawThree,
|
||
}
|
||
|
||
/// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate.
|
||
///
|
||
/// This type is intentionally zero-sized: it does not carry mutable runtime
|
||
/// state, and exists only as a namespace for configuration, conversion, and
|
||
/// scoring helpers.
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||
pub struct KlondikeAdapter;
|
||
|
||
impl KlondikeAdapter {
|
||
/// Build a [`KlondikeConfig`] from draw mode and foundation house-rule setting.
|
||
pub fn config_for(draw_mode: DrawMode, take_from_foundation: bool) -> KlondikeConfig {
|
||
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,
|
||
}
|
||
}
|
||
|
||
// ── 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(from: &KlondikePile, to: &KlondikePile) -> i32 {
|
||
let sc = ScoringConfig::DEFAULT;
|
||
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() -> i32 {
|
||
ScoringConfig::DEFAULT.flip_up_bonus
|
||
}
|
||
|
||
/// Score delta for undo: −15.
|
||
///
|
||
/// 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
|
||
}
|
||
|
||
/// 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 |
|
||
///
|
||
/// **Design note:** recycling is *never* blocked — only penalised.
|
||
/// This is intentional: Draw-1 can be played indefinitely with the score
|
||
/// dropping toward zero after the first free recycle. A hard cap would
|
||
/// create unwinnable positions when the solver cannot find a path without
|
||
/// additional recycling. Zen mode suppresses the penalty entirely.
|
||
///
|
||
/// `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(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(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)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Convert a zero-based tableau index (0..=6) into [`Tableau`].
|
||
pub fn tableau_from_index(index: usize) -> Option<Tableau> {
|
||
match index {
|
||
0 => Some(Tableau::Tableau1),
|
||
1 => Some(Tableau::Tableau2),
|
||
2 => Some(Tableau::Tableau3),
|
||
3 => Some(Tableau::Tableau4),
|
||
4 => Some(Tableau::Tableau5),
|
||
5 => Some(Tableau::Tableau6),
|
||
6 => Some(Tableau::Tableau7),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Convert a zero-based foundation slot (0..=3) into [`Foundation`].
|
||
pub fn foundation_from_slot(slot: u8) -> Option<Foundation> {
|
||
match slot {
|
||
0 => Some(Foundation::Foundation1),
|
||
1 => Some(Foundation::Foundation2),
|
||
2 => Some(Foundation::Foundation3),
|
||
3 => Some(Foundation::Foundation4),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Convert a tableau skip count (0..=12) into [`SkipCards`].
|
||
pub fn skip_cards_from_count(skip: usize) -> Option<SkipCards> {
|
||
match skip {
|
||
0 => Some(SkipCards::Skip0),
|
||
1 => Some(SkipCards::Skip1),
|
||
2 => Some(SkipCards::Skip2),
|
||
3 => Some(SkipCards::Skip3),
|
||
4 => Some(SkipCards::Skip4),
|
||
5 => Some(SkipCards::Skip5),
|
||
6 => Some(SkipCards::Skip6),
|
||
7 => Some(SkipCards::Skip7),
|
||
8 => Some(SkipCards::Skip8),
|
||
9 => Some(SkipCards::Skip9),
|
||
10 => Some(SkipCards::Skip10),
|
||
11 => Some(SkipCards::Skip11),
|
||
12 => Some(SkipCards::Skip12),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
// ── Legacy serde mirror types (kept for backward compatibility) ───────────────
|
||
//
|
||
// These types were introduced when upstream `klondike` had no serde feature.
|
||
// Mainline `klondike` now provides full serde support (with a hand-written
|
||
// compact `KlondikeInstruction` impl), and `GameState` serialises
|
||
// `saved_moves` directly as `Vec<KlondikeInstruction>` (schema v4).
|
||
//
|
||
// The mirror types are retained for three reasons:
|
||
// 1. Schema v3 migration: `AnyInstruction` in `game_state.rs` uses
|
||
// `TryFrom<SavedInstruction> for KlondikeInstruction` to parse old save
|
||
// files with u8 indices and replay them.
|
||
// 2. `solitaire_data::ReplayMove` uses `SavedKlondikePile` as its serde
|
||
// type; changing it would break the on-disk replay format (schema v2).
|
||
// 3. `solitaire_wasm` mirrors `ReplayMove` using the same types so that
|
||
// replay JSON is cross-compatible between the desktop and browser builds.
|
||
//
|
||
// These types should not be used for new serialisation concerns. If the
|
||
// ReplayMove format is ever bumped to a new schema, migrate those callers to
|
||
// `KlondikePile` / `KlondikePileStack` and the types here can then be deleted.
|
||
|
||
/// 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> {
|
||
tableau_from_index(s.0 as usize).ok_or(InvalidSavedInstruction::Tableau(s.0))
|
||
}
|
||
}
|
||
|
||
impl TryFrom<SavedFoundation> for Foundation {
|
||
type Error = InvalidSavedInstruction;
|
||
fn try_from(s: SavedFoundation) -> Result<Self, Self::Error> {
|
||
foundation_from_slot(s.0).ok_or(InvalidSavedInstruction::Foundation(s.0))
|
||
}
|
||
}
|
||
|
||
impl TryFrom<SavedSkipCards> for SkipCards {
|
||
type Error = InvalidSavedInstruction;
|
||
fn try_from(s: SavedSkipCards) -> Result<Self, Self::Error> {
|
||
skip_cards_from_count(s.0 as usize).ok_or(InvalidSavedInstruction::SkipCards(s.0))
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|