Files
Ferrous-Solitaire/docs/card-game-integration.md
T
funman300 389fdd1fb0 docs: add card-game integration guide (closes #76)
Full gap analysis between Quaternions/card_game and solitaire_core,
integration steps 1-7 (all now complete), and references.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-29 18:04:41 -07:00

12 KiB
Raw Blame History

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: Most gaps are closed in Ferrous Solitaire's own solitaire_core crate via a wrapper/adapter layer. Gaps 1, 3, and 4 have been addressed upstream. Integration is ready to begin.


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 Needs (Gaps)

1. Scoring — remaining adapter responsibilities

Ferrous uses Windows XP Standard scoring. The exact table already implemented in solitaire_core/src/scoring.rs:

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. The 15 undo penalty is built into SessionConfig (default). Once GameState fully delegates to Session, our KlondikeAdapter::score_for_undo() helper becomes redundant.

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: Configure ScoringConfig with the WXP deltas for the five events upstream handles (including undo via SessionConfig). Implement recycle-with-free-allowance, score floor, and time bonus in the adapter.

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: Add GameMode to solitaire_core::GameState; intercept undo calls and scoring deltas in the adapter before delegating to KlondikeState.

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 our SolverResult::Inconclusive
  • StatesBudgetExceeded — equivalent to our SolverResult::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).

Our 767-line solitaire_core::solver reimplements the full game rules to run the DFS; session.solve() replaces it entirely. The solver will be removed once the Session<Klondike> is wired into GameState.

In our wrapper: Replace solitaire_core::solver with session.solve(). Map Ok(Some(_)) → Winnable, Ok(None) → Unwinnable, Err(_) → 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.

Important: The upstream default is MoveFromFoundationConfig::Allowed. Ferrous Solitaire uses the standard rule (foundation cards cannot be moved back) as the default, with the house rule as an opt-in. Our adapter explicitly sets Disallowed in the default KlondikeConfig and switches to Allowed only when the user toggles the house-rule option.

In our wrapper: Construct KlondikeConfig { move_from_foundation: MoveFromFoundationConfig::Disallowed, .. } by default; mirror the user's settings toggle to Allowed. 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. KlondikeState derives Clone + Eq + Hash but not Serialize / Deserialize. No upstream changes are needed — this is handled externally.

Session history: StateSnapshot<G> stores the pre-move game state and instruction. On load, the session is reconstructed from the serialised snapshot history — no full replay from seed needed.

In our wrapper: Serialise the solitaire_core wrapper struct using newtypes. Define SavedInstruction (a Serialize + Deserialize mirror of KlondikeInstruction) and SavedStateSnapshot. Reconstruct SessionState from the deserialised history. 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: Use Session's built-in snapshot history. Our GameState.undo_stack: VecDeque<StateSnapshot> will be removed once GameState is fully migrated to delegate to Session.


Integration Path (All work in solitaire_core)

Steps in dependency order. Upstream issues #10, #11, and the solver are all merged.

  1. Add klondike = "0.3.0" / card_game = "0.4.0" as dependencies of solitaire_core; KlondikeAdapter wraps KlondikeConfig and exposes scoring helpers.
  2. Map pile types — project klondike's stock face-up half as PileType::Waste; expose the same HashMap<PileType, Pile> the engine already reads. Wire Session<Klondike> into KlondikeAdapter (gap 7).
  3. Configure KlondikeConfig — set move_from_foundation: MoveFromFoundationConfig::Disallowed by default; wire the user's house-rule toggle to Allowed (gap 4, upstream).
  4. Port scoring — pass WXP deltas into ScoringConfig; SessionConfig::undo_penalty handles undo; implement recycle-with-free-allowance, score floor, and time bonus in the adapter (gap 1).
  5. Port GameMode — intercept undo + scoring in the adapter based on mode (gap 2).
  6. Replace solver — call session.solve() with budgets from our SolverConfig; map Ok(Some) → Winnable, Ok(None) → Unwinnable, Err → Inconclusive (gap 3, upstream).
  7. Implement serde — define SavedInstruction + SavedStateSnapshot newtypes; serialise session history; migrate save-file schema (gap 5).

What Does NOT Need to Change

  • The solitaire_engine Bevy layer — it works against solitaire_core types; changes are isolated to solitaire_core.
  • The solitaire_sync merge logic — operates on a SyncPayload DTO, independent of core card types.
  • The solitaire_server — speaks only SyncPayload JSON, unaffected.

References

  • Quaternions' repo: https://git.aleshym.co/Quaternions/card_game
  • card_game v0.4.0 release commit: fa098f0d
  • klondike v0.3.0 release commit: f4c4e350
  • Upstream scoring + config PRs: #12 (closes #11), #13 (closes #10)
  • Upstream solver PR: #14
  • solitaire_core source: solitaire_core/src/
  • Scoring spec: solitaire_core/src/scoring.rs
  • Architecture overview: ARCHITECTURE.md