From ccccdd2b40eebc09c3b8e0e74f4d91a804533f0e Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 28 May 2026 15:28:44 -0700 Subject: [PATCH 1/5] docs: add card-game integration gap analysis Documents what Quaternions/card_game already provides, what solitaire_core requires that is currently missing, and the suggested step-by-step integration path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/card-game-integration.md | 136 ++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 docs/card-game-integration.md diff --git a/docs/card-game-integration.md b/docs/card-game-integration.md new file mode 100644 index 0000000..9852269 --- /dev/null +++ b/docs/card-game-integration.md @@ -0,0 +1,136 @@ +# 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, and lists the concrete work needed before the two can be integrated. + +--- + +## 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` | Const-generic `ArrayVec` wrapper | +| `Pile` | 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 | + +### 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 | + +### 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. + +**Work needed:** Port or reimplement the DFS solver 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. + +**Work needed:** Add `foundation_to_tableau` as a valid `KlondikeInstruction` (guarded by 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`. + +**Work needed:** Derive (or implement) `serde::Serialize` / `Deserialize` on `KlondikeState`, `KlondikeStats`, `KlondikeConfig`, and `SessionState`. The schema needs a version field so save files can be migrated. + +### 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`. **Work needed:** Return `Result<(), MoveError>` (or an equivalent) so callers know *why* a move was rejected. + +### 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. + +### 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. + +**Options:** +- Accept the replay cost (simple, no changes needed) +- Add a snapshot-based fast-undo path to `Session` + +--- + +## Integration Path (Suggested Steps) + +Work items in rough dependency order: + +1. **Add `serde` feature** to `card_game` and `klondike` (gated, not mandatory) — unblocks persistence. +2. **Add flip-bonus + recycle-penalty scoring** to `KlondikeStats::process_instruction`. +3. **Add `GameMode`** config variant; gate undo and scoring accordingly. +4. **Add `MoveError` return type** to `process_instruction` / `is_instruction_valid`. +5. **Add `take_from_foundation` instruction** behind a config flag. +6. **Implement the DFS solvability solver** (can be a separate crate, e.g. `klondike-solver`). +7. **Add save/load round-trip tests** once serde is wired up. +8. **Adapter layer in `solitaire_core`** (or replace it) that wraps `klondike` types in the engine's `PileType`/`GameState` API so the Bevy engine layer (`solitaire_engine`) does not need to change. + +--- + +## 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: +- `solitaire_core` source: `solitaire_core/src/` +- Scoring spec: `solitaire_core/src/scoring.rs` +- Solver spec: `solitaire_core/src/solver.rs` +- Architecture overview: `ARCHITECTURE.md` -- 2.47.3 From c21c0ebf9917e6dbde806c4fcb7ab7546e36b60a Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 28 May 2026 16:23:05 -0700 Subject: [PATCH 2/5] =?UTF-8?q?docs:=20revise=20integration=20plan=20?= =?UTF-8?q?=E2=80=94=20all=20gaps=20closed=20in=20Ferrous=20Solitaire=20wr?= =?UTF-8?q?apper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- docs/card-game-integration.md | 42 ++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/docs/card-game-integration.md b/docs/card-game-integration.md index 9852269..e21b4b5 100644 --- a/docs/card-game-integration.md +++ b/docs/card-game-integration.md @@ -1,6 +1,8 @@ # 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, and lists the concrete work needed before the two can be integrated. +**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. --- @@ -54,6 +56,8 @@ Ferrous uses Windows XP Standard scoring. `KlondikeStats` tracks a score and a r | 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: @@ -63,20 +67,22 @@ Ferrous has three modes that alter rules and scoring. None exist in `klondike` t | `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. -**Work needed:** Port or reimplement the DFS solver against `KlondikeState`. The memoisation key must be a deterministic canonical hash of the full game state. +**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. -**Work needed:** Add `foundation_to_tableau` as a valid `KlondikeInstruction` (guarded by a config flag). +**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`. -**Work needed:** Derive (or implement) `serde::Serialize` / `Deserialize` on `KlondikeState`, `KlondikeStats`, `KlondikeConfig`, and `SessionState`. The schema needs a version field so save files can be migrated. +**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.): @@ -90,32 +96,32 @@ InvalidDestination RuleViolation(String) ``` -`klondike::is_instruction_valid` returns `bool`. **Work needed:** Return `Result<(), MoveError>` (or an equivalent) so callers know *why* a move was rejected. +`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. -**Options:** -- Accept the replay cost (simple, no changes needed) -- Add a snapshot-based fast-undo path to `Session` +**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 (Suggested Steps) +## Integration Path (All work in `solitaire_core`) -Work items in rough dependency order: +Steps in dependency order — no changes required in `card_game` or `klondike`: -1. **Add `serde` feature** to `card_game` and `klondike` (gated, not mandatory) — unblocks persistence. -2. **Add flip-bonus + recycle-penalty scoring** to `KlondikeStats::process_instruction`. -3. **Add `GameMode`** config variant; gate undo and scoring accordingly. -4. **Add `MoveError` return type** to `process_instruction` / `is_instruction_valid`. -5. **Add `take_from_foundation` instruction** behind a config flag. -6. **Implement the DFS solvability solver** (can be a separate crate, e.g. `klondike-solver`). -7. **Add save/load round-trip tests** once serde is wired up. -8. **Adapter layer in `solitaire_core`** (or replace it) that wraps `klondike` types in the engine's `PileType`/`GameState` API so the Bevy engine layer (`solitaire_engine`) does not need to change. +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` 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). --- -- 2.47.3 From 8f5193035b1634a066a01e113b1fbf5cb0f3ac47 Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 29 May 2026 13:44:36 -0700 Subject: [PATCH 3/5] 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 --- docs/card-game-integration.md | 87 ++++++++++++++++++++--------------- 1 file changed, 50 insertions(+), 37 deletions(-) diff --git a/docs/card-game-integration.md b/docs/card-game-integration.md index e21b4b5..3f86c00 100644 --- a/docs/card-game-integration.md +++ b/docs/card-game-integration.md @@ -2,7 +2,7 @@ **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. +**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. --- @@ -42,32 +42,40 @@ 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: +Ferrous uses **Windows XP Standard** scoring. The exact table already implemented in `solitaire_core/src/scoring.rs`: -| 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 | +| 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` | -**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`. +Reference: + +`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 rules and scoring. None exist in `klondike` today: +Ferrous has three modes that alter scoring and undo behaviour: -| Mode | Behaviour | -|---|---| -| `Classic` | Standard scoring, undo allowed | -| `Challenge` | Undo disabled (returns an error) | -| `Zen` | All scoring disabled; score is always 0 | +| 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 | -**In our wrapper:** Add `GameMode` to `solitaire_core::GameState`; intercept undo calls and scoring deltas in the adapter before delegating to `KlondikeState`. +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. @@ -75,14 +83,16 @@ The engine has a Settings toggle — "Winnable deals only" — backed by a DFS s **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. +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. -**In our wrapper:** Intercept and validate `foundation_to_tableau` moves in `solitaire_core` before delegating to `klondike`; guard with a config flag. +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`. +`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). Reconstruct `KlondikeState` from the seed + move history on load, or snapshot it as an opaque field. Schema version field lives on our wrapper. +**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.): @@ -96,32 +106,33 @@ 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. +`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 -`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. +### 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. -**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. +**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 — no changes required in `card_game` or `klondike`: +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` 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). +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). --- @@ -135,7 +146,9 @@ Steps in dependency order — no changes required in `card_game` or `klondike`: ## References -- Friend's repo: +- Quaternions' repo: +- Upstream issue — scoring: +- Upstream issue — take_from_foundation: - `solitaire_core` source: `solitaire_core/src/` - Scoring spec: `solitaire_core/src/scoring.rs` - Solver spec: `solitaire_core/src/solver.rs` -- 2.47.3 From a550a0cdf9acc7c5be6cb2fc0b02e99f4b952c24 Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 29 May 2026 14:07:34 -0700 Subject: [PATCH 4/5] docs: update integration doc to reflect klondike v0.2.0 / card_game v0.3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/card-game-integration.md | 81 ++++++++++++++++++----------------- 1 file changed, 41 insertions(+), 40 deletions(-) diff --git a/docs/card-game-integration.md b/docs/card-game-integration.md index 3f86c00..8033735 100644 --- a/docs/card-game-integration.md +++ b/docs/card-game-integration.md @@ -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. -**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. +**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) +### `card_game` crate (generic primitives) — v0.3.0 | Feature | Notes | |---|---| | `Card` (Deck + Suit + Rank packed in 1 byte) | `NonZeroU8` layout — no heap allocation | @@ -20,18 +20,21 @@ | `SessionInstruction::Undo` | Undo via full-state replay from seed | | `no_std`-compatible design | Const generics on stack sizes | -### `klondike` crate (Klondike rules) +### `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` | +| 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) | ✅ | -| `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 | +| `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 | @@ -39,30 +42,26 @@ ## 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 +### 1. Scoring — remaining adapter responsibilities 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` | +| 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: -`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. +**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. -**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. +**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. ### 2. Game Modes Ferrous has three modes that alter scoring and undo behaviour: @@ -75,22 +74,22 @@ Ferrous has three modes that alter scoring and undo behaviour: 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. +**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 **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. +### 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. -Quaternions has opened [card_game issue #11](https://git.aleshym.co/Quaternions/card_game/issues/11) ✅ to add this as a config 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 must explicitly set `Disallowed` in the default `KlondikeConfig` and switch to `Allowed` only when the user toggles the house-rule 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. +**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 here — Quaternions confirmed this is cleanly 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. @@ -106,7 +105,7 @@ 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`. +`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. @@ -116,21 +115,21 @@ 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. ### 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. +`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. No wrapper work needed for this item. +**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 can land at any time and will shrink the wrapper surface further. +Steps in dependency order. Upstream issues #10 and #11 are closed and merged; no further upstream work is pending. -1. **Add `klondike` as a dependency** of `solitaire_core`; stub out a `KlondikeAdapter` struct that wraps `KlondikeState` and passes all existing tests. +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` 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). +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). @@ -147,8 +146,10 @@ Steps in dependency order. Upstream issues #10 and #11 can land at any time and ## References - Quaternions' repo: -- Upstream issue — scoring: -- Upstream issue — take_from_foundation: +- `card_game v0.3.0` release commit: `31e7cdbc` +- `klondike v0.2.0` release commit: `50cf2f37` +- Upstream scoring PR: (closes issue #10) +- Upstream foundation config PR: (closes issue #11) - `solitaire_core` source: `solitaire_core/src/` - Scoring spec: `solitaire_core/src/scoring.rs` - Solver spec: `solitaire_core/src/solver.rs` -- 2.47.3 From aebb401c441d65537d94baf67214a559d2002ad5 Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 29 May 2026 15:08:32 -0700 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20update=20for=20card=5Fgame=20v0.4.0?= =?UTF-8?q?=20/=20klondike=20v0.3.0=20=E2=80=94=20undo=20scoring=20+=20sol?= =?UTF-8?q?ver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/card-game-integration.md | 71 ++++++++++++++++++++--------------- 1 file changed, 41 insertions(+), 30 deletions(-) diff --git a/docs/card-game-integration.md b/docs/card-game-integration.md index 8033735..6ad1f38 100644 --- a/docs/card-game-integration.md +++ b/docs/card-game-integration.md @@ -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. -**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 -### `card_game` crate (generic primitives) — v0.3.0 +### `card_game` crate (generic primitives) — v0.4.0 | Feature | Notes | |---|---| | `Card` (Deck + Suit + Rank packed in 1 byte) | `NonZeroU8` layout — no heap allocation | @@ -16,11 +16,13 @@ | `Stack` | Const-generic `ArrayVec` wrapper | | `Pile` | 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 | +| `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` or `SolveError` | +| `StateSnapshot` | 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 | |---|---| | 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` ✅ | | 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) | +| 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** | @@ -59,9 +61,11 @@ Ferrous uses **Windows XP Standard** scoring. The exact table already implemente Reference: -**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 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`. -### 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. +### 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>, 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` contains the winning move sequence as `Vec>`; `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` 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. -**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. ### 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. +**Session history:** `StateSnapshot` 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 `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. ### 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>` 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` will be removed once `GameState` is fully migrated to delegate to `Session`. --- ## 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. -2. **Map pile types** — project `klondike`'s stock face-up half as `PileType::Waste`; expose the same `HashMap` 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). +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` the engine already reads. Wire `Session` 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, 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 our `SolverConfig`; map `Ok(Some)` → Winnable, `Ok(None)` → Unwinnable, `Err` → Inconclusive (gap 3, upstream). +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 - Quaternions' repo: -- `card_game v0.3.0` release commit: `31e7cdbc` -- `klondike v0.2.0` release commit: `50cf2f37` -- Upstream scoring PR: (closes issue #10) -- Upstream foundation config PR: (closes issue #11) +- `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 spec: `solitaire_core/src/scoring.rs` -- Solver spec: `solitaire_core/src/solver.rs` - Architecture overview: `ARCHITECTURE.md` -- 2.47.3