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>
7.8 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: 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:
- Add
klondikeas a dependency ofsolitaire_core; stub out aKlondikeAdapterstruct that wrapsKlondikeStateand passes all existing tests. - Map pile types — project
klondike's stock face-up half asPileType::Waste; expose the sameHashMap<PileType, Pile>the engine already reads (gap 7). - Wrap move errors — translate
klondike'sboolreturns toResult<(), MoveError>(gap 6). - Port scoring — implement flip-bonus, undo penalty, recycle penalty, time bonus, and score floor in the adapter (gap 1).
- Port
GameMode— intercept undo + scoring in the adapter based on mode (gap 2). - Port
take_from_foundation— validate and apply in the adapter behind a config flag (gap 4). - Port the DFS solver to run against
KlondikeAdapter(gap 3). - Implement
serdeon the adapter wrapper; migrate save-file schema (gap 5).
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
- Friend's repo: https://git.aleshym.co/Quaternions/card_game
solitaire_coresource:solitaire_core/src/- Scoring spec:
solitaire_core/src/scoring.rs - Solver spec:
solitaire_core/src/solver.rs - Architecture overview:
ARCHITECTURE.md