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

143 lines
7.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Integrating `card_game` / `klondike` as the Solitaire Core
**Context:** A collaborator ([Quaternions](https://git.aleshym.co/Quaternions/card_game)) 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`