Files
Ferrous-Solitaire/docs/card-game-integration.md
T
funman300 c21c0ebf99 docs: revise integration plan — all gaps closed in Ferrous Solitaire wrapper
Reframe the integration approach: klondike is a read-only dependency;
all 8 gaps (scoring, game modes, solver, take_from_foundation, serde,
MoveError, waste pile, undo stack) are closed in solitaire_core via a
KlondikeAdapter wrapper layer. No upstream changes to card_game or
klondike are required.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 16:23:05 -07:00

7.8 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: klondike is treated as a read-only dependency. All gaps are closed in Ferrous Solitaire's own solitaire_core crate via a wrapper/adapter layer. No upstream changes to card_game or klondike are required — Quaternions can evolve their library independently.


What card_game + klondike Already Has

card_game crate (generic primitives)

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; tracks history, replays for undo
SessionInstruction::Undo Undo via full-state replay from seed
no_std-compatible design Const generics on stack sizes

klondike crate (Klondike rules)

Feature Notes
7 tableau + 4 foundation + 1 stock Fully dealt from a seeded RNG
Draw-1 / Draw-3 config KlondikeConfig::draw_stock
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)
KlondikeStats (score, recycle_count, moves) Basic tracking
is_win_trivial (all face-down cards cleared) Auto-complete trigger
get_auto_move / get_sorted_moves Priority-ranked move suggestion
Benchmark suite (klondike-bench) 1 000-game throughput test
CLI display (klondike-cli) Terminal renderer

What Ferrous Solitaire's solitaire_core Needs (Gaps)

The items below are either missing from klondike today or behave differently from what the engine expects.

1. Scoring — missing sub-rules

Ferrous uses Windows XP Standard scoring. KlondikeStats tracks a score and a recycle count, but the following deltas are not yet applied:

Rule klondike solitaire_core
Card → foundation +10 +10
Waste → tableau +5 +5
Flip face-down card +5
Foundation → tableau 15
Undo 15
Recycle penalty (Draw-1) 100 after 1st free recycle
Recycle penalty (Draw-3) 20 after 3rd free recycle
Time bonus on win 700 000 / elapsed_seconds
Score floor of 0 score.max(0) enforced

In our wrapper: Implement all missing scoring deltas on top of KlondikeState in solitaire_core. Track flip bonus, undo penalty, and recycle penalties in our own adapter rather than inside KlondikeStats.

2. Game Modes

Ferrous has three modes that alter rules and scoring. None exist in klondike today:

Mode Behaviour
Classic Standard scoring, undo allowed
Challenge Undo disabled (returns an error)
Zen All scoring disabled; score is always 0

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

The engine has a Settings toggle — "Winnable deals only" — backed by a DFS solver with canonical-state memoisation (solitaire_core::solver). The solver classifies each deal as Winnable, Unwinnable, or Inconclusive (budget exceeded). klondike has no equivalent.

In our wrapper: Port the existing DFS solver to run against KlondikeState. The memoisation key must be a deterministic canonical hash of the full game state.

4. take_from_foundation House Rule

A configurable option allows moving the top foundation card back onto a compatible tableau column. klondike marks Foundation → Foundation as is_useless and does not expose foundation → tableau as a valid instruction.

In our wrapper: Intercept and validate foundation_to_tableau moves in solitaire_core before delegating to klondike; guard with a config flag.

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.

In our wrapper: Serialise the solitaire_core wrapper struct (which owns KlondikeState by value). Reconstruct KlondikeState from the seed + move history on load, or snapshot it as an opaque field. 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)

klondike::is_instruction_valid returns bool. In our wrapper: Call is_instruction_valid first; map false to the appropriate MoveError variant before returning to the engine.

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

solitaire_core keeps a snapshot stack (up to 64 snapshots) so undo is O(1) without replaying history. Session undoes by replaying all moves from the seed, which is O(n). For a game-in-progress with many moves this is fine for a solver but may be perceptible in the UI on slow hardware.

In our wrapper: Maintain our own snapshot ring buffer; bypass Session::undo and restore directly from the snapshot. Accept the replay cost only if profiling shows it is not perceptible.


Integration Path (All work in solitaire_core)

Steps in dependency order — no changes required in card_game or klondike:

  1. Add klondike as a dependency of solitaire_core; stub out a KlondikeAdapter struct that wraps KlondikeState and passes all existing tests.
  2. Map pile types — project klondike's stock face-up half as PileType::Waste; expose the same HashMap<PileType, Pile> the engine already reads (gap 7).
  3. Wrap move errors — translate klondike's bool returns to Result<(), MoveError> (gap 6).
  4. Port scoring — implement flip-bonus, undo penalty, recycle penalty, time bonus, and score floor in the adapter (gap 1).
  5. Port GameMode — intercept undo + scoring in the adapter based on mode (gap 2).
  6. Port take_from_foundation — validate and apply in the adapter behind a config flag (gap 4).
  7. Port the DFS solver to run against KlondikeAdapter (gap 3).
  8. Implement serde on the adapter wrapper; 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

  • Friend's repo: https://git.aleshym.co/Quaternions/card_game
  • solitaire_core source: solitaire_core/src/
  • Scoring spec: solitaire_core/src/scoring.rs
  • Solver spec: solitaire_core/src/solver.rs
  • Architecture overview: ARCHITECTURE.md