212 lines
14 KiB
Markdown
212 lines
14 KiB
Markdown
# 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:** Integration is complete. Upstream `card_game` / `klondike` now owns
|
||
authoritative Klondike rules, session history, undo snapshots, and solving.
|
||
Ferrous keeps product-specific scoring, persistence, rendering DTOs, game modes,
|
||
and typed UI errors in `solitaire_core`.
|
||
|
||
---
|
||
|
||
## What `card_game` + `klondike` Already Has
|
||
|
||
### `card_game` crate (generic primitives) — v0.4.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`; snapshot-based undo (O(1)), score including undo penalty |
|
||
| `Session::solve()` | Built-in DFS solver with move/state budgets; returns `Solution<G>` or `SolveError` |
|
||
| `StateSnapshot<G>` | Pre-move state + instruction; used by snapshot history and `Solution` |
|
||
| `SessionState::score()` | = `game_score + undos × undo_penalty` (−15 by default via `SessionConfig`) |
|
||
| `SessionConfig` | `undo_penalty`, `solve_moves_budget`, `solve_states_budget` |
|
||
|
||
### `klondike` crate (Klondike rules) — v0.3.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` Still Owns
|
||
|
||
### 1. Scoring — remaining adapter responsibilities
|
||
Ferrous uses **Windows XP Standard** scoring. The upstream library handles the
|
||
per-move counters and configurable deltas; Ferrous adds the product-specific
|
||
parts in `GameState` / `KlondikeAdapter`.
|
||
|
||
| 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 | `SessionStats` / `SessionConfig::undo_penalty` ✅ |
|
||
| 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>
|
||
|
||
**Undo penalty:** `SessionState::score()` = `KlondikeStats.score(&scoring) + undos × undo_penalty`. Ferrous still owns the exact user-visible score because it must restore the pre-move score when undoing recycle penalties and then apply the product's undo penalty.
|
||
|
||
**Recycle penalty note:** `ScoringConfig::recycle` is a flat delta (default 0 = always free). WXP allows a fixed number of free recycles before charging a penalty, which the upstream library cannot express with a single delta. Our adapter tracks `recycle_count` from `KlondikeStats` and applies the penalty only beyond the free allowance.
|
||
|
||
**In our wrapper:** `KlondikeAdapter::config_for` configures the upstream rules
|
||
and scoring deltas. `GameState` applies recycle-with-free-allowance, score floor,
|
||
time bonus, game-mode suppression, and undo score restoration.
|
||
|
||
### 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:** `GameMode` lives on `solitaire_core::GameState`; undo and
|
||
scoring behavior are applied before/after delegating legal moves to the upstream
|
||
session.
|
||
|
||
### 3. Solvability Solver *(upstream merged — card_game v0.4.0)*
|
||
`card_game v0.4.0` ships `Session::solve()` — a budget-bounded DFS that returns `Result<Option<Solution<G>>, SolveError>`. `SolveError` has two variants:
|
||
- `MovesBudgetExceeded` — equivalent to our `SolverResult::Inconclusive`
|
||
- `StatesBudgetExceeded` — equivalent to our `SolverResult::Inconclusive`
|
||
|
||
`Solution<G>` contains the winning move sequence as `Vec<StateSnapshot<G>>`; `clean_solution()` removes cycles. `Session::solve()` uses `SessionConfig::solve_moves_budget` and `SessionConfig::solve_states_budget` (defaults: 100 000 each).
|
||
|
||
The old local DFS has been replaced. `solitaire_core::solver` is now a small
|
||
adapter around `Session::solve()` that preserves the engine-facing
|
||
`SolverResult`, `SolverConfig`, and first-move payload contract.
|
||
|
||
**In our wrapper:** `solve_game_state` calls `session.solve()` with the requested
|
||
budgets. It maps `Ok(Some(_))` → Winnable, `Ok(None)` → Unwinnable, and budget
|
||
errors → Inconclusive.
|
||
|
||
### 4. `take_from_foundation` House Rule *(upstream merged — v0.3.0)*
|
||
`MoveFromFoundationConfig` is now part of `KlondikeConfig`. When set to `Disallowed`, `is_instruction_valid` blocks foundation → tableau instructions.
|
||
|
||
**Default behaviour:** The upstream default is `MoveFromFoundationConfig::Allowed`. Ferrous Solitaire **also defaults to Allowed** (`take_from_foundation: true` in `GameState`, `Settings`). This matches the upstream default and provides the most beginner-friendly experience. The player can disable foundation returns via a settings toggle (`take_from_foundation = false`), which maps to `Disallowed`.
|
||
|
||
**In our wrapper:** `KlondikeAdapter::config_for(draw_mode, take_from_foundation)` constructs `KlondikeConfig { move_from_foundation: if take_from_foundation { Allowed } else { Disallowed }, .. }`. 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.
|
||
|
||
**Upstream serde status (rev 99b49e62):** At this revision, `klondike` and `card_game` both enable a `serde` feature. All nine instruction/pile types (`KlondikeInstruction`, `KlondikePile`, `KlondikePileStack`, `DstFoundation`, `DstTableau`, `TableauStack`, `Foundation`, `Tableau`, `SkipCards`) derive `serde::Serialize` + `serde::Deserialize` under that feature. The workspace `Cargo.toml` enables `features = ["serde"]`.
|
||
|
||
**Schema v4 (current):** `saved_moves` serialises as `Vec<KlondikeInstruction>` using upstream named-variant serde. Example: `{"DstFoundation": {"src": "Stock", "foundation": "Foundation1"}}`.
|
||
|
||
**Schema v3 (legacy, auto-migrated):** `saved_moves` used local `SavedInstruction` mirror types with u8 indices. Example: `{"DstFoundation": {"src": "Stock", "foundation": 0}}`. On load, an `AnyInstruction` untagged serde enum transparently upgrades v3 instructions to v4 and the file is written back in v4 format. The `SavedInstruction` bridge types are retained in `solitaire_core::klondike_adapter` for this migration path and for backward-compatible `solitaire_data::ReplayMove` / WASM replay formats.
|
||
|
||
**Session history:** `StateSnapshot<G>` stores the pre-move game state and instruction. On load, the session is reconstructed by replaying the instruction history against a fresh deal — no full state snapshot needed.
|
||
|
||
**In our wrapper:** `GameState::Serialize` emits schema v4 (upstream instruction types). `GameState::Deserialize` accepts v3 (auto-migrates) and v4 (direct). 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)*
|
||
`card_game v0.4.0` `Session` uses snapshot-based undo: `SessionState` stores `Vec<StateSnapshot<G>>` where each entry holds the pre-move game state and the instruction. Undo pops the last snapshot and restores state directly — O(1), matching our existing `GameState.undo_stack`.
|
||
|
||
**Resolution:** `GameState` uses `Session`'s built-in snapshot history. Ferrous
|
||
keeps parallel score/recycle metadata so undo can restore product-specific score
|
||
state that upstream snapshots do not own.
|
||
|
||
---
|
||
|
||
## Integration Path (All work in `solitaire_core`)
|
||
|
||
Steps in dependency order. Upstream issues #10, #11, and the solver are all merged.
|
||
|
||
1. ✅ **Add `klondike = "0.3.0"` / `card_game = "0.4.0"` as dependencies** of `solitaire_core`; `KlondikeAdapter` wraps `KlondikeConfig` and exposes scoring helpers.
|
||
2. ✅ **Map pile types** — project `klondike`'s stock face-up half as the engine's waste pile and expose renderer-facing pile snapshots.
|
||
3. ✅ **Configure `KlondikeConfig`** — set `move_from_foundation: MoveFromFoundationConfig::Allowed` by default; wire the user's settings toggle to `Disallowed` when foundation returns are disabled (gap 4, upstream).
|
||
4. ✅ **Port scoring** — pass WXP deltas into `ScoringConfig`; `SessionConfig::undo_penalty` handles undo; implement 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. ✅ **Replace solver** — call `session.solve()` with budgets from `SolverConfig`; map `Ok(Some)` → Winnable, `Ok(None)` → Unwinnable, `Err` → Inconclusive (gap 3, upstream).
|
||
7. ✅ **Implement `serde`** — serialise schema v4 with upstream `KlondikeInstruction`; auto-migrate schema v3 via `SavedInstruction` compatibility types.
|
||
|
||
---
|
||
|
||
## Quaternions Upgrade Runbook
|
||
|
||
Use this sequence whenever upgrading `klondike` / `card_game` from the
|
||
Quaternions registry:
|
||
|
||
1. Review upstream changes/releases:
|
||
- <https://git.aleshym.co/Quaternions/card_game>
|
||
- <https://git.aleshym.co/Quaternions/klondike>
|
||
2. Run:
|
||
```bash
|
||
scripts/update_quaternions_deps.sh <klondike_version> <card_game_version>
|
||
```
|
||
3. If the script passes, inspect the resulting `Cargo.lock` diff and land the
|
||
upgrade with the normal PR flow.
|
||
|
||
The script enforces:
|
||
- lockfile update to requested versions
|
||
- `cargo test --workspace`
|
||
- `cargo clippy --workspace -- -D warnings`
|
||
- deterministic replay/debug-API smoke tests in `solitaire_wasm`
|
||
|
||
---
|
||
|
||
## 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.4.0` release commit: `fa098f0d`
|
||
- `klondike v0.3.0` release commit: `f4c4e350`
|
||
- Upstream scoring + config PRs: #12 (closes #11), #13 (closes #10)
|
||
- Upstream solver PR: #14
|
||
- `solitaire_core` source: `solitaire_core/src/`
|
||
- Scoring implementation: `solitaire_core/src/game_state.rs`, `solitaire_core/src/klondike_adapter.rs`
|
||
- Architecture overview: `ARCHITECTURE.md`
|