docs: card_game integration gap analysis #76

Closed
funman300 wants to merge 5 commits from docs/card-game-integration into master
Owner

Summary

Adds docs/card-game-integration.md — a full comparison of what @Quaternions's card_game library already provides versus what solitaire_core needs, plus a prioritised list of work items.

What the doc covers

  • Feature matrix — what card_game + klondike have today (Card primitives, Pile, Game trait, Session/undo, Draw-1/3, auto-complete, benchmarks)
  • 8 identified gaps that need to be closed before integration is possible:
    1. Missing scoring sub-rules (flip bonus, recycle penalty, undo penalty, time bonus)
    2. Game modes (Classic / Challenge / Zen)
    3. Solvability solver (DFS with memoisation)
    4. take_from_foundation house rule
    5. JSON serialisation for persistence
    6. Typed MoveError returns (currently bool)
    7. Explicit Waste pile concept
    8. Undo performance (snapshot vs. replay trade-off)
  • 8-step integration path in dependency order
  • What does NOT need to change (engine, sync, server layers are isolated)

No code changes — documentation only.

## Summary Adds `docs/card-game-integration.md` — a full comparison of what [@Quaternions](https://git.aleshym.co/Quaternions)'s [`card_game`](https://git.aleshym.co/Quaternions/card_game) library already provides versus what `solitaire_core` needs, plus a prioritised list of work items. ## What the doc covers - **Feature matrix** — what `card_game` + `klondike` have today (Card primitives, Pile, Game trait, Session/undo, Draw-1/3, auto-complete, benchmarks) - **8 identified gaps** that need to be closed before integration is possible: 1. Missing scoring sub-rules (flip bonus, recycle penalty, undo penalty, time bonus) 2. Game modes (Classic / Challenge / Zen) 3. Solvability solver (DFS with memoisation) 4. `take_from_foundation` house rule 5. JSON serialisation for persistence 6. Typed `MoveError` returns (currently `bool`) 7. Explicit Waste pile concept 8. Undo performance (snapshot vs. replay trade-off) - **8-step integration path** in dependency order - **What does NOT need to change** (engine, sync, server layers are isolated) No code changes — documentation only.
funman300 added 1 commit 2026-05-28 22:30:33 +00:00
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>
funman300 added 1 commit 2026-05-28 23:23:08 +00:00
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>
First-time contributor

Missing scoring sub-rules (flip bonus, recycle penalty, undo penalty, time bonus)

If you can find an online article that has the exact scoring rules I can work on improving the scoring, that should be pretty simple. Time bonus will need to be integrated externally due to dependence on non-portable primitives (std::time not portable to wasm).
Quaternions/card_game#10

Classic / Challenge / Zen

What does this entail? I don't know the difference and couldn't tell the difference when playing.

take_from_foundation house rule

I assume this means a configuration option to disable taking from foundation. Can do.
Quaternions/card_game#11

JSON serialisation for persistence

This can be implemented externally from card_game (i.e. in solitaire_core) with newtypes

Typed MoveError returns (currently bool)

I don't see the point of this. Invalid moves are not expressible with KlondikeInstruction other than referring to a SkipCard incorrectly, which is not possible from the user's POV since the KlondikeInstruction is generated by the game code using game entity layout. Therefore, solitaire_core will be the one to know when a move is invalid because it fails to construct the move, and can give an error accordingly if so desired.

Undo performance (snapshot vs. replay trade-off)

This is not an issue. Yes it is O(n), but the performance is 1000000 moves / second which means a maximum game length of ~192 moves will always complete in 0.02ms and feel instantaneous.

