docs: update for card_game v0.4.0 / klondike v0.3.0 — undo scoring + solver

Correct Gap 1 undo penalty: SessionState::score() already includes
undos × undo_penalty via SessionStats — undo IS tracked upstream,
just in SessionStats not KlondikeStats. Mark as  upstream.

Add Gap 3 upstream-merged note: Session::solve() in card_game v0.4.0
is a budget-bounded DFS that replaces our 767-line solver. Document
SolveError mapping (both variants → Inconclusive).

Update 'Already has' table for v0.4.0: Session now derives Clone,
uses snapshot-based O(1) undo (StateSnapshot stores pre-move state +
instruction), and carries SessionConfig with solve budgets.

Mark Gap 8 (undo O(1)) resolved: card_game v0.4.0 uses snapshots,
same approach as our existing undo_stack.

Update integration path: steps 1/3/4/5 marked ; steps 2/6/7 remain.
Update references with new release commits and solver PR #14.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-29 15:08:32 -07:00
parent a550a0cdf9
commit aebb401c44
+41 -30
View File
@@ -2,13 +2,13 @@
**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. **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. **Approach:** Most gaps are closed in Ferrous Solitaire's own `solitaire_core` crate via a wrapper/adapter layer. Gaps 1, 3, and 4 have been addressed upstream. Integration is ready to begin.
--- ---
## What `card_game` + `klondike` Already Has ## What `card_game` + `klondike` Already Has
### `card_game` crate (generic primitives) — v0.3.0 ### `card_game` crate (generic primitives) — v0.4.0
| Feature | Notes | | Feature | Notes |
|---|---| |---|---|
| `Card` (Deck + Suit + Rank packed in 1 byte) | `NonZeroU8` layout — no heap allocation | | `Card` (Deck + Suit + Rank packed in 1 byte) | `NonZeroU8` layout — no heap allocation |
@@ -16,11 +16,13 @@
| `Stack<CAP>` | Const-generic `ArrayVec` wrapper | | `Stack<CAP>` | Const-generic `ArrayVec` wrapper |
| `Pile<DN, UP>` | Face-down + face-up stacks; `flip_up`, `pop_flip_up` | | `Pile<DN, UP>` | Face-down + face-up stacks; `flip_up`, `pop_flip_up` |
| `Game` trait | `possible_instructions`, `is_instruction_valid`, `process_instruction`, `is_win` | | `Game` trait | `possible_instructions`, `is_instruction_valid`, `process_instruction`, `is_win` |
| `Session` | Wraps a `Game`; tracks history, replays for undo | | `Session` | Wraps a `Game`; snapshot-based undo (O(1)), score including undo penalty |
| `SessionInstruction::Undo` | Undo via full-state replay from seed | | `Session::solve()` | Built-in DFS solver with move/state budgets; returns `Solution<G>` or `SolveError` |
| `no_std`-compatible design | Const generics on stack sizes | | `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.2.0 ### `klondike` crate (Klondike rules) — v0.3.0
| Feature | Notes | | Feature | Notes |
|---|---| |---|---|
| 7 tableau + 4 foundation + 1 stock | Fully dealt from a seeded RNG | | 7 tableau + 4 foundation + 1 stock | Fully dealt from a seeded RNG |
@@ -51,7 +53,7 @@ Ferrous uses **Windows XP Standard** scoring. The exact table already implemente
| Waste → tableau | +5 | `KlondikeStats` / `ScoringConfig::move_to_tableau` ✅ | | Waste → tableau | +5 | `KlondikeStats` / `ScoringConfig::move_to_tableau` ✅ |
| Flip face-down tableau card | +5 | `KlondikeStats` / `ScoringConfig::flip_up_bonus` ✅ | | Flip face-down tableau card | +5 | `KlondikeStats` / `ScoringConfig::flip_up_bonus` ✅ |
| Foundation → tableau | 15 | `KlondikeStats` / `ScoringConfig::move_from_foundation` ✅ | | Foundation → tableau | 15 | `KlondikeStats` / `ScoringConfig::move_from_foundation` ✅ |
| Undo | 15 | **Our adapter** (klondike has no undo event) | | Undo | 15 | `SessionStats` / `SessionConfig::undo_penalty` |
| Recycle (Draw-1, after 1st free) | 100 | **Our adapter** — see below | | Recycle (Draw-1, after 1st free) | 100 | **Our adapter** — see below |
| Recycle (Draw-3, after 3rd free) | 20 | **Our adapter** — see below | | Recycle (Draw-3, after 3rd free) | 20 | **Our adapter** — see below |
| Score floor | `score.max(0)` always | **Our adapter** | | Score floor | `score.max(0)` always | **Our adapter** |
@@ -59,9 +61,11 @@ Ferrous uses **Windows XP Standard** scoring. The exact table already implemente
Reference: <https://www.solitaireparadise.com/games_list/klondike_solitaire_scoring.html> 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. **Undo penalty:** `SessionState::score()` = `KlondikeStats.score(&scoring) + undos × undo_penalty`. The 15 undo penalty is built into `SessionConfig` (default). Once `GameState` fully delegates to `Session`, our `KlondikeAdapter::score_for_undo()` helper becomes redundant.
**In our wrapper:** Configure `ScoringConfig` with the WXP deltas for the four events upstream tracks. Additionally implement undo penalty (15), recycle-with-free-allowance logic, score floor (`score.max(0)`), and time bonus in the adapter. **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:** Configure `ScoringConfig` with the WXP deltas for the five events upstream handles (including undo via `SessionConfig`). Implement recycle-with-free-allowance, score floor, and time bonus in the adapter.
### 2. Game Modes ### 2. Game Modes
Ferrous has three modes that alter scoring and undo behaviour: Ferrous has three modes that alter scoring and undo behaviour:
@@ -76,22 +80,30 @@ Zen is intended for relaxed play where the score does not matter. Challenge is a
**In our wrapper:** Add `GameMode` to `solitaire_core::GameState`; intercept undo calls and scoring deltas in the adapter before delegating to `KlondikeState`. **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 ### 3. Solvability Solver *(upstream merged — card_game v0.4.0)*
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. `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`
**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. `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).
### 4. `take_from_foundation` House Rule *(upstream merged — v0.2.0)* Our 767-line `solitaire_core::solver` reimplements the full game rules to run the DFS; `session.solve()` replaces it entirely. The solver will be removed once the `Session<Klondike>` is wired into `GameState`.
**In our wrapper:** Replace `solitaire_core::solver` with `session.solve()`. Map `Ok(Some(_))` → Winnable, `Ok(None)` → Unwinnable, `Err(_)` → 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. `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. **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 explicitly sets `Disallowed` in the default `KlondikeConfig` and switches 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. **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 ### 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. `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. **Session history:** `StateSnapshot<G>` stores the pre-move game state and instruction. On load, the session is reconstructed from the serialised snapshot history — no full replay from seed needed.
**In our wrapper:** Serialise the `solitaire_core` wrapper struct using newtypes. Define `SavedInstruction` (a `Serialize + Deserialize` mirror of `KlondikeInstruction`) and `SavedStateSnapshot`. Reconstruct `SessionState` from the deserialised history. Schema version field lives on our wrapper.
### 6. Typed Move Errors ### 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.): `solitaire_core::error::MoveError` returns structured errors the engine uses to trigger UI feedback (wrong-destination toast, stock-empty chime, etc.):
@@ -115,23 +127,23 @@ Ferrous tracks `PileType::Waste` as a distinct pile. `klondike` folds waste into
**In our wrapper:** Project the face-up half of `klondike`'s stock `Pile` as `PileType::Waste` when building pile snapshots for the engine. **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)* ### 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. `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:** Use `Session::undo` as-is. The snapshot ring-buffer plan is dropped. **Resolution:** Use `Session`'s built-in snapshot history. Our `GameState.undo_stack: VecDeque<StateSnapshot>` will be removed once `GameState` is fully migrated to delegate to `Session`.
--- ---
## Integration Path (All work in `solitaire_core`) ## 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. Steps in dependency order. Upstream issues #10, #11, and the solver are all merged.
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. 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 `PileType::Waste`; expose the same `HashMap<PileType, Pile>` the engine already reads (gap 7). 2. **Map pile types** — project `klondike`'s stock face-up half as `PileType::Waste`; expose the same `HashMap<PileType, Pile>` the engine already reads. Wire `Session<Klondike>` into `KlondikeAdapter` (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). 3. **Configure `KlondikeConfig`** — set `move_from_foundation: MoveFromFoundationConfig::Disallowed` by default; wire the user's house-rule toggle to `Allowed` (gap 4, 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). 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). 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). 6. **Replace solver** — call `session.solve()` with budgets from our `SolverConfig`; map `Ok(Some)` → Winnable, `Ok(None)` → Unwinnable, `Err` → Inconclusive (gap 3, upstream).
7. **Implement `serde`** on the adapter wrapper using newtypes; migrate save-file schema (gap 5). 7. **Implement `serde`** — define `SavedInstruction` + `SavedStateSnapshot` newtypes; serialise session history; migrate save-file schema (gap 5).
--- ---
@@ -146,11 +158,10 @@ Steps in dependency order. Upstream issues #10 and #11 are closed and merged; no
## References ## References
- Quaternions' repo: <https://git.aleshym.co/Quaternions/card_game> - Quaternions' repo: <https://git.aleshym.co/Quaternions/card_game>
- `card_game v0.3.0` release commit: `31e7cdbc` - `card_game v0.4.0` release commit: `fa098f0d`
- `klondike v0.2.0` release commit: `50cf2f37` - `klondike v0.3.0` release commit: `f4c4e350`
- Upstream scoring PR: <https://git.aleshym.co/Quaternions/card_game/pulls/13> (closes issue #10) - Upstream scoring + config PRs: #12 (closes #11), #13 (closes #10)
- Upstream foundation config PR: <https://git.aleshym.co/Quaternions/card_game/pulls/12> (closes issue #11) - Upstream solver PR: #14
- `solitaire_core` source: `solitaire_core/src/` - `solitaire_core` source: `solitaire_core/src/`
- Scoring spec: `solitaire_core/src/scoring.rs` - Scoring spec: `solitaire_core/src/scoring.rs`
- Solver spec: `solitaire_core/src/solver.rs`
- Architecture overview: `ARCHITECTURE.md` - Architecture overview: `ARCHITECTURE.md`