Files
Ferrous-Solitaire/docs/card-game-integration.md
T
funman300 8f5193035b docs: update card-game-integration with PR discussion outcomes
- Approach: note Quaternions is addressing gaps 1 and 4 upstream
  (card_game issues #10 and #11)
- Gap 1: replace comparison table with exact WXP scoring table from
  solitaire_core/src/scoring.rs; add solitaireparadise.com reference;
  note time bonus stays in adapter (not wasm-portable)
- Gap 2: expand mode table with full Scoring + Undo columns; add
  descriptions for Zen (relaxed, score = 0) and Challenge (timed
  daily puzzle, undo disabled)
- Gap 4: clarify the flag *enables* an optional move (off by default),
  not disables; link upstream issue #11
- Gap 5: note Quaternions confirmed newtypes approach, no upstream
  changes needed
- Gap 6: document that MoveError is generated at instruction-
  construction boundary in solitaire_core, not by wrapping
  is_instruction_valid's bool
- Gap 8: mark resolved; 0.02 ms worst case at 1M moves/s; drop
  snapshot ring-buffer plan
- Integration path: updated steps to reflect resolved gaps and
  upstream issue dependencies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 13:44:36 -07:00

156 lines
10 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:** Most gaps are closed in Ferrous Solitaire's own `solitaire_core` crate via a wrapper/adapter layer. Two gaps (scoring sub-rules, `take_from_foundation`) are being addressed upstream by Quaternions and will be pulled in once merged. Quaternions confirmed as of the PR discussion that the latest version of `card_game` + `klondike` already supports the scoring specifics and game-mode configurability needed — integration is ready to begin.
---
## 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. The exact table already implemented in `solitaire_core/src/scoring.rs`:
| Event | Delta |
|---|---|
| Any card → foundation | +10 |
| Waste → tableau | +5 |
| Flip face-down tableau card | +5 |
| Foundation → tableau | 15 |
| Undo | 15 |
| Recycle (Draw-1, after 1st free) | 100 |
| Recycle (Draw-3, after 3rd free) | 20 |
| Score floor | `score.max(0)` always |
| Time bonus on win | `700_000 / elapsed_seconds` |
Reference: <https://www.solitaireparadise.com/games_list/klondike_solitaire_scoring.html>
`KlondikeStats` already tracks score and recycle count; the flip bonus, undo penalty, recycle penalties, score floor, and time bonus are not yet applied upstream. Quaternions has opened [card_game issue #10](https://git.aleshym.co/Quaternions/card_game/issues/10) ✅ to address the missing scoring deltas.
**Time bonus exception:** The time bonus (`700_000 / elapsed_seconds`) depends on wall-clock time which is not wasm-portable. It stays tracked in `solitaire_core`'s adapter regardless of upstream changes.
**In our wrapper:** Implement the time bonus and score floor in the adapter. Once upstream issue #10 lands, the remaining scoring deltas will be configurable from `KlondikeStats` directly.
### 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`. Quaternions confirmed the latest `card_game` + `klondike` supports the configurability required for all three modes.
### 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 **enables** an optional move that is off by default: moving the top card of a foundation pile back down onto a compatible tableau column. This is a house-rule relaxation that makes stuck games more recoverable; the standard rule disallows it. `klondike` marks `Foundation → Foundation` as `is_useless` and does not expose foundation → tableau as a valid instruction.
Quaternions has opened [card_game issue #11](https://git.aleshym.co/Quaternions/card_game/issues/11) ✅ to add this as a config option.
**In our wrapper:** Guard the move with a config flag. Once upstream issue #11 lands, delegate to `klondike`'s config; until then, intercept and validate in `solitaire_core` before delegating.
### 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 here — Quaternions confirmed this is cleanly handled externally.
**In our wrapper:** Serialise the `solitaire_core` wrapper struct (which owns `KlondikeState` by value) using newtypes. 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`. However, because `KlondikeInstruction` is always constructed by game code from valid entity layout, the only real failure mode is that `solitaire_core` fails to **construct** the instruction in the first place — the error lives at our construction boundary, 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)*
`solitaire_core` previously kept a snapshot stack so undo was O(1). `Session` undoes by replaying all moves from the seed — O(n). After discussion, this is not an issue: the benchmark shows 1 000 000 moves/second throughput, and a maximum game length of ~192 moves gives a worst-case undo time of 0.02 ms — imperceptible on any hardware.
**Resolution:** Use `Session::undo` as-is. The snapshot ring-buffer plan is dropped. No wrapper work needed for this item.
---
## Integration Path (All work in `solitaire_core`)
Steps in dependency order. Upstream issues #10 and #11 can land at any time and will shrink the wrapper surface further.
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. **Port scoring** — implement time bonus and score floor in the adapter; wire up the remaining WXP deltas (flip bonus, undo/recycle penalties) either from upstream (post issue #10) or directly in the adapter (gap 1).
4. **Port `GameMode`** — intercept undo + scoring in the adapter based on mode (gap 2).
5. **Port `take_from_foundation`** — validate and apply in the adapter behind a config flag; delegate to `klondike`'s config once upstream issue #11 lands (gap 4).
6. **Port the DFS solver** to run against `KlondikeAdapter` (gap 3).
7. **Implement `serde`** on the adapter wrapper using newtypes; 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>
- Upstream issue — scoring: <https://git.aleshym.co/Quaternions/card_game/issues/10>
- Upstream issue — take_from_foundation: <https://git.aleshym.co/Quaternions/card_game/issues/11>
- `solitaire_core` source: `solitaire_core/src/`
- Scoring spec: `solitaire_core/src/scoring.rs`
- Solver spec: `solitaire_core/src/solver.rs`
- Architecture overview: `ARCHITECTURE.md`