> Missing scoring sub-rules (flip bonus, recycle penalty, undo penalty, time bonus) If you can find an online article that has the exact scoring rules I can work on improving the scoring, that should be pretty simple. Time bonus will need to be integrated externally due to dependence on non-portable primitives (std::time not portable to wasm). https://git.aleshym.co/Quaternions/card_game/issues/10 ✅ > Classic / Challenge / Zen What does this entail? I don't know the difference and couldn't tell the difference when playing. > take_from_foundation house rule I assume this means a configuration option to disable taking from foundation. Can do. https://git.aleshym.co/Quaternions/card_game/issues/11 ✅ > JSON serialisation for persistence This can be implemented externally from card_game (i.e. in solitaire_core) with newtypes > Typed MoveError returns (currently bool) I don't see the point of this. Invalid moves are not expressible with KlondikeInstruction other than referring to a SkipCard incorrectly, which is not possible from the user's POV since the KlondikeInstruction is generated by the game code using game entity layout. Therefore, solitaire_core will be the one to know when a move is invalid because it fails to construct the move, and can give an error accordingly if so desired. > Undo performance (snapshot vs. replay trade-off) This is not an issue. Yes it is O(n), but the performance is 1000000 moves / second which means a maximum game length of ~192 moves will always complete in 0.02ms and feel instantaneous.
Author
Owner

Thanks for the detailed response!


Scoring sub-rules

The rules we use are the Windows XP Standard scoring variant. Here is 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

A commonly cited reference: https://www.solitaireparadise.com/games_list/klondike_solitaire_scoring.html

Agreed on the time bonus — that stays in solitaire_core since it depends on wall-clock time which is not wasm-portable.


Classic / Challenge / Zen

The three modes share the same Klondike rules but differ on scoring and undo:

Mode Scoring Undo
Classic Full WXP scoring as above Allowed (−15 penalty)
Zen Score always stays 0 — no deltas applied Allowed (no penalty)
Challenge Full WXP scoring Disabled — returns an error

Zen is intended for relaxed play where the score does not matter. Challenge is a timed daily puzzle where the no-undo constraint is the difficulty mechanic.


take_from_foundation

To clarify: this is about enabling an optional move that is off by default. When the flag is on, the player is allowed to move the top card of a foundation pile back down to a compatible tableau column. The standard rule is that foundation cards cannot be moved back; this is the house-rule relaxation that makes stuck games more recoverable. So it is the opposite of disabling — it is an extra legal instruction guarded by a config flag.


JSON serialisation

Agreed — we will handle it in solitaire_core via newtypes. No changes needed on your side.


Typed MoveError

Your reasoning makes sense. 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 — so the error lives at our boundary, not yours. We will map it there.


Undo performance

1 000 000 moves/sec and a max game length of ~192 moves = 0.02 ms worst case. Agreed — not worth changing. We will accept the replay approach.

Thanks for the detailed response! --- ### Scoring sub-rules The rules we use are the Windows XP Standard scoring variant. Here is 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` | A commonly cited reference: https://www.solitaireparadise.com/games_list/klondike_solitaire_scoring.html Agreed on the time bonus — that stays in `solitaire_core` since it depends on wall-clock time which is not wasm-portable. --- ### Classic / Challenge / Zen The three modes share the same Klondike rules but differ on scoring and undo: | Mode | Scoring | Undo | |---|---|---| | **Classic** | Full WXP scoring as above | Allowed (−15 penalty) | | **Zen** | Score always stays 0 — no deltas applied | Allowed (no penalty) | | **Challenge** | Full WXP scoring | **Disabled** — returns an error | Zen is intended for relaxed play where the score does not matter. Challenge is a timed daily puzzle where the no-undo constraint is the difficulty mechanic. --- ### `take_from_foundation` To clarify: this is about **enabling** an optional move that is off by default. When the flag is on, the player is allowed to move the top card of a foundation pile back down to a compatible tableau column. The standard rule is that foundation cards cannot be moved back; this is the house-rule relaxation that makes stuck games more recoverable. So it is the opposite of disabling — it is an extra legal instruction guarded by a config flag. --- ### JSON serialisation Agreed — we will handle it in `solitaire_core` via newtypes. No changes needed on your side. --- ### Typed `MoveError` Your reasoning makes sense. 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 — so the error lives at our boundary, not yours. We will map it there. --- ### Undo performance 1 000 000 moves/sec and a max game length of ~192 moves = 0.02 ms worst case. Agreed — not worth changing. We will accept the replay approach.
First-time contributor

The scoring specifics and challenge modes are completely configurable from within solitaire_core using the latest version of card_game + klondike. Looks like this is ready to start implementing.

The scoring specifics and challenge modes are completely configurable from within solitaire_core using the latest version of card_game + klondike. Looks like this is ready to start implementing.
funman300 added 1 commit 2026-05-29 20:44:40 +00:00
- 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>
funman300 added 1 commit 2026-05-29 21:07:37 +00:00
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 <noreply@anthropic.com>
First-time contributor

