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`