Phase 0 – doc fixes (docs/card-game-integration.md): - Correct stale "no serde" claim: upstream has serde at rev 99b49e62 - Correct take_from_foundation default description (Allowed, not Disallowed) - Document schema v3→v4 migration and AnyInstruction strategy Phase 1 – delegate check_win / check_auto_complete to upstream: - Proptests verify semantic agreement with is_win() / is_win_trivial() across 256 random states before delegation Phase 2 – schema v4 with v3 auto-migration: - SavedInstruction mirror types kept as legacy compat module (needed by solitaire_data::ReplayMove and solitaire_wasm replay layer) - klondike_adapter.rs: add comprehensive legacy-purpose doc comment - proptest_tests.rs: add check_auto_complete/check_win semantic proofs - storage.rs: rename round-trip test to v4, add v3-migrates-to-v4 test Also track the rewrite plan (docs/in-place-card-game-rewrite-plan.md). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
19 KiB
In-Place card_game / klondike Rewrite Plan
Date: 2026-06-08
Upstream rev: 99b49e62
Status: Phases 0–2 complete. Pre-Phase 3 undo audit complete (see §5 below). Awaiting approval for Phase 3.
1. What Is Already Integrated
The integration is substantially complete. solitaire_core already delegates all
authoritative Klondike logic to the upstream crates.
| Area | Status | Location |
|---|---|---|
Session<Klondike> ownership |
✅ complete | GameState.session |
draw() → session.process_instruction(RotateStock) |
✅ complete | game_state.rs |
move_cards() → session.process_instruction(KlondikeInstruction) |
✅ complete | game_state.rs |
undo() → session.undo() |
✅ complete | game_state.rs |
possible_instructions() → session.state().state().get_sorted_moves() |
✅ complete | game_state.rs |
can_move_cards() → session.state().state().is_instruction_valid() |
✅ complete | game_state.rs |
solver.rs → session.solve() |
✅ complete | solver.rs |
Suit, Rank → re-export from card_game |
✅ complete | card.rs |
Foundation, Klondike, KlondikePile, Session, Tableau → solitaire_core::lib |
✅ complete | lib.rs |
| Move legality enforcement | ✅ upstream (is_instruction_valid) |
klondike/src/lib.rs |
| Foundation placement rules (Ace start, suit match) | ✅ upstream | klondike/src/lib.rs |
| Tableau placement rules (alternating colour, King on empty) | ✅ upstream | klondike/src/lib.rs |
Multi-card stack moves via SkipCards |
✅ upstream | klondike/src/lib.rs |
| Session history / snapshot undo | ✅ upstream | card_game/src/lib.rs |
| DFS solver with budget limits | ✅ upstream | card_game/src/lib.rs |
Instruction history → SavedInstruction serde mirrors |
✅ in adapter | klondike_adapter.rs |
| Schema v3 save/load (instruction replay) | ✅ complete | game_state.rs, storage.rs |
take_from_foundation house rule → MoveFromFoundationConfig |
✅ complete | klondike_adapter.rs |
2. Duplicated / Replaceable Logic
These are local implementations that either replicate upstream or could be removed.
2a. SavedInstruction mirror types (~300 lines, klondike_adapter.rs)
What: A full hand-written serde mirror for every upstream klondike instruction type
(SavedInstruction, SavedDstFoundation, SavedDstTableau, SavedKlondikePile,
SavedKlondikePileStack, SavedTableauStack, SavedTableau, SavedFoundation,
SavedSkipCards, InvalidSavedInstruction) plus ~20 From/TryFrom conversion impls.
Why written: At the time, upstream klondike had no serde feature.
Current upstream status: At rev 99b49e62, the serde feature is present and active.
KlondikeInstruction, KlondikePile, KlondikePileStack, DstFoundation, DstTableau,
TableauStack, Tableau, Foundation, SkipCards all derive
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))].
Blocker — JSON format incompatibility:
| Field | Local SavedInstruction JSON |
Upstream KlondikeInstruction JSON |
|---|---|---|
| Tableau index | { "Tableau": 0 } (u8) |
{ "Tableau": "Tableau1" } (named) |
| Foundation slot | { "Foundation": 0 } (u8) |
{ "Foundation": "Foundation1" } (named) |
| Skip count | { "skip_cards": 0 } (u8) |
{ "skip_cards": "Skip0" } (named) |
Switching to direct upstream serde changes the saved_moves JSON shape stored in
game_state.json. Any existing v3 save file would fail to deserialize after the switch.
This requires either:
- A schema bump to v4 with a migration (deserialize v3 manually then re-save as v4), or
- A schema bump to v4 with graceful fallback (v3 files rejected → fresh game).
Recommendation: Schema v4 with graceful fallback (v3 saves start fresh). Migration is feasible but adds ~100 lines of throwaway code; the in-progress game loss is modest since schema v3 was never shipped to users (it landed in the current dev branch, not a release).
2b. GameState::check_win() (~15 lines)
What: Iterates all four foundation slots checking 13-card A→K sequences.
Upstream equivalent: session.state().state().is_win() on Klondike.
Status: Local check is correct but redundant. Trivially replaceable with no format change.
Risk: None — only affects is_won flag update path.
2c. GameState::check_auto_complete() (~15 lines)
What: Checks stock empty, waste empty, all tableau cards face-up.
Upstream equivalent: session.state().state().is_win_trivial() on Klondike.
Semantic difference: Upstream is_win_trivial checks stock.is_empty() (both faces)
and all tableau.face_down().is_empty(). Ferrous additionally checks waste.is_empty().
These are logically equivalent for a valid game state (waste = stock face-up half).
Risk: Low — validated by existing auto-complete engine tests.
2c. recycle_count drift on undo (existing bug, not new)
What: GameState.recycle_count is incremented in draw() when stock is empty.
undo() does not decrement it. After undoing a recycle, recycle_count is stale and
may cause incorrect future penalty application.
Upstream: KlondikeStats.recycle_count() has the same problem — it is cumulative
and not restored on undo (stats are not part of the session snapshot, only game state is).
Fix approach: After each undo, recompute recycle_count by scanning
session.history() for RotateStock instructions that caused recycling.
Priority: Medium — affects scoring correctness in rare paths. File as a separate bug.
3. What Must Remain Ferrous-Specific
These responsibilities are product-layer, not Klondike-rules-layer, and must stay in solitaire_core.
| Responsibility | Why upstream cannot own it |
|---|---|
| WXP recycle penalties (free allowance + -100/-20) | ScoringConfig::recycle is a flat delta; no free-allowance concept exists upstream |
Score floor (score.max(0)) |
Not modelled upstream |
Time bonus (700_000 / elapsed_seconds) |
Not modelled upstream |
DrawMode / GameMode enums |
Product concept; not in upstream |
| Challenge mode undo block | Product rule |
| Zen mode scoring suppression | Product rule |
MoveError variants for UI feedback |
Upstream returns bool; Ferrous needs typed errors |
card::Card projection (adds id, face_up) |
Renderer requires stable id and face orientation |
Pile DTO for engine sync |
Renderer-facing snapshot type |
stock_cards() / waste_cards() distinction |
Engine models waste as a separate pile; upstream uses stock face-up half |
recycle_count tracking |
Needed for free-allowance penalty calculation |
| Persistence format + schema versioning | Product concern |
SavedInstruction (currently) or upstream serde (after migration) |
Either way, Ferrous owns the save contract |
4. Key Audit Findings
Finding 1 — Upstream serde claim in docs is stale
docs/card-game-integration.md (last section "JSON Serialisation") states:
Current verification (2026-06-01): klondike v0.3.0 and card_game v0.4.0 crate manifests expose no serde dependency/feature.
This is wrong at rev 99b49e62. The serde feature is present and active. All nine
instruction/pile types have #[cfg_attr(feature = "serde", derive(...))]. The doc must
be updated.
Finding 2 — take_from_foundation default: docs vs code
docs/card-game-integration.md says:
Ferrous Solitaire uses the standard rule (foundation cards cannot be moved back) as the default, with the house rule as an opt-in.
The code and settings say the opposite: Settings::take_from_foundation defaults to
true (Allowed); GameState.take_from_foundation also initializes to true. Multiple
tests assert this is the intended behavior. The upstream default is also Allowed.
Resolution: The docs are wrong. Default = Allowed (house rule on by default for beginner-friendliness) is intentional. Update the docs; do not change the code.
Finding 3 — KlondikeStats cumulative vs session-history-aware counts
KlondikeStats.moves() and KlondikeStats.recycle_count() accumulate monotonically.
They are NOT restored when Session::undo() is called (only Klondike game state is
restored from the snapshot, not the stats). Ferrous correctly uses
session.history().len() for move_count (history-aware). But recycle_count is
stored separately in GameState and also not decremented on undo — making them
equivalent in this one bug.
Finding 4 — SkipCards as usize cast is correct
Upstream SkipCards has no explicit discriminants, so Skip0 = 0 .. Skip12 = 12.
skip_cards as usize in solver.rs and game_state.rs is correct.
5. Staged Migration
Phase 0 — Doc fixes only (no code change)
Files: docs/card-game-integration.md
- Correct the serde claim (upstream has serde at rev 99b49e62).
- Correct the
take_from_foundationdefault description. - Update integration status table.
Phase 1 — Delegate is_win / is_win_trivial (safe, no format change)
Files: solitaire_core/src/game_state.rs
Replace local check_win() and check_auto_complete() with upstream delegation:
// before
pub fn check_win(&self) -> bool { ... 40 lines ... }
// after
pub fn check_win(&self) -> bool {
self.session.state().state().is_win()
}
// before
pub fn check_auto_complete(&self) -> bool { ... 15 lines ... }
// after
pub fn check_auto_complete(&self) -> bool {
self.session.state().state().is_win_trivial()
}
Risk: Very low. Both methods are tested by existing integration tests. The semantic
difference in check_auto_complete (upstream vs Ferrous definition) is equivalent for
valid game states.
Phase 2 — Replace SavedInstruction with upstream serde (schema v4)
Files:
solitaire_core/src/klondike_adapter.rs(remove ~300 lines)solitaire_core/src/game_state.rs(updateSerialize/Deserializeimpls)solitaire_core/src/proptest_tests.rs(remove now-redundant SavedInstruction tests)solitaire_data/src/storage.rs(add schema v4 rejection test)solitaire_data/src/replay.rs(no change — usesSavedKlondikePileindependently)solitaire_wasm/src/lib.rs(usesSavedKlondikePileStackin its own mirror — evaluate)
Steps:
- In
game_state.rs, changePersistedGameState.saved_movesfromVec<SavedInstruction>toVec<KlondikeInstruction>(upstream serde now works). - Update
GameState::Serializeto emitKlondikeInstructiondirectly. - Update
GameState::Deserializeto parseKlondikeInstructiondirectly. - Increment
GAME_STATE_SCHEMA_VERSIONto 4. - In
GameState::Deserialize, reject schema != 4 with graceful fallback (already handled byload_game_state_fromreturningNoneon serde error or wrong version). - Delete
SavedInstruction,SavedDstFoundation,SavedDstTableau,SavedKlondikePile,SavedKlondikePileStack,SavedTableauStack,SavedTableau,SavedFoundation,SavedSkipCards,InvalidSavedInstructionfromklondike_adapter.rs. - Delete the 20
From/TryFromimpls. - Remove
SavedInstructionproptest and boundary tests (no longer needed). - Add schema v4 round-trip test and v3 rejection test.
Note on solitaire_data::replay.rs:
replay.rs uses SavedKlondikePile independently (for ReplayMove). This is a
separate type from the game-state save format and is NOT changed by this phase.
ReplayMove has its own schema (REPLAY_SCHEMA_VERSION) and can keep using the local
mirror types.
Note on solitaire_wasm/src/lib.rs:
Uses SavedKlondikePileStack in its own ReplayMove mirror. Same as above — separate
type, not affected.
Pre-Phase 3 — Undo Field Audit (completed 2026-06-08)
Full audit of every Ferrous-owned field in GameState for undo correctness.
| Field | Correctly updated by undo()? |
Notes |
|---|---|---|
score |
✅ By design | −15 WXP undo penalty applied; Zen: stays 0 |
move_count |
✅ Correct | Recomputed from session.history().len() |
is_won |
✅ Correct | Recomputed; undo blocked on won game |
is_auto_completable |
✅ Correct | Recomputed |
undo_count |
✅ By design | Total undos ever, intentionally non-reversible |
elapsed_seconds |
✅ Intentional | Timer is independent of moves |
seed / draw_mode / mode / take_from_foundation |
✅ Immutable | |
recycle_count |
❌ Bug | Not decremented — see below |
recycle_count drift bug:
draw() increments recycle_count when stock.face_down().is_empty() (the rotation
is a recycle, not just a draw). undo() calls session.undo() which restores the
Klondike card state, but does NOT decrement recycle_count.
Consequence: if the player recycles, undoes it, then recycles again, recycle_count
is 2 instead of 1 — the free-recycle allowance is consumed even though the first
recycle was undone. On Draw-1, the 2nd recycle costs −100; after the undo-and-replay
bug the player pays −100 for what should be their still-free recycle.
Score compound effect: When undo() is applied to a recycle that incurred a
penalty, the penalty amount (score_after_recycle - 100) is already in self.score.
apply_undo_score then adds −15 on top. The recycle penalty is never reversed.
Fix approach for Phase 3:
- After
session.undo(), recomputerecycle_countby scanning the newsession.history()forRotateStocksnapshots wheresnapshot.state().state().stock().face_down().is_empty()(indicating the rotation was a recycle, not a draw from a populated stock). - Restore
scoretosnapshot_scorebefore the undone move, then apply only the −15 undo penalty. This requires reading the score stored inStateSnapshotor keeping a pre-move score stack alongside the session history.
Simpler alternative: Store (score_before, recycle_count_before) in GameState
alongside each session.process_instruction call, mirroring the snapshot stack.
Undo pops this alongside the session undo.
Phase 3 — Fix recycle_count drift on undo (optional, post-approval)
Files: solitaire_core/src/game_state.rs
After session.undo(), recompute recycle_count by scanning session.history() for
RotateStock snapshots where the pre-instruction stock face-down was empty (indicating
a recycle). Also correct the score: restore to the pre-undone-move score and apply only
the −15 undo penalty.
Tests to add:
recycle_count_decrements_when_recycle_is_undonescore_recycle_penalty_is_reversed_on_undo
Risk: Medium — changes observable scoring behavior. The fix is strictly more correct, but any golden-file or regression test that recorded the old (buggy) score after undo-of-recycle will need updating.
6. Files Likely to Change Per Phase
| Phase | Files |
|---|---|
| Phase 0 | docs/card-game-integration.md |
| Phase 1 | solitaire_core/src/game_state.rs |
| Phase 2 | solitaire_core/src/klondike_adapter.rs, solitaire_core/src/game_state.rs, solitaire_core/src/proptest_tests.rs, solitaire_data/src/storage.rs |
| Phase 3 | solitaire_core/src/game_state.rs, new test module |
7. Risks
R1 — Save file format break (Phase 2, HIGH)
Users with v3 saves lose their in-progress game. Mitigated by the fact that v3 is not in any shipped release (dev branch only). Graceful fallback (start fresh) is acceptable; a migration shim is possible but not required.
R2 — solitaire_wasm / solitaire_data::replay breakage (Phase 2, MEDIUM)
SavedKlondikePile and SavedKlondikePileStack are also used in replay.rs and
wasm/src/lib.rs. These are separate from the game-state save format and must be
left in place. Plan is to keep them in klondike_adapter.rs (or relocate to
replay.rs) after the game-state mirror types are deleted.
R3 — check_auto_complete semantic drift (Phase 1, LOW)
Upstream is_win_trivial checks stock.is_empty() (no cards at all in stock)
whereas Ferrous also checks waste. These are equivalent for a valid game state but
could differ under test-support pile overrides. Existing auto-complete tests will
catch any regression.
R4 — SkipCards as usize cast correctness
Already verified: enums have implicit 0..12 discriminants. No risk.
R5 — Upstream changes after rev pin
The workspace is pinned to rev = "99b49e62". No upstream drift risk until explicitly
re-pinned.
8. Test Plan
Phase 1 tests (all currently pass)
game_state::tests::take_from_foundation_allows_legal_return_movegame_state::tests::take_from_foundation_disabled_blocks_return_move_everywhereproptest_tests::*(card conservation, deal determinism, undo invariant, legal moves)
Phase 2 tests to add
storage::tests::game_state_v4_mid_game_round_trip— verify upstream serde round-trip after migrating toKlondikeInstructiondirectlystorage::tests::save_format_v3_is_rejected— v3 files must returnNone- Update
game_state::tests::*— all existing tests must continue to pass
Phase 2 tests to remove
proptest_tests::saved_instruction_round_trip— no longer needed (no mirror types)proptest_tests::saved_instruction_boundary_tests::*— no longer needed
Phase 3 tests to add
game_state::tests::recycle_count_decrements_on_undo— after recycling and undoing,recycle_countmust reflect the correct post-undo count
9. Validation Commands
Run after each phase:
# Targeted (fast)
cargo test -p solitaire_core
cargo clippy -p solitaire_core -- -D warnings
# Broader
cargo test -p solitaire_wasm
cargo test -p solitaire_data
# Full workspace (run before declaring phase complete)
cargo test --workspace
cargo clippy --workspace -- -D warnings
Summary: What Would Be Removed vs Kept
Removed after all phases complete
| Code | Lines est. | Reason |
|---|---|---|
SavedInstruction + 8 mirror types |
~150 | Upstream serde now available |
20 From/TryFrom impls |
~150 | Upstream serde now available |
InvalidSavedInstruction error type |
~10 | Upstream serde now available |
check_win() local impl |
~20 | Replaced by is_win() delegation |
check_auto_complete() local impl |
~15 | Replaced by is_win_trivial() delegation |
SavedInstruction proptest + boundary tests |
~60 | Mirror types removed |
Total: ~400 lines removed from solitaire_core
Remains Ferrous-specific
KlondikeAdapterscoring helpers (recycle penalties, score floor, time bonus, Zen/mode suppression)DrawMode,GameMode,DifficultyLevelMoveErrorand all boundary-checking logiccard::Card(id + face_up projection)PileDTOstock_cards()/waste_cards()projections- Persistence format (
GameStateserde, schema version,PersistedGameState) solitaire_data::replaytypes (ReplayMove,SavedKlondikePilemirror — unchanged)solitaire_wasmreplay mirror types (unchanged)