Solvability solver (DFS with memoisation)

Published a new crate version with a solver. The history is also changed to use snapshots instead of replay.

> Solvability solver (DFS with memoisation) Published a new crate version with a solver. The history is also changed to use snapshots instead of replay.
Quaternions requested changes 2026-05-29 22:03:14 +00:00
@@ -0,0 +51,4 @@
| 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) |
First-time contributor

Undo is indeed not present in klondike, but it is implemented by card_game::Session, undo score is tracked in card_game::SessionStats.

Undo is indeed not present in klondike, but it is implemented by card_game::Session, undo score is tracked in card_game::SessionStats.
@@ -0,0 +77,4 @@
**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.
First-time contributor

Added a solver to card_game 0.4.0.

Added a solver to card_game 0.4.0.
@@ -0,0 +115,4 @@
**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.
First-time contributor

Undo has been changed to a stack-based approach in card_game 0.4.0.

Undo has been changed to a stack-based approach in card_game 0.4.0.
Author
Owner

Thanks for the correction and for the new release!

Undo scoring

You are right — undo penalty is included via SessionState::score() = game_score + undos × undo_penalty. I had misread the v0.2.0 source and assumed the penalty was absent. Will fix the doc.

Solver

Looking at the new Session::solve() — much cleaner than what we wrote. We will replace our 767-line solver with session.solve() once the session is wired up. Can you clarify what SolveError::MovesBudgetExceeded vs SolveError::StatesBudgetExceeded maps to in terms of our SolverResult::Inconclusive? I assume both cases correspond to Inconclusive on our side.

Snapshot-based history

Also noticed SessionState now uses snapshot-based undo (stores pre-move G snapshot + instruction) instead of replaying from seed. This closes Gap 8 from the original doc — undo is O(1) on the session side too.

Thanks for the correction and for the new release! ### Undo scoring You are right — undo penalty is included via `SessionState::score()` = `game_score + undos × undo_penalty`. I had misread the v0.2.0 source and assumed the penalty was absent. Will fix the doc. ### Solver Looking at the new `Session::solve()` — much cleaner than what we wrote. We will replace our 767-line solver with `session.solve()` once the session is wired up. Can you clarify what `SolveError::MovesBudgetExceeded` vs `SolveError::StatesBudgetExceeded` maps to in terms of our `SolverResult::Inconclusive`? I assume both cases correspond to `Inconclusive` on our side. ### Snapshot-based history Also noticed `SessionState` now uses snapshot-based undo (stores pre-move `G` snapshot + instruction) instead of replaying from seed. This closes Gap 8 from the original doc — undo is O(1) on the session side too.
funman300 added 1 commit 2026-05-29 22:08:35 +00:00
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>
Quaternions requested changes 2026-05-29 22:16:54 +00:00
@@ -0,0 +138,4 @@
Steps in dependency order. Upstream issues #10, #11, and the solver are all merged.
1. ✅ **Add `klondike = "0.3.0"` / `card_game = "0.4.0"` as dependencies** of `solitaire_core`; `KlondikeAdapter` wraps `KlondikeConfig` and exposes scoring helpers.
2. **Map pile types** — project `klondike`'s stock face-up half as `PileType::Waste`; expose the same `HashMap<PileType, Pile>` the engine already reads. Wire `Session<Klondike>` into `KlondikeAdapter` (gap 7).
First-time contributor

HashMap<PileType, Pile> is not going to work because the piles are owned by KlondikeState. There should be no HashMap required to display the pile contents since each pile / stack is rendered explicitly in a fixed location. Attempted moves can be constructed directly as a KlondikeInstruction with no need for (PileType, PileType, usize) or HashMap.

`HashMap<PileType, Pile>` is not going to work because the piles are owned by KlondikeState. There should be no HashMap required to display the pile contents since each pile / stack is rendered explicitly in a fixed location. Attempted moves can be constructed directly as a KlondikeInstruction with no need for (PileType, PileType, usize) or HashMap.
funman300 closed this pull request 2026-05-30 01:04:59 +00:00

Pull request closed

Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: funman300/Ferrous-Solitaire#76