feat(core): add klondike v0.2.0 dep and KlondikeAdapter (integration steps 1, 3, 4)
Build and Deploy / build-and-push (push) Failing after 1m0s

Step 1 — Cargo & registry:
- Add .cargo/config.toml with Quaternions sparse registry
  (https://git.aleshym.co/api/packages/Quaternions/cargo/)
- Add klondike = "0.2.0" to workspace deps (+ card_game v0.3.0,
  arrayvec v0.7.6 as transitives via the Quaternions registry)
- Add klondike as a solitaire_core dep

Step 3 — KlondikeConfig / MoveFromFoundationConfig:
- KlondikeAdapter::new(draw_mode, take_from_foundation) builds a
  KlondikeConfig with the correct DrawStockConfig and
  MoveFromFoundationConfig (Allowed/Disallowed); exposes it via
  klondike_config() for future solver and pile-mapping steps

Step 4 — Scoring via ScoringConfig:
- GameState.adapter (serde(skip)) owns the authoritative KlondikeConfig
  with ScoringConfig::DEFAULT (WXP values)
- score_for_move/flip/undo/recycle replace direct scoring.rs calls;
  scoring.rs retained for reference and future deletion
- score_for_recycle implements the WXP free-recycle allowance rule
  that ScoringConfig::recycle cannot express (flat delta)
- PartialEq/Eq for KlondikeAdapter compare draw_stock and
  move_from_foundation only (scoring is always DEFAULT)

All 192 solitaire_core tests pass; clippy -D warnings clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-29 14:34:22 -07:00
parent 0a6eb8c610
commit f1914b4398
7 changed files with 220 additions and 21 deletions
+11 -9
View File
@@ -3,10 +3,8 @@ use crate::deck::{Deck, deal_klondike};
use crate::error::MoveError;
use crate::pile::{Pile, PileType};
use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence};
use crate::scoring::{
compute_time_bonus as scoring_time_bonus, score_flip, score_move, score_recycle,
score_undo as scoring_undo,
};
use crate::klondike_adapter::KlondikeAdapter;
use crate::scoring::compute_time_bonus as scoring_time_bonus;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
@@ -166,6 +164,8 @@ pub struct GameState {
#[serde(default = "schema_v1")]
pub schema_version: u32,
#[serde(skip)]
pub adapter: KlondikeAdapter,
#[serde(skip)]
undo_stack: VecDeque<StateSnapshot>,
}
@@ -208,6 +208,7 @@ impl GameState {
recycle_count: 0,
take_from_foundation: true,
schema_version: GAME_STATE_SCHEMA_VERSION,
adapter: KlondikeAdapter::new(draw_mode, true),
undo_stack: VecDeque::new(),
}
}
@@ -276,7 +277,7 @@ impl GameState {
self.recycle_count = self.recycle_count.saturating_add(1);
if self.mode != GameMode::Zen {
let penalty =
score_recycle(self.recycle_count, self.draw_mode == DrawMode::DrawThree);
KlondikeAdapter::score_for_recycle(self.recycle_count, self.draw_mode == DrawMode::DrawThree);
self.score = (self.score + penalty).max(0);
}
self.move_count = self.move_count.saturating_add(1);
@@ -413,7 +414,7 @@ impl GameState {
let score_delta = if self.mode == GameMode::Zen {
0
} else {
score_move(&from, &to)
self.adapter.score_for_move(&from, &to)
};
self.push_snapshot();
@@ -446,7 +447,7 @@ impl GameState {
.append(&mut moved);
let flip_bonus = if flipped && self.mode != GameMode::Zen {
score_flip()
self.adapter.score_for_flip()
} else {
0
};
@@ -480,7 +481,7 @@ impl GameState {
self.score = if self.mode == GameMode::Zen {
0
} else {
(snapshot.score + scoring_undo()).max(0)
(snapshot.score + KlondikeAdapter::score_for_undo()).max(0)
};
self.move_count = snapshot.move_count;
self.is_won = false;
@@ -726,6 +727,7 @@ impl GameState {
mod tests {
use super::*;
use crate::card::{Card, Rank, Suit};
use crate::klondike_adapter::KlondikeAdapter;
fn new_game() -> GameState {
GameState::new(42, DrawMode::DrawOne)
@@ -1126,7 +1128,7 @@ mod tests {
let score_before = g.score;
g.draw().unwrap();
g.undo().unwrap();
let expected = (score_before + scoring_undo()).max(0);
let expected = (score_before + KlondikeAdapter::score_for_undo()).max(0);
assert_eq!(g.score, expected);
}
+139
View File
@@ -0,0 +1,139 @@
//! 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).
use klondike::{DrawStockConfig, KlondikeConfig, MoveFromFoundationConfig, ScoringConfig};
use crate::game_state::DrawMode;
use crate::pile::PileType;
/// 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: &PileType, to: &PileType) -> i32 {
let sc = &self.config.scoring;
match (from, to) {
(_, PileType::Foundation(_)) => sc.move_to_foundation,
(PileType::Waste, PileType::Tableau(_)) => sc.move_to_tableau,
(PileType::Foundation(_), PileType::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
}
}
}
+1
View File
@@ -3,6 +3,7 @@ pub mod card;
pub mod deck;
pub mod error;
pub mod game_state;
pub mod klondike_adapter;
pub mod pile;
pub mod rules;
pub mod scoring;