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 <noreply@anthropic.com>
This commit is contained in:
@@ -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: <https://www.solitaireparadise.com/games_list/klondike_solitaire_scoring.html>
|
||||
|
||||
`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<PileType, Pile>` 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: <https://git.aleshym.co/Quaternions/card_game>
|
||||
- Quaternions' repo: <https://git.aleshym.co/Quaternions/card_game>
|
||||
- Upstream issue — scoring: <https://git.aleshym.co/Quaternions/card_game/issues/10>
|
||||
- Upstream issue — take_from_foundation: <https://git.aleshym.co/Quaternions/card_game/issues/11>
|
||||
- `solitaire_core` source: `solitaire_core/src/`
|
||||
- Scoring spec: `solitaire_core/src/scoring.rs`
|
||||
- Solver spec: `solitaire_core/src/solver.rs`
|
||||
|
||||
Reference in New Issue
Block a user