14 KiB
Integrating card_game / klondike as the Solitaire Core
Context: A collaborator (Quaternions) is building a pure-logic Klondike library in Rust. This document maps what that library currently provides against what Ferrous Solitaire's solitaire_core crate requires.
Approach: Integration is complete. Upstream card_game / klondike now owns
authoritative Klondike rules, session history, undo snapshots, and solving.
Ferrous keeps product-specific scoring, persistence, rendering DTOs, game modes,
and typed UI errors in solitaire_core.
What card_game + klondike Already Has
card_game crate (generic primitives) — v0.4.0
| Feature | Notes |
|---|---|
Card (Deck + Suit + Rank packed in 1 byte) |
NonZeroU8 layout — no heap allocation |
Suit, Rank, Deck enums |
Full A→K, 4 suits, up to 4 deck IDs |
Stack<CAP> |
Const-generic ArrayVec wrapper |
Pile<DN, UP> |
Face-down + face-up stacks; flip_up, pop_flip_up |
Game trait |
possible_instructions, is_instruction_valid, process_instruction, is_win |
Session |
Wraps a Game; snapshot-based undo (O(1)), score including undo penalty |
Session::solve() |
Built-in DFS solver with move/state budgets; returns Solution<G> or SolveError |
StateSnapshot<G> |
Pre-move state + instruction; used by snapshot history and Solution |
SessionState::score() |
= game_score + undos × undo_penalty (−15 by default via SessionConfig) |
SessionConfig |
undo_penalty, solve_moves_budget, solve_states_budget |
klondike crate (Klondike rules) — v0.3.0
| Feature | Notes |
|---|---|
| 7 tableau + 4 foundation + 1 stock | Fully dealt from a seeded RNG |
| Draw-1 / Draw-3 config | KlondikeConfig::draw_stock (DrawStockConfig) |
MoveFromFoundationConfig |
Allowed (upstream default) / Disallowed; controls foundation → tableau rule |
ScoringConfig |
Configurable deltas: move_to_foundation (+10), flip_up_bonus (+5), move_to_tableau (+5), move_from_foundation (−15), recycle (0 by default) |
KlondikeStats::score(&config) |
Computes score from per-event counters × ScoringConfig deltas |
KlondikeStats counters |
move_to_foundation_count, flip_up_bonus_count, move_to_tableau_count, move_from_foundation_count, recycle_count, moves |
| Foundation placement (Ace start, suit-matched A→K) | ✅ |
| Tableau placement (alternating colour, K on empty) | ✅ |
Multi-card stack moves (via SkipCards) |
✅ |
RotateStock (recycle waste → stock) |
✅ |
is_win_trivial (all face-down cards cleared) |
Auto-complete trigger |
get_auto_move / get_sorted_moves |
Priority-ranked move suggestion (take &KlondikeConfig) |
Benchmark suite (klondike-bench) |
1 000-game throughput test |
CLI display (klondike-cli) |
Terminal renderer |
What Ferrous Solitaire's solitaire_core Still Owns
1. Scoring — remaining adapter responsibilities
Ferrous uses Windows XP Standard scoring. The upstream library handles the
per-move counters and configurable deltas; Ferrous adds the product-specific
parts in GameState / KlondikeAdapter.
| Event | Delta | Handled by |
|---|---|---|
| Any card → foundation | +10 | KlondikeStats / ScoringConfig::move_to_foundation ✅ |
| Waste → tableau | +5 | KlondikeStats / ScoringConfig::move_to_tableau ✅ |
| Flip face-down tableau card | +5 | KlondikeStats / ScoringConfig::flip_up_bonus ✅ |
| Foundation → tableau | −15 | KlondikeStats / ScoringConfig::move_from_foundation ✅ |
| Undo | −15 | SessionStats / SessionConfig::undo_penalty ✅ |
| Recycle (Draw-1, after 1st free) | −100 | Our adapter — see below |
| Recycle (Draw-3, after 3rd free) | −20 | Our adapter — see below |
| Score floor | score.max(0) always |
Our adapter |
| Time bonus on win | 700_000 / elapsed_seconds |
Our adapter (not wasm-portable) |
Reference: https://www.solitaireparadise.com/games_list/klondike_solitaire_scoring.html
Undo penalty: SessionState::score() = KlondikeStats.score(&scoring) + undos × undo_penalty. Ferrous still owns the exact user-visible score because it must restore the pre-move score when undoing recycle penalties and then apply the product's undo penalty.
Recycle penalty note: 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. Our adapter tracks recycle_count from KlondikeStats and applies the penalty only beyond the free allowance.
In our wrapper: KlondikeAdapter::config_for configures the upstream rules
and scoring deltas. GameState applies recycle-with-free-allowance, score floor,
time bonus, game-mode suppression, and undo score restoration.
2. Game Modes
Ferrous has three modes that alter scoring and undo behaviour:
| Mode | Scoring | Undo |
|---|---|---|
| Classic | Full WXP scoring (table above) | Allowed (−15 penalty) |
| Zen | All deltas suppressed — score stays 0 | Allowed (no penalty) |
| Challenge | Full WXP scoring | Disabled — returns an error |
Zen is intended for relaxed play where the score does not matter. Challenge is a timed daily puzzle where the no-undo constraint is the difficulty mechanic.
In our wrapper: GameMode lives on solitaire_core::GameState; undo and
scoring behavior are applied before/after delegating legal moves to the upstream
session.
3. Solvability Solver (upstream merged — card_game v0.4.0)
card_game v0.4.0 ships Session::solve() — a budget-bounded DFS that returns Result<Option<Solution<G>>, SolveError>. SolveError has two variants:
MovesBudgetExceeded— equivalent to ourSolverResult::InconclusiveStatesBudgetExceeded— equivalent to ourSolverResult::Inconclusive
Solution<G> contains the winning move sequence as Vec<StateSnapshot<G>>; clean_solution() removes cycles. Session::solve() uses SessionConfig::solve_moves_budget and SessionConfig::solve_states_budget (defaults: 100 000 each).
The old local DFS has been replaced. solitaire_core::solver is now a small
adapter around Session::solve() that preserves the engine-facing
SolverResult, SolverConfig, and first-move payload contract.
In our wrapper: solve_game_state calls session.solve() with the requested
budgets. It maps Ok(Some(_)) → Winnable, Ok(None) → Unwinnable, and budget
errors → Inconclusive.
4. take_from_foundation House Rule (upstream merged — v0.3.0)
MoveFromFoundationConfig is now part of KlondikeConfig. When set to Disallowed, is_instruction_valid blocks foundation → tableau instructions.
Default behaviour: The upstream default is MoveFromFoundationConfig::Allowed. Ferrous Solitaire also defaults to Allowed (take_from_foundation: true in GameState, Settings). This matches the upstream default and provides the most beginner-friendly experience. The player can disable foundation returns via a settings toggle (take_from_foundation = false), which maps to Disallowed.
In our wrapper: KlondikeAdapter::config_for(draw_mode, take_from_foundation) constructs KlondikeConfig { move_from_foundation: if take_from_foundation { Allowed } else { Disallowed }, .. }. No custom intercept needed — klondike enforces the rule automatically.
5. JSON Serialisation / Persistence
solitaire_core::GameState serialises the full mid-game state to JSON via serde so the engine can save on exit and restore on launch.
Upstream serde status (rev 99b49e62): At this revision, klondike and card_game both enable a serde feature. All nine instruction/pile types (KlondikeInstruction, KlondikePile, KlondikePileStack, DstFoundation, DstTableau, TableauStack, Foundation, Tableau, SkipCards) derive serde::Serialize + serde::Deserialize under that feature. The workspace Cargo.toml enables features = ["serde"].
Schema v4 (current): saved_moves serialises as Vec<KlondikeInstruction> using upstream named-variant serde. Example: {"DstFoundation": {"src": "Stock", "foundation": "Foundation1"}}.
Schema v3 (legacy, auto-migrated): saved_moves used local SavedInstruction mirror types with u8 indices. Example: {"DstFoundation": {"src": "Stock", "foundation": 0}}. On load, an AnyInstruction untagged serde enum transparently upgrades v3 instructions to v4 and the file is written back in v4 format. The SavedInstruction bridge types are retained in solitaire_core::klondike_adapter for this migration path and for backward-compatible solitaire_data::ReplayMove / WASM replay formats.
Session history: StateSnapshot<G> stores the pre-move game state and instruction. On load, the session is reconstructed by replaying the instruction history against a fresh deal — no full state snapshot needed.
In our wrapper: GameState::Serialize emits schema v4 (upstream instruction types). GameState::Deserialize accepts v3 (auto-migrates) and v4 (direct). Schema version field lives on our wrapper.
6. Typed Move Errors
solitaire_core::error::MoveError returns structured errors the engine uses to trigger UI feedback (wrong-destination toast, stock-empty chime, etc.):
GameAlreadyWon
UndoStackEmpty
StockEmpty
InvalidSource
InvalidDestination
RuleViolation(String)
KlondikeInstruction is always constructed by game code from valid entity layout, so invalid moves are only detectable at solitaire_core's construction boundary — the error lives there, not inside klondike.
In our wrapper: MoveError variants are generated when solitaire_core fails to construct a KlondikeInstruction from the player's requested move. No translation of is_instruction_valid's bool return is required; by the time an instruction reaches klondike, it is already known to be structurally valid.
7. Waste Pile as Separate Concept
Ferrous tracks PileType::Waste as a distinct pile. klondike folds waste into Stock (the face-up half of the stock Pile). The engine's UI and scoring logic reference the waste pile directly; the mapping needs to be explicit.
In our wrapper: Project the face-up half of klondike's stock Pile as PileType::Waste when building pile snapshots for the engine.
8. Undo Stack Approach (resolved — not an issue)
card_game v0.4.0 Session uses snapshot-based undo: SessionState stores Vec<StateSnapshot<G>> where each entry holds the pre-move game state and the instruction. Undo pops the last snapshot and restores state directly — O(1), matching our existing GameState.undo_stack.
Resolution: GameState uses Session's built-in snapshot history. Ferrous
keeps parallel score/recycle metadata so undo can restore product-specific score
state that upstream snapshots do not own.
Integration Path (All work in solitaire_core)
Steps in dependency order. Upstream issues #10, #11, and the solver are all merged.
- ✅ Add
klondike = "0.3.0"/card_game = "0.4.0"as dependencies ofsolitaire_core;KlondikeAdapterwrapsKlondikeConfigand exposes scoring helpers. - ✅ Map pile types — project
klondike's stock face-up half as the engine's waste pile and expose renderer-facing pile snapshots. - ✅ Configure
KlondikeConfig— setmove_from_foundation: MoveFromFoundationConfig::Allowedby default; wire the user's settings toggle toDisallowedwhen foundation returns are disabled (gap 4, upstream). - ✅ Port scoring — pass WXP deltas into
ScoringConfig;SessionConfig::undo_penaltyhandles undo; implement recycle-with-free-allowance, score floor, and time bonus in the adapter (gap 1). - ✅ Port
GameMode— intercept undo + scoring in the adapter based on mode (gap 2). - ✅ Replace solver — call
session.solve()with budgets fromSolverConfig; mapOk(Some)→ Winnable,Ok(None)→ Unwinnable,Err→ Inconclusive (gap 3, upstream). - ✅ Implement
serde— serialise schema v4 with upstreamKlondikeInstruction; auto-migrate schema v3 viaSavedInstructioncompatibility types.
Quaternions Upgrade Runbook
Use this sequence whenever upgrading klondike / card_game from the
Quaternions registry:
- Review upstream changes/releases:
- Run:
scripts/update_quaternions_deps.sh <klondike_version> <card_game_version> - If the script passes, inspect the resulting
Cargo.lockdiff and land the upgrade with the normal PR flow.
The script enforces:
- lockfile update to requested versions
cargo test --workspacecargo clippy --workspace -- -D warnings- deterministic replay/debug-API smoke tests in
solitaire_wasm
What Does NOT Need to Change
- The
solitaire_engineBevy layer — it works againstsolitaire_coretypes; changes are isolated tosolitaire_core. - The
solitaire_syncmerge logic — operates on aSyncPayloadDTO, independent of core card types. - The
solitaire_server— speaks onlySyncPayloadJSON, unaffected.
References
- Quaternions' repo: https://git.aleshym.co/Quaternions/card_game
card_game v0.4.0release commit:fa098f0dklondike v0.3.0release commit:f4c4e350- Upstream scoring + config PRs: #12 (closes #11), #13 (closes #10)
- Upstream solver PR: #14
solitaire_coresource:solitaire_core/src/- Scoring implementation:
solitaire_core/src/game_state.rs,solitaire_core/src/klondike_adapter.rs - Architecture overview:
ARCHITECTURE.md