6193d31497
apply_safe_area_to_modal_scrims now sets both padding.top (status-bar height) and padding.bottom (gesture-bar height) on every ModalScrim. With align_items/justify_content: Center on the scrim, the modal card lands at the visual midpoint of the visible area between the two system bars, fixing the slight upward shift that occurred when only the bottom inset was applied. Also: mark all rewrite-plan phases (0–3) complete; drop obsolete stash whose 20 files are already incorporated into master; update CLAUDE.md §14.3 to document both edges. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
409 lines
19 KiB
Markdown
409 lines
19 KiB
Markdown
# In-Place card_game / klondike Rewrite Plan
|
||
|
||
**Date:** 2026-06-08
|
||
**Upstream rev:** `99b49e62`
|
||
**Status:** All phases complete (0–3). recycle_count drift and score compound error on undo fixed in `56e3b62`.
|
||
|
||
---
|
||
|
||
## 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_foundation` default 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:
|
||
|
||
```rust
|
||
// before
|
||
pub fn check_win(&self) -> bool { ... 40 lines ... }
|
||
|
||
// after
|
||
pub fn check_win(&self) -> bool {
|
||
self.session.state().state().is_win()
|
||
}
|
||
```
|
||
|
||
```rust
|
||
// 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` (update `Serialize`/`Deserialize` impls)
|
||
- `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 — uses `SavedKlondikePile` independently)
|
||
- `solitaire_wasm/src/lib.rs` (uses `SavedKlondikePileStack` in its own mirror — evaluate)
|
||
|
||
**Steps:**
|
||
1. In `game_state.rs`, change `PersistedGameState.saved_moves` from
|
||
`Vec<SavedInstruction>` to `Vec<KlondikeInstruction>` (upstream serde now works).
|
||
2. Update `GameState::Serialize` to emit `KlondikeInstruction` directly.
|
||
3. Update `GameState::Deserialize` to parse `KlondikeInstruction` directly.
|
||
4. Increment `GAME_STATE_SCHEMA_VERSION` to 4.
|
||
5. In `GameState::Deserialize`, reject schema != 4 with graceful fallback (already
|
||
handled by `load_game_state_from` returning `None` on serde error or wrong version).
|
||
6. Delete `SavedInstruction`, `SavedDstFoundation`, `SavedDstTableau`, `SavedKlondikePile`,
|
||
`SavedKlondikePileStack`, `SavedTableauStack`, `SavedTableau`, `SavedFoundation`,
|
||
`SavedSkipCards`, `InvalidSavedInstruction` from `klondike_adapter.rs`.
|
||
7. Delete the 20 `From`/`TryFrom` impls.
|
||
8. Remove `SavedInstruction` proptest and boundary tests (no longer needed).
|
||
9. 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()`, recompute `recycle_count` by scanning the new
|
||
`session.history()` for `RotateStock` snapshots where
|
||
`snapshot.state().state().stock().face_down().is_empty()` (indicating the rotation
|
||
was a recycle, not a draw from a populated stock).
|
||
- Restore `score` to `snapshot_score` **before** the undone move, then apply only
|
||
the −15 undo penalty. This requires reading the score stored in `StateSnapshot`
|
||
or 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_undone`
|
||
- `score_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_move`
|
||
- `game_state::tests::take_from_foundation_disabled_blocks_return_move_everywhere`
|
||
- `proptest_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 to `KlondikeInstruction` directly
|
||
- `storage::tests::save_format_v3_is_rejected` — v3 files must return `None`
|
||
- 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_count` must reflect the correct post-undo count
|
||
|
||
---
|
||
|
||
## 9. Validation Commands
|
||
|
||
Run after each phase:
|
||
|
||
```bash
|
||
# 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
|
||
- `KlondikeAdapter` scoring helpers (recycle penalties, score floor, time bonus, Zen/mode suppression)
|
||
- `DrawMode`, `GameMode`, `DifficultyLevel`
|
||
- `MoveError` and all boundary-checking logic
|
||
- `card::Card` (id + face_up projection)
|
||
- `Pile` DTO
|
||
- `stock_cards()` / `waste_cards()` projections
|
||
- Persistence format (`GameState` serde, schema version, `PersistedGameState`)
|
||
- `solitaire_data::replay` types (`ReplayMove`, `SavedKlondikePile` mirror — unchanged)
|
||
- `solitaire_wasm` replay mirror types (unchanged)
|