docs: update integration doc to reflect klondike v0.2.0 / card_game v0.3.0

Both upstream issues are now merged:
- PR #13 (closes #10): ScoringConfig with 5 configurable deltas lands
  in KlondikeConfig; KlondikeStats gains flip_up_bonus_count and
  move_from_foundation_count; score() takes &ScoringConfig
- PR #12 (closes #11): MoveFromFoundationConfig (Allowed/Disallowed)
  lands in KlondikeConfig; is_instruction_valid enforces it

Doc changes:
- "Already has" table updated with ScoringConfig, MoveFromFoundationConfig,
  richer KlondikeStats counters, and version numbers (v0.3.0 / v0.2.0)
- Gap 1 scoring table gains a "Handled by" column showing which deltas
  upstream now owns vs. which remain in our adapter (undo penalty,
  recycle-with-free-allowance, score floor, time bonus)
- Gap 1 adds note that ScoringConfig::recycle is a flat delta and cannot
  express the "N free recycles then penalty" WXP rule
- Gap 4 marked as upstream merged; notes that upstream default is
  MoveFromFoundationConfig::Allowed — we must explicitly set Disallowed
- Integration path: steps renumbered (8→7), step 3 now configures
  MoveFromFoundationConfig, step 4 splits upstream-handled vs.
  adapter-owned scoring; dependency versions pinned
- References updated with PR links and release commit hashes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-29 14:05:35 -07:00
parent 38e4c0341e
commit bb92bb333b
+156
View File
@@ -0,0 +1,156 @@
# 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. Gaps 1 and 4 were addressed upstream and are now merged as `card_game v0.3.0` / `klondike v0.2.0`. Integration is ready to begin.
---
## What `card_game` + `klondike` Already Has
### `card_game` crate (generic primitives) — v0.3.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`; 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) — v0.2.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 | **Our adapter** (klondike has no undo event) |
| 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>
**Recycle penalty note:** `ScoringConfig::recycle` is a flat delta (default 0 = free every time). WXP scoring allows a fixed number of free recycles (1 for Draw-1, 3 for Draw-3) and only charges the penalty afterwards. Our adapter must track `recycle_count` from `KlondikeStats` and apply the penalty only beyond the free allowance — it cannot delegate this to `ScoringConfig` directly.
**In our wrapper:** Configure `ScoringConfig` with the WXP deltas for the events upstream tracks. Additionally implement undo penalty (15), recycle-with-free-allowance logic, score floor (`score.max(0)`), 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
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 *(upstream merged — v0.2.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 must explicitly set `Disallowed` in the default `KlondikeConfig` and switch 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.
**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)
```
`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)*
`Session` undoes by replaying all moves from the seed — O(n). Benchmark shows 1 000 000 moves/second throughput; 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.
---
## Integration Path (All work in `solitaire_core`)
Steps in dependency order. Upstream issues #10 and #11 are closed and merged; no further upstream work is pending.
1. **Add `klondike = "0.2.0"` 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. **Configure `KlondikeConfig`** — set `move_from_foundation: MoveFromFoundationConfig::Disallowed` by default; wire the user's house-rule toggle to `Allowed` (gap 4, now upstream).
4. **Port scoring** — pass WXP deltas into `ScoringConfig` for the four events upstream handles; implement undo penalty, 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. **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>
- `card_game v0.3.0` release commit: `31e7cdbc`
- `klondike v0.2.0` release commit: `50cf2f37`
- Upstream scoring PR: <https://git.aleshym.co/Quaternions/card_game/pulls/13> (closes issue #10)
- Upstream foundation config PR: <https://git.aleshym.co/Quaternions/card_game/pulls/12> (closes issue #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`