Compare commits
4 Commits
7dbf34c163
...
6193d31497
| Author | SHA1 | Date | |
|---|---|---|---|
| 6193d31497 | |||
| 26f1b00186 | |||
| 56e3b62269 | |||
| 9bcf13d8f2 |
@@ -430,9 +430,11 @@ explicitly replacing the current one (despawn first, then spawn).
|
||||
|
||||
## 14.3 Safe area
|
||||
|
||||
Every `ModalScrim` automatically receives `padding.bottom` equal to the
|
||||
logical gesture-bar height via `apply_safe_area_to_modal_scrims` in
|
||||
`SafeAreaInsetsPlugin`. Do not manually add bottom padding to scrim nodes.
|
||||
Every `ModalScrim` automatically receives `padding.top` equal to the logical
|
||||
status-bar height and `padding.bottom` equal to the logical gesture-bar height
|
||||
via `apply_safe_area_to_modal_scrims` in `SafeAreaInsetsPlugin`. This centres
|
||||
the modal card within the usable area between both system bars. Do not manually
|
||||
add top or bottom padding to scrim nodes.
|
||||
|
||||
## 14.4 Z-ordering
|
||||
|
||||
|
||||
Generated
+3
-2
@@ -2084,7 +2084,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "card_game"
|
||||
version = "0.4.0"
|
||||
source = "git+https://git.aleshym.co/Quaternions/card_game#2eaa99e82dc40ab59ca0033717667fe7f66452d3"
|
||||
source = "git+https://git.aleshym.co/Quaternions/card_game?rev=99b49e62#99b49e629e2372962b082325503c33e20a458818"
|
||||
dependencies = [
|
||||
"arrayvec 0.7.6 (sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/)",
|
||||
"serde",
|
||||
@@ -4600,10 +4600,11 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "klondike"
|
||||
version = "0.3.0"
|
||||
source = "git+https://git.aleshym.co/Quaternions/card_game#2eaa99e82dc40ab59ca0033717667fe7f66452d3"
|
||||
source = "git+https://git.aleshym.co/Quaternions/card_game?rev=99b49e62#99b49e629e2372962b082325503c33e20a458818"
|
||||
dependencies = [
|
||||
"card_game",
|
||||
"rand 0.10.1",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+2
-2
@@ -38,8 +38,8 @@ solitaire_core = { path = "solitaire_core" }
|
||||
solitaire_sync = { path = "solitaire_sync" }
|
||||
solitaire_data = { path = "solitaire_data" }
|
||||
solitaire_engine = { path = "solitaire_engine" }
|
||||
klondike = { git = "https://git.aleshym.co/Quaternions/card_game" }
|
||||
card_game = { git = "https://git.aleshym.co/Quaternions/card_game", features = ["serde"] }
|
||||
klondike = { git = "https://git.aleshym.co/Quaternions/card_game", rev = "99b49e62", features = ["serde"] }
|
||||
card_game = { git = "https://git.aleshym.co/Quaternions/card_game", rev = "99b49e62", features = ["serde"] }
|
||||
|
||||
# Bevy with `default-features = false` to avoid the unused
|
||||
# `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain.
|
||||
|
||||
@@ -94,21 +94,22 @@ Our 767-line `solitaire_core::solver` reimplements the full game rules to run th
|
||||
### 4. `take_from_foundation` House Rule *(upstream merged — v0.3.0)*
|
||||
`MoveFromFoundationConfig` is now part of `KlondikeConfig`. When set to `Disallowed`, `is_instruction_valid` blocks foundation → tableau instructions.
|
||||
|
||||
**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 explicitly sets `Disallowed` in the default `KlondikeConfig` and switches to `Allowed` only when the user toggles the house-rule option.
|
||||
**Default behaviour:** The upstream default is `MoveFromFoundationConfig::Allowed`. Ferrous Solitaire **also defaults to Allowed** (`take_from_foundation: true` in `GameState`, `Settings`). This matches the upstream default and provides the most beginner-friendly experience. The player can disable foundation returns via a settings toggle (`take_from_foundation = false`), which maps to `Disallowed`.
|
||||
|
||||
**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.
|
||||
**In our wrapper:** `KlondikeAdapter::config_for(draw_mode, take_from_foundation)` constructs `KlondikeConfig { move_from_foundation: if take_from_foundation { Allowed } else { Disallowed }, .. }`. 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 — this is 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.
|
||||
|
||||
**Current verification (2026-06-01):** `klondike v0.3.0` and `card_game v0.4.0`
|
||||
crate manifests expose no `serde` dependency/feature, and source exports no
|
||||
serde derives for instruction/state snapshot types. Keep Ferrous'
|
||||
`SavedInstruction` bridge in place.
|
||||
**Upstream serde status (rev 99b49e62):** At this revision, `klondike` and `card_game` both enable a `serde` feature. All nine instruction/pile types (`KlondikeInstruction`, `KlondikePile`, `KlondikePileStack`, `DstFoundation`, `DstTableau`, `TableauStack`, `Foundation`, `Tableau`, `SkipCards`) derive `serde::Serialize` + `serde::Deserialize` under that feature. The workspace `Cargo.toml` enables `features = ["serde"]`.
|
||||
|
||||
**Session history:** `StateSnapshot<G>` stores the pre-move game state and instruction. On load, the session is reconstructed from the serialised snapshot history — no full replay from seed needed.
|
||||
**Schema v4 (current):** `saved_moves` serialises as `Vec<KlondikeInstruction>` using upstream named-variant serde. Example: `{"DstFoundation": {"src": "Stock", "foundation": "Foundation1"}}`.
|
||||
|
||||
**In our wrapper:** Serialise the `solitaire_core` wrapper struct using newtypes. Define `SavedInstruction` (a `Serialize + Deserialize` mirror of `KlondikeInstruction`) and `SavedStateSnapshot`. Reconstruct `SessionState` from the deserialised history. Schema version field lives on our wrapper.
|
||||
**Schema v3 (legacy, auto-migrated):** `saved_moves` used local `SavedInstruction` mirror types with u8 indices. Example: `{"DstFoundation": {"src": "Stock", "foundation": 0}}`. On load, an `AnyInstruction` untagged serde enum transparently upgrades v3 instructions to v4 and the file is written back in v4 format. The `SavedInstruction` bridge types are retained in `solitaire_core::klondike_adapter` for this migration path and for backward-compatible `solitaire_data::ReplayMove` / WASM replay formats.
|
||||
|
||||
**Session history:** `StateSnapshot<G>` stores the pre-move game state and instruction. On load, the session is reconstructed by replaying the instruction history against a fresh deal — no full state snapshot needed.
|
||||
|
||||
**In our wrapper:** `GameState::Serialize` emits schema v4 (upstream instruction types). `GameState::Deserialize` accepts v3 (auto-migrates) and v4 (direct). 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.):
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
# 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)
|
||||
@@ -6,7 +6,7 @@ use crate::klondike_adapter::{
|
||||
skip_cards_from_count as adapter_skip_cards_from_count,
|
||||
tableau_from_index as adapter_tableau_from_index,
|
||||
};
|
||||
use card_game::{Game, Session, SessionConfig};
|
||||
use card_game::{Game as _, Session, SessionConfig};
|
||||
use klondike::{
|
||||
DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig, KlondikeInstruction,
|
||||
KlondikePile, KlondikePileStack, SkipCards, Tableau, TableauStack,
|
||||
@@ -20,9 +20,12 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
/// History:
|
||||
/// - v1: `Foundation(Suit)` keys.
|
||||
/// - v2: `Foundation(u8)` slot keys; claimed suit derived from the bottom card.
|
||||
/// - v3 (current): session-backed save files store replayable instruction
|
||||
/// history instead of raw piles + undo snapshots.
|
||||
pub const GAME_STATE_SCHEMA_VERSION: u32 = 3;
|
||||
/// - v3: session-backed save files using local `SavedInstruction` mirror types
|
||||
/// with u8 indices for enum variants.
|
||||
/// - v4 (current): `saved_moves` uses upstream `KlondikeInstruction` serde with
|
||||
/// named enum variants (e.g. `"Foundation1"` instead of `0`). v3 files are
|
||||
/// auto-migrated on load via `AnyInstruction` transparent deserialization.
|
||||
pub const GAME_STATE_SCHEMA_VERSION: u32 = 4;
|
||||
|
||||
/// Default value for `GameState::schema_version` when deserialising older
|
||||
/// save files that pre-date the field.
|
||||
@@ -84,8 +87,45 @@ pub enum GameMode {
|
||||
Difficulty(DifficultyLevel),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
/// Output struct for schema v4 serialisation. `saved_moves` uses upstream
|
||||
/// `KlondikeInstruction` serde, which produces named enum variants.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct PersistedGameState {
|
||||
pub draw_mode: DrawMode,
|
||||
pub mode: GameMode,
|
||||
pub score: i32,
|
||||
pub elapsed_seconds: u64,
|
||||
pub seed: u64,
|
||||
pub undo_count: u32,
|
||||
pub recycle_count: u32,
|
||||
pub take_from_foundation: bool,
|
||||
pub schema_version: u32,
|
||||
pub saved_moves: Vec<KlondikeInstruction>,
|
||||
}
|
||||
|
||||
/// Transparent migration wrapper for deserialisation.
|
||||
///
|
||||
/// Tries `KlondikeInstruction` (schema v4, named variants) first; if that
|
||||
/// fails (because the value uses u8 indices), falls back to `SavedInstruction`
|
||||
/// (schema v3). Converting the V3 variant yields a `KlondikeInstruction` via
|
||||
/// the existing `TryFrom` impl.
|
||||
///
|
||||
/// `SavedInstruction` remains `pub` in `klondike_adapter` because
|
||||
/// `solitaire_data::ReplayMove` and the WASM replay layer depend on it.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum AnyInstruction {
|
||||
V4(KlondikeInstruction),
|
||||
V3(SavedInstruction),
|
||||
}
|
||||
|
||||
/// Input struct that accepts both schema v3 and v4 `saved_moves` formats.
|
||||
///
|
||||
/// `recycle_count` is intentionally absent: the value is rebuilt from the
|
||||
/// instruction replay so that stale counts (from the pre-Phase-3 undo drift
|
||||
/// bug) are corrected on load. Serde ignores the field in the JSON.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct PersistedGameStateIn {
|
||||
pub draw_mode: DrawMode,
|
||||
#[serde(default)]
|
||||
pub mode: GameMode,
|
||||
@@ -94,12 +134,10 @@ struct PersistedGameState {
|
||||
pub seed: u64,
|
||||
pub undo_count: u32,
|
||||
#[serde(default)]
|
||||
pub recycle_count: u32,
|
||||
#[serde(default)]
|
||||
pub take_from_foundation: bool,
|
||||
#[serde(default = "schema_v1")]
|
||||
pub schema_version: u32,
|
||||
pub saved_moves: Vec<SavedInstruction>,
|
||||
pub saved_moves: Vec<AnyInstruction>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
@@ -150,6 +188,15 @@ pub struct GameState {
|
||||
/// Save-file schema version.
|
||||
pub schema_version: u32,
|
||||
pub(crate) session: Session<Klondike>,
|
||||
/// Score recorded immediately before each instruction was applied.
|
||||
/// Parallel to `session.history()` during live play; used by `undo()` to
|
||||
/// correctly restore the pre-move score before applying the undo penalty.
|
||||
/// Empty after a load (can't be reconstructed from history alone).
|
||||
score_history: Vec<i32>,
|
||||
/// Whether each entry in `session.history()` was a stock recycle.
|
||||
/// Parallel to `session.history()`; rebuilt from replay on load so that
|
||||
/// `undo()` correctly decrements `recycle_count` even across save/load cycles.
|
||||
is_recycle_history: Vec<bool>,
|
||||
#[cfg(feature = "test-support")]
|
||||
/// Test pile overrides. Always `None` in production runtime code.
|
||||
pub test_pile_state: Option<TestPileState>,
|
||||
@@ -205,12 +252,17 @@ impl Serialize for GameState {
|
||||
|
||||
impl<'de> Deserialize<'de> for GameState {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let persisted = PersistedGameState::deserialize(deserializer)?;
|
||||
if persisted.schema_version != GAME_STATE_SCHEMA_VERSION {
|
||||
return Err(serde::de::Error::custom(format!(
|
||||
"unsupported GameState schema version {}",
|
||||
persisted.schema_version
|
||||
)));
|
||||
let persisted = PersistedGameStateIn::deserialize(deserializer)?;
|
||||
|
||||
// Accept v3 (legacy u8-index format, auto-migrated) and v4 (current,
|
||||
// upstream named-variant serde). Reject everything else.
|
||||
match persisted.schema_version {
|
||||
3 | 4 => {}
|
||||
v => {
|
||||
return Err(serde::de::Error::custom(format!(
|
||||
"unsupported GameState schema version {v}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let mut game = Self {
|
||||
@@ -223,18 +275,44 @@ impl<'de> Deserialize<'de> for GameState {
|
||||
is_won: false,
|
||||
is_auto_completable: false,
|
||||
undo_count: persisted.undo_count,
|
||||
recycle_count: persisted.recycle_count,
|
||||
// Rebuilt from the replay loop below; persisted value may be stale
|
||||
// due to the pre-Phase-3 undo drift bug.
|
||||
recycle_count: 0,
|
||||
take_from_foundation: persisted.take_from_foundation,
|
||||
schema_version: persisted.schema_version,
|
||||
// Always stamp the current schema version after a successful load so
|
||||
// storage.rs schema checks pass and re-saving writes the v4 format.
|
||||
schema_version: GAME_STATE_SCHEMA_VERSION,
|
||||
session: Self::new_session(persisted.seed, persisted.draw_mode),
|
||||
// score_history cannot be faithfully rebuilt from the instruction
|
||||
// history because live-play undo penalties are not recorded in
|
||||
// saved_moves. Leave empty; undo() falls back to old behaviour for
|
||||
// any move made before this load (see undo() for details).
|
||||
score_history: Vec::new(),
|
||||
// is_recycle_history IS rebuilt: recycle detection only needs the
|
||||
// pre-instruction session state, which is available during replay.
|
||||
is_recycle_history: Vec::new(),
|
||||
#[cfg(feature = "test-support")]
|
||||
test_pile_state: None,
|
||||
};
|
||||
|
||||
let replay_config = Self::replay_config(game.draw_mode);
|
||||
for saved in persisted.saved_moves {
|
||||
let instruction =
|
||||
KlondikeInstruction::try_from(saved).map_err(serde::de::Error::custom)?;
|
||||
for any in persisted.saved_moves {
|
||||
// AnyInstruction::V4 arrives directly from upstream serde (schema v4).
|
||||
// AnyInstruction::V3 was serialised with u8 indices (schema v3) and is
|
||||
// converted here via the existing TryFrom impl.
|
||||
let instruction = match any {
|
||||
AnyInstruction::V4(i) => i,
|
||||
AnyInstruction::V3(s) => {
|
||||
KlondikeInstruction::try_from(s).map_err(serde::de::Error::custom)?
|
||||
}
|
||||
};
|
||||
|
||||
// Detect recycle BEFORE processing so that the pre-instruction
|
||||
// session state (face-down stock) is still available.
|
||||
let is_recycle = matches!(instruction, KlondikeInstruction::RotateStock)
|
||||
&& game.stock_cards().is_empty()
|
||||
&& !game.waste_cards().is_empty();
|
||||
|
||||
if !game
|
||||
.session
|
||||
.state()
|
||||
@@ -246,6 +324,11 @@ impl<'de> Deserialize<'de> for GameState {
|
||||
));
|
||||
}
|
||||
game.session.process_instruction(instruction);
|
||||
|
||||
game.is_recycle_history.push(is_recycle);
|
||||
if is_recycle {
|
||||
game.recycle_count = game.recycle_count.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
game.move_count = Self::u32_from_len(game.session.history().len());
|
||||
@@ -277,6 +360,8 @@ impl GameState {
|
||||
take_from_foundation: true,
|
||||
schema_version: GAME_STATE_SCHEMA_VERSION,
|
||||
session: Self::new_session(seed, draw_mode),
|
||||
score_history: Vec::new(),
|
||||
is_recycle_history: Vec::new(),
|
||||
#[cfg(feature = "test-support")]
|
||||
test_pile_state: None,
|
||||
}
|
||||
@@ -295,6 +380,11 @@ impl GameState {
|
||||
}
|
||||
|
||||
fn replay_config(draw_mode: DrawMode) -> KlondikeConfig {
|
||||
// Always allow foundation returns during replay, regardless of the
|
||||
// player's current `take_from_foundation` setting. A move recorded
|
||||
// when the rule was enabled must replay correctly even if the player
|
||||
// later disables it; a restrictive replay config would reject it and
|
||||
// corrupt the save.
|
||||
KlondikeAdapter::config_for(draw_mode, true)
|
||||
}
|
||||
|
||||
@@ -302,7 +392,27 @@ impl GameState {
|
||||
KlondikeAdapter::config_for(self.draw_mode, self.take_from_foundation)
|
||||
}
|
||||
|
||||
fn saved_moves(&self) -> Vec<SavedInstruction> {
|
||||
/// Collects the session instruction history as upstream types for schema v4
|
||||
/// serialisation.
|
||||
fn saved_moves(&self) -> Vec<KlondikeInstruction> {
|
||||
self.session
|
||||
.history()
|
||||
.iter()
|
||||
.map(|snapshot| *snapshot.instruction())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the deterministic instruction history for the current deal as
|
||||
/// legacy mirror types.
|
||||
///
|
||||
/// Combined with [`GameState::seed`] and [`GameState::draw_mode`], this
|
||||
/// sequence is sufficient to replay the game state exactly.
|
||||
///
|
||||
/// Returns [`SavedInstruction`] (u8-index mirror types) for backward
|
||||
/// compatibility with the WASM replay layer and `solitaire_data::ReplayMove`
|
||||
/// format. New code that does not need serde should prefer
|
||||
/// `session().history()` directly.
|
||||
pub fn instruction_history(&self) -> Vec<SavedInstruction> {
|
||||
self.session
|
||||
.history()
|
||||
.iter()
|
||||
@@ -310,14 +420,6 @@ impl GameState {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the deterministic instruction history for the current deal.
|
||||
///
|
||||
/// Combined with [`GameState::seed`] and [`GameState::draw_mode`], this
|
||||
/// sequence is sufficient to replay the game state exactly.
|
||||
pub fn instruction_history(&self) -> Vec<SavedInstruction> {
|
||||
self.saved_moves()
|
||||
}
|
||||
|
||||
fn u32_from_len(len: usize) -> u32 {
|
||||
if len > u32::MAX as usize {
|
||||
u32::MAX
|
||||
@@ -362,6 +464,11 @@ impl GameState {
|
||||
Self::cards_with_face(state.stock().face_up().iter().map(card_from_kl), true)
|
||||
}
|
||||
|
||||
/// Returns the cards in the requested pile.
|
||||
///
|
||||
/// **Note on `KlondikePile::Stock`:** this variant returns the face-up
|
||||
/// *waste* pile, not the face-down draw stack. Use [`Self::stock_cards`]
|
||||
/// to read the face-down draw cards.
|
||||
pub fn pile(&self, pile: KlondikePile) -> Vec<Card> {
|
||||
#[cfg(feature = "test-support")]
|
||||
if let Some(ref state) = self.test_pile_state {
|
||||
@@ -523,6 +630,68 @@ impl GameState {
|
||||
pile.len() > count && !pile[pile.len() - count - 1].face_up
|
||||
}
|
||||
|
||||
/// Returns `(score_delta, is_recycle)` for `instruction` given the *current*
|
||||
/// game state. Must be called **before** the instruction is applied to the
|
||||
/// session; the helper reads pre-instruction pile state from `self`.
|
||||
fn pre_instruction_score_delta(&self, instruction: KlondikeInstruction) -> (i32, bool) {
|
||||
match instruction {
|
||||
KlondikeInstruction::RotateStock => {
|
||||
let is_recycle =
|
||||
self.stock_cards().is_empty() && !self.waste_cards().is_empty();
|
||||
if is_recycle {
|
||||
let next_count = self.recycle_count.saturating_add(1);
|
||||
let penalty = KlondikeAdapter::score_for_recycle_with_mode(
|
||||
next_count,
|
||||
self.draw_mode == DrawMode::DrawThree,
|
||||
self.mode,
|
||||
);
|
||||
(penalty, true)
|
||||
} else {
|
||||
(0, false)
|
||||
}
|
||||
}
|
||||
KlondikeInstruction::DstFoundation(dst_foundation) => {
|
||||
let from = dst_foundation.src;
|
||||
let to = KlondikePile::Foundation(dst_foundation.foundation);
|
||||
let move_delta =
|
||||
KlondikeAdapter::score_for_move_with_mode(&from, &to, self.mode);
|
||||
// DstFoundation always moves exactly 1 card.
|
||||
let flip_bonus = if self.will_flip_tableau_source(from, 1) {
|
||||
KlondikeAdapter::score_for_flip_with_mode(self.mode)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
(move_delta + flip_bonus, false)
|
||||
}
|
||||
KlondikeInstruction::DstTableau(dst_tableau) => {
|
||||
let (from, count) = match dst_tableau.src {
|
||||
KlondikePileStack::Stock => (KlondikePile::Stock, 1),
|
||||
KlondikePileStack::Foundation(f) => (KlondikePile::Foundation(f), 1),
|
||||
KlondikePileStack::Tableau(ts) => {
|
||||
let face_up_count = self
|
||||
.session
|
||||
.state()
|
||||
.state()
|
||||
.state()
|
||||
.tableau_face_up_cards(ts.tableau)
|
||||
.len();
|
||||
let count = face_up_count.saturating_sub(ts.skip_cards as usize);
|
||||
(KlondikePile::Tableau(ts.tableau), count)
|
||||
}
|
||||
};
|
||||
let to = KlondikePile::Tableau(dst_tableau.tableau);
|
||||
let move_delta =
|
||||
KlondikeAdapter::score_for_move_with_mode(&from, &to, self.mode);
|
||||
let flip_bonus = if self.will_flip_tableau_source(from, count) {
|
||||
KlondikeAdapter::score_for_flip_with_mode(self.mode)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
(move_delta + flip_bonus, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn instruction_for_move(
|
||||
&self,
|
||||
from: KlondikePile,
|
||||
@@ -660,19 +829,19 @@ impl GameState {
|
||||
return Err(MoveError::StockEmpty);
|
||||
}
|
||||
|
||||
let recycling = stock_empty && !waste_empty;
|
||||
let (score_delta, is_recycle) =
|
||||
self.pre_instruction_score_delta(KlondikeInstruction::RotateStock);
|
||||
|
||||
self.score_history.push(self.score);
|
||||
self.is_recycle_history.push(is_recycle);
|
||||
|
||||
self.session
|
||||
.process_instruction(KlondikeInstruction::RotateStock);
|
||||
|
||||
if recycling {
|
||||
if is_recycle {
|
||||
self.recycle_count = self.recycle_count.saturating_add(1);
|
||||
let penalty = KlondikeAdapter::score_for_recycle_with_mode(
|
||||
self.recycle_count,
|
||||
self.draw_mode == DrawMode::DrawThree,
|
||||
self.mode,
|
||||
);
|
||||
self.score = (self.score + penalty).max(0);
|
||||
}
|
||||
self.score = (self.score + score_delta).max(0);
|
||||
self.move_count = Self::u32_from_len(self.session.history().len());
|
||||
Ok(())
|
||||
}
|
||||
@@ -712,15 +881,13 @@ impl GameState {
|
||||
return Err(MoveError::RuleViolation("move violates rules".into()));
|
||||
}
|
||||
|
||||
let score_delta = KlondikeAdapter::score_for_move_with_mode(&from, &to, self.mode);
|
||||
let flip_bonus = if self.will_flip_tableau_source(from, count) {
|
||||
KlondikeAdapter::score_for_flip_with_mode(self.mode)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let (score_delta, _) = self.pre_instruction_score_delta(instruction);
|
||||
|
||||
self.score_history.push(self.score);
|
||||
self.is_recycle_history.push(false);
|
||||
|
||||
self.session.process_instruction(instruction);
|
||||
self.score = (self.score + score_delta + flip_bonus).max(0);
|
||||
self.score = (self.score + score_delta).max(0);
|
||||
self.move_count = Self::u32_from_len(self.session.history().len());
|
||||
self.is_won = self.check_win();
|
||||
self.is_auto_completable = !self.is_won && self.check_auto_complete();
|
||||
@@ -740,9 +907,23 @@ impl GameState {
|
||||
if self.session.history().is_empty() {
|
||||
return Err(MoveError::UndoStackEmpty);
|
||||
}
|
||||
let snapshot_score = self.score;
|
||||
|
||||
// Pop the pre-instruction score for the move being undone. Falls back
|
||||
// to self.score (= old behaviour) when score_history is empty, which
|
||||
// happens for moves made before a save/load cycle because undo
|
||||
// penalties aren't reflected in the saved instruction history.
|
||||
let pre_move_score = self.score_history.pop().unwrap_or(self.score);
|
||||
let was_recycle = self.is_recycle_history.pop().unwrap_or(false);
|
||||
|
||||
self.session.undo();
|
||||
self.score = KlondikeAdapter::apply_undo_score(snapshot_score, self.mode);
|
||||
|
||||
if was_recycle {
|
||||
self.recycle_count = self.recycle_count.saturating_sub(1);
|
||||
}
|
||||
// Apply the undo penalty to the pre-move score, not the post-move score.
|
||||
// This correctly reverses any recycle or move penalty that was applied
|
||||
// before adding the −15 undo penalty.
|
||||
self.score = KlondikeAdapter::apply_undo_score(pre_move_score, self.mode);
|
||||
self.move_count = Self::u32_from_len(self.session.history().len());
|
||||
self.is_won = self.check_win();
|
||||
self.is_auto_completable = !self.is_won && self.check_auto_complete();
|
||||
@@ -750,42 +931,15 @@ impl GameState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns `true` when all four foundation slots each contain a valid A→K sequence.
|
||||
/// Returns `true` when all four foundation slots each contain a complete A→K sequence.
|
||||
pub fn check_win(&self) -> bool {
|
||||
(0..4_u8).all(|slot| self.is_valid_foundation_pile(slot))
|
||||
self.session.state().state().is_win()
|
||||
}
|
||||
|
||||
fn is_valid_foundation_pile(&self, slot: u8) -> bool {
|
||||
let Ok(pile) = self.foundation_cards(slot) else {
|
||||
return false;
|
||||
};
|
||||
if pile.len() != 13 {
|
||||
return false;
|
||||
}
|
||||
let suit = pile[0].suit;
|
||||
pile.iter()
|
||||
.enumerate()
|
||||
.all(|(i, card)| card.suit == suit && card.rank.value() == i as u8 + 1)
|
||||
}
|
||||
|
||||
/// Returns `true` when stock and waste are empty and all tableau cards are face-up.
|
||||
/// Returns `true` when the game can be completed without further player input
|
||||
/// (stock empty, waste empty, all tableau cards face-up).
|
||||
pub fn check_auto_complete(&self) -> bool {
|
||||
if !self.stock_cards().is_empty() {
|
||||
return false;
|
||||
}
|
||||
if !self.waste_cards().is_empty() {
|
||||
return false;
|
||||
}
|
||||
(0..7).all(|index| {
|
||||
Self::tableau_from_index(index)
|
||||
.ok()
|
||||
.map(|tableau| {
|
||||
self.pile(KlondikePile::Tableau(tableau))
|
||||
.iter()
|
||||
.all(|card| card.face_up)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
self.session.state().state().is_win_trivial()
|
||||
}
|
||||
|
||||
/// Returns all currently valid `(from, to, count)` moves.
|
||||
@@ -939,6 +1093,77 @@ mod tests {
|
||||
None
|
||||
}
|
||||
|
||||
/// Drive a DrawOne game until a recycle is available, perform it, and return
|
||||
/// the game. Returns `None` if no recycle position is found within the
|
||||
/// iteration limit (shouldn't happen in practice).
|
||||
fn game_at_first_recycle() -> Option<GameState> {
|
||||
for seed in 1..=256_u64 {
|
||||
let mut game = GameState::new(seed, DrawMode::DrawOne);
|
||||
for _ in 0..200 {
|
||||
if game.stock_cards().is_empty() && !game.waste_cards().is_empty() {
|
||||
// This draw will recycle.
|
||||
game.draw().ok()?;
|
||||
return Some(game);
|
||||
}
|
||||
let _ = game.draw();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycle_count_decrements_when_recycle_is_undone() {
|
||||
let mut game = game_at_first_recycle().expect("could not reach recycle");
|
||||
let count_after_recycle = game.recycle_count;
|
||||
assert_eq!(count_after_recycle, 1, "first recycle should give count=1");
|
||||
game.undo().expect("undo should succeed");
|
||||
assert_eq!(
|
||||
game.recycle_count, 0,
|
||||
"recycle_count must decrement back to 0 after undoing the recycle",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_recycle_penalty_is_reversed_on_undo() {
|
||||
// Reach the second recycle (count=2, Draw-1) so there is a −100 penalty.
|
||||
let mut game = game_at_first_recycle().expect("could not reach first recycle");
|
||||
|
||||
// Draw until stock is empty again so we can do a second recycle.
|
||||
let mut second_recycle_done = false;
|
||||
for _ in 0..200 {
|
||||
if game.stock_cards().is_empty() && !game.waste_cards().is_empty() {
|
||||
let score_before_second_recycle = game.score;
|
||||
game.draw().expect("second recycle should succeed");
|
||||
assert_eq!(game.recycle_count, 2);
|
||||
|
||||
// The second recycle in Draw-1 mode costs −100.
|
||||
let expected_after = (score_before_second_recycle - 100).max(0);
|
||||
assert_eq!(
|
||||
game.score, expected_after,
|
||||
"second Draw-1 recycle must apply −100 penalty",
|
||||
);
|
||||
|
||||
// Undo: score should recover to (score_before_second_recycle − 15).max(0),
|
||||
// NOT to (score_after_recycle − 15).max(0).
|
||||
game.undo().expect("undo of second recycle should succeed");
|
||||
let expected_after_undo = (score_before_second_recycle - 15).max(0);
|
||||
assert_eq!(
|
||||
game.score, expected_after_undo,
|
||||
"undoing a penalised recycle must reverse the recycle penalty \
|
||||
before applying the −15 undo penalty",
|
||||
);
|
||||
assert_eq!(
|
||||
game.recycle_count, 1,
|
||||
"recycle_count must also be decremented on undo",
|
||||
);
|
||||
second_recycle_done = true;
|
||||
break;
|
||||
}
|
||||
let _ = game.draw();
|
||||
}
|
||||
assert!(second_recycle_done, "could not reach second recycle in test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_from_foundation_allows_legal_return_move() {
|
||||
let (mut game, from, to) = find_foundation_return_position()
|
||||
|
||||
@@ -223,17 +223,24 @@ pub fn card_from_kl(kl_card: &KlCard) -> card::Card {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Serde newtypes for KlondikeInstruction (Step 7) ──────────────────────────
|
||||
// ── Legacy serde mirror types (kept for backward compatibility) ───────────────
|
||||
//
|
||||
// `klondike::KlondikeInstruction` (and its sub-types) do not derive
|
||||
// `Serialize` / `Deserialize`. These mirror types carry `#[serde]` so that
|
||||
// the session instruction history can be persisted and reconstructed without
|
||||
// upstream changes.
|
||||
// These types were introduced when upstream `klondike` had no serde feature.
|
||||
// At rev 99b49e62, upstream provides full serde support, and `GameState`
|
||||
// serialises `saved_moves` directly as `Vec<KlondikeInstruction>` (schema v4).
|
||||
//
|
||||
// Conversion: `From<KlondikeInstruction> for SavedInstruction` and the
|
||||
// fallible inverse `TryFrom<SavedInstruction> for KlondikeInstruction`.
|
||||
// Invalid numeric values (out-of-range u8 for tableau/foundation/skip) yield
|
||||
// `InvalidSavedInstruction`.
|
||||
// The mirror types are retained for three reasons:
|
||||
// 1. Schema v3 migration: `AnyInstruction` in `game_state.rs` uses
|
||||
// `TryFrom<SavedInstruction> for KlondikeInstruction` to parse old save
|
||||
// files with u8 indices and replay them.
|
||||
// 2. `solitaire_data::ReplayMove` uses `SavedKlondikePile` as its serde
|
||||
// type; changing it would break the on-disk replay format (schema v2).
|
||||
// 3. `solitaire_wasm` mirrors `ReplayMove` using the same types so that
|
||||
// replay JSON is cross-compatible between the desktop and browser builds.
|
||||
//
|
||||
// These types should not be used for new serialisation concerns. If the
|
||||
// ReplayMove format is ever bumped to a new schema, migrate those callers to
|
||||
// `KlondikePile` / `KlondikePileStack` and the types here can then be deleted.
|
||||
|
||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::Tableau`] (0 = Tableau1 … 6 = Tableau7).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -6,8 +6,13 @@ pub mod klondike_adapter;
|
||||
pub mod pile;
|
||||
pub mod solver;
|
||||
|
||||
// Re-export upstream types that cross the solitaire_core API boundary so
|
||||
// callers can import from one place without a direct `klondike` / `card_game` dep.
|
||||
// Re-export the upstream types that cross the solitaire_core API boundary so
|
||||
// downstream crates (engine, wasm) can import from one place without a direct
|
||||
// `klondike` / `card_game` dep.
|
||||
//
|
||||
// `KlondikePileStack`, `SkipCards`, and `TableauStack` are intentionally NOT
|
||||
// re-exported — they are only used internally in `klondike_adapter.rs` and do
|
||||
// not appear in any public method signature.
|
||||
pub use card_game::Session;
|
||||
pub use klondike::{Foundation, Klondike, KlondikePile, Tableau};
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
use klondike::{Foundation, KlondikePile, Tableau};
|
||||
use card_game::Game;
|
||||
use klondike::{Foundation, KlondikePile, KlondikeInstruction, SkipCards, Tableau};
|
||||
use proptest::prelude::*;
|
||||
|
||||
use crate::game_state::{DrawMode, GameState};
|
||||
use crate::klondike_adapter::{
|
||||
InvalidSavedInstruction, SavedDstFoundation, SavedDstTableau, SavedFoundation,
|
||||
SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, SavedSkipCards, SavedTableau,
|
||||
SavedTableauStack,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
@@ -102,6 +108,52 @@ fn apply_one_move(game: &mut GameState, move_idx: usize) -> bool {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
proptest! {
|
||||
/// `check_auto_complete()` and `is_win_trivial()` must agree on every
|
||||
/// reachable game state.
|
||||
///
|
||||
/// The upstream `Klondike::is_win_trivial()` checks that the stock pile
|
||||
/// (both face-down and face-up halves) is completely empty AND that all
|
||||
/// tableau columns have no face-down cards. Ferrous `check_auto_complete()`
|
||||
/// checks the same three conditions individually (stock empty, waste empty,
|
||||
/// all tableau cards face-up). This property guards against any semantic
|
||||
/// drift between the two implementations so that delegating to upstream is
|
||||
/// safe.
|
||||
///
|
||||
/// If this property ever fails, `check_auto_complete()` must NOT be fully
|
||||
/// replaced — the Ferrous conditions must be preserved and `is_win_trivial()`
|
||||
/// used only as a supplementary guard.
|
||||
#[test]
|
||||
fn check_auto_complete_agrees_with_is_win_trivial(
|
||||
seed in any::<u64>(),
|
||||
draw_mode in draw_mode_strategy(),
|
||||
actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..30),
|
||||
) {
|
||||
let mut game = GameState::new(seed, draw_mode);
|
||||
apply_random_actions(&mut game, &actions);
|
||||
prop_assert_eq!(
|
||||
game.check_auto_complete(),
|
||||
game.session().state().state().is_win_trivial(),
|
||||
"check_auto_complete() disagreed with is_win_trivial() after {:?} actions",
|
||||
actions.len(),
|
||||
);
|
||||
}
|
||||
|
||||
/// `check_win()` and `is_win()` must agree on every reachable game state.
|
||||
#[test]
|
||||
fn check_win_agrees_with_is_win(
|
||||
seed in any::<u64>(),
|
||||
draw_mode in draw_mode_strategy(),
|
||||
actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..30),
|
||||
) {
|
||||
let mut game = GameState::new(seed, draw_mode);
|
||||
apply_random_actions(&mut game, &actions);
|
||||
prop_assert_eq!(
|
||||
game.check_win(),
|
||||
game.session().state().state().is_win(),
|
||||
"check_win() disagreed with is_win()",
|
||||
);
|
||||
}
|
||||
|
||||
/// All 52 card IDs must be present exactly once across every pile after
|
||||
/// any reachable sequence of draw + move_cards actions.
|
||||
///
|
||||
@@ -221,4 +273,117 @@ proptest! {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SavedInstruction ↔ KlondikeInstruction round-trip
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Every valid `SavedInstruction` survives a round-trip through
|
||||
/// `KlondikeInstruction::try_from(SavedInstruction::from(original))`.
|
||||
///
|
||||
/// Covers all three variants (`RotateStock`, `DstFoundation`, `DstTableau`)
|
||||
/// and all legal sub-field ranges:
|
||||
/// - `SavedTableau`: 0–6
|
||||
/// - `SavedFoundation`: 0–3
|
||||
/// - `SavedSkipCards`: 0–12
|
||||
#[test]
|
||||
fn saved_instruction_round_trip(
|
||||
instruction in saved_instruction_strategy(),
|
||||
) {
|
||||
let klondike = KlondikeInstruction::try_from(instruction);
|
||||
prop_assert!(
|
||||
klondike.is_ok(),
|
||||
"TryFrom failed for valid SavedInstruction {instruction:?}: {:?}",
|
||||
klondike.err(),
|
||||
);
|
||||
let saved_again = SavedInstruction::from(klondike.expect("checked above"));
|
||||
prop_assert_eq!(
|
||||
saved_again,
|
||||
instruction,
|
||||
"round-trip produced a different SavedInstruction",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Proptest strategies for SavedInstruction and its sub-types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn saved_tableau_strategy() -> impl Strategy<Value = SavedTableau> {
|
||||
(0u8..=6).prop_map(SavedTableau)
|
||||
}
|
||||
|
||||
fn saved_foundation_strategy() -> impl Strategy<Value = SavedFoundation> {
|
||||
(0u8..=3).prop_map(SavedFoundation)
|
||||
}
|
||||
|
||||
fn saved_skip_cards_strategy() -> impl Strategy<Value = SavedSkipCards> {
|
||||
(0u8..=12).prop_map(SavedSkipCards)
|
||||
}
|
||||
|
||||
fn saved_klondike_pile_strategy() -> impl Strategy<Value = SavedKlondikePile> {
|
||||
prop_oneof![
|
||||
saved_tableau_strategy().prop_map(SavedKlondikePile::Tableau),
|
||||
Just(SavedKlondikePile::Stock),
|
||||
saved_foundation_strategy().prop_map(SavedKlondikePile::Foundation),
|
||||
]
|
||||
}
|
||||
|
||||
fn saved_klondike_pile_stack_strategy() -> impl Strategy<Value = SavedKlondikePileStack> {
|
||||
prop_oneof![
|
||||
(saved_tableau_strategy(), saved_skip_cards_strategy()).prop_map(|(tableau, skip_cards)| {
|
||||
SavedKlondikePileStack::Tableau(SavedTableauStack { tableau, skip_cards })
|
||||
}),
|
||||
Just(SavedKlondikePileStack::Stock),
|
||||
saved_foundation_strategy().prop_map(SavedKlondikePileStack::Foundation),
|
||||
]
|
||||
}
|
||||
|
||||
fn saved_instruction_strategy() -> impl Strategy<Value = SavedInstruction> {
|
||||
prop_oneof![
|
||||
Just(SavedInstruction::RotateStock),
|
||||
(saved_klondike_pile_strategy(), saved_foundation_strategy()).prop_map(
|
||||
|(src, foundation)| {
|
||||
SavedInstruction::DstFoundation(SavedDstFoundation { src, foundation })
|
||||
}
|
||||
),
|
||||
(saved_klondike_pile_stack_strategy(), saved_tableau_strategy()).prop_map(
|
||||
|(src, tableau)| {
|
||||
SavedInstruction::DstTableau(SavedDstTableau { src, tableau })
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Boundary error unit tests (exact out-of-range values)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod saved_instruction_boundary_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn saved_tableau_7_is_invalid() {
|
||||
let result = Tableau::try_from(SavedTableau(7));
|
||||
assert_eq!(result, Err(InvalidSavedInstruction::Tableau(7)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_tableau_255_is_invalid() {
|
||||
let result = Tableau::try_from(SavedTableau(255));
|
||||
assert_eq!(result, Err(InvalidSavedInstruction::Tableau(255)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_foundation_4_is_invalid() {
|
||||
let result = Foundation::try_from(SavedFoundation(4));
|
||||
assert_eq!(result, Err(InvalidSavedInstruction::Foundation(4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_skip_cards_13_is_invalid() {
|
||||
let result = SkipCards::try_from(SavedSkipCards(13));
|
||||
assert_eq!(result, Err(InvalidSavedInstruction::SkipCards(13)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,6 +495,154 @@ mod tests {
|
||||
assert_eq!(loaded, StatsSnapshot::default());
|
||||
}
|
||||
|
||||
/// Schema v4 serialises the instruction history using upstream
|
||||
/// `KlondikeInstruction` serde (named enum variants). The deserialiser
|
||||
/// replays all `saved_moves` to reconstruct every pile.
|
||||
///
|
||||
/// A fresh-game test (zero moves) never exercises that replay path, so this
|
||||
/// test plays several real moves — including an undo — before saving, then
|
||||
/// asserts the full pile layout round-trips exactly.
|
||||
///
|
||||
/// `GameState::PartialEq` covers stock, waste, all four foundations, all
|
||||
/// seven tableau columns, `score`, `move_count`, `undo_count`, and
|
||||
/// `recycle_count`. Any breakage in the upstream serde or replay path
|
||||
/// will cause at least one pile to disagree.
|
||||
#[test]
|
||||
fn game_state_v4_mid_game_round_trip() {
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_core::game_state::{DrawMode, GameState, GAME_STATE_SCHEMA_VERSION};
|
||||
|
||||
let path = gs_path("v4_mid_game");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
let mut gs = GameState::new(42, DrawMode::DrawOne);
|
||||
|
||||
// Draw several times to populate the instruction history with
|
||||
// RotateStock entries and expose waste cards for further moves.
|
||||
for _ in 0..6 {
|
||||
if gs.draw().is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the first available DstTableau or DstFoundation move so the
|
||||
// instruction history contains a type other than RotateStock.
|
||||
let moves = gs.possible_instructions();
|
||||
if let Some((from, to, count)) = moves.iter().copied().find(|(_, to, _)| {
|
||||
matches!(to, KlondikePile::Tableau(_) | KlondikePile::Foundation(_))
|
||||
}) {
|
||||
let _ = gs.move_cards(from, to, count);
|
||||
}
|
||||
|
||||
// Undo once: verifies that `undo_count` is persisted and that the
|
||||
// truncated history (post-undo) replays back to the correct state.
|
||||
if gs.undo_stack_len() > 0 {
|
||||
let _ = gs.undo();
|
||||
}
|
||||
|
||||
assert!(
|
||||
gs.undo_stack_len() > 0,
|
||||
"instruction history must be non-empty (seed 42 always produces draws)",
|
||||
);
|
||||
|
||||
save_game_state_to(&path, &gs).expect("save");
|
||||
|
||||
// Verify the file contains the v4 schema marker (tolerates pretty-print whitespace).
|
||||
let json = fs::read_to_string(&path).expect("read json");
|
||||
assert!(
|
||||
json.contains("schema_version") && json.contains('4') && !json.contains(": 3"),
|
||||
"saved file must use schema version 4",
|
||||
);
|
||||
|
||||
let loaded = load_game_state_from(&path)
|
||||
.expect("a valid in-progress game must load without error");
|
||||
|
||||
assert_eq!(loaded.schema_version, GAME_STATE_SCHEMA_VERSION);
|
||||
assert_eq!(
|
||||
loaded, gs,
|
||||
"all pile layouts and counters must be identical after schema-v4 round-trip",
|
||||
);
|
||||
}
|
||||
|
||||
/// A schema v3 save (instruction history using u8 indices) must load
|
||||
/// successfully and be transparently migrated to schema v4.
|
||||
///
|
||||
/// This verifies the `AnyInstruction` untagged deserialization migration
|
||||
/// path. v3 files with `RotateStock` (unit variant, format-identical in
|
||||
/// v3 and v4) load correctly and report `schema_version == 4` after load.
|
||||
/// The `SavedInstruction` boundary tests in `proptest_tests.rs` cover the
|
||||
/// u8-to-named conversion for `DstFoundation` / `DstTableau` indices.
|
||||
#[test]
|
||||
fn game_state_v3_migrates_to_v4() {
|
||||
use solitaire_core::game_state::{DrawMode, GameState, GAME_STATE_SCHEMA_VERSION};
|
||||
|
||||
let path = gs_path("v3_migrate");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
// Hand-crafted schema v3 JSON: one RotateStock (draw) instruction.
|
||||
// RotateStock serialises as the string "RotateStock" in both v3 and v4,
|
||||
// so this exercises the schema version acceptance code path.
|
||||
let v3_json = r#"{
|
||||
"draw_mode": "DrawOne",
|
||||
"mode": "Classic",
|
||||
"score": 0,
|
||||
"elapsed_seconds": 0,
|
||||
"seed": 42,
|
||||
"undo_count": 0,
|
||||
"recycle_count": 0,
|
||||
"take_from_foundation": true,
|
||||
"schema_version": 3,
|
||||
"saved_moves": ["RotateStock"]
|
||||
}"#;
|
||||
fs::write(&path, v3_json).expect("write v3 fixture");
|
||||
|
||||
let loaded = load_game_state_from(&path)
|
||||
.expect("schema v3 must be accepted and migrated to v4");
|
||||
|
||||
// After migration, the in-memory schema version must be current.
|
||||
assert_eq!(
|
||||
loaded.schema_version, GAME_STATE_SCHEMA_VERSION,
|
||||
"migrated game must report current schema version",
|
||||
);
|
||||
|
||||
// The loaded game should match a fresh game that had one draw applied.
|
||||
let mut expected = GameState::new(42, DrawMode::DrawOne);
|
||||
expected.draw().expect("draw must succeed on a fresh game");
|
||||
assert_eq!(loaded, expected, "migrated v3 game state must match equivalent v4 state");
|
||||
}
|
||||
|
||||
/// Schema v2 stored raw pile arrays and undo snapshots (no instruction
|
||||
/// history). Any file claiming `schema_version: 2` must be rejected so
|
||||
/// players upgrading from an older build start with a fresh game rather
|
||||
/// than a half-reconstructed state.
|
||||
#[test]
|
||||
fn save_format_v2_is_rejected() {
|
||||
let path = gs_path("schema_v2");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
// Structurally valid JSON for `PersistedGameState` but with
|
||||
// `schema_version: 2`. The schema-version gate in
|
||||
// `GameState::deserialize` must reject this before replay starts.
|
||||
let v2_json = r#"{
|
||||
"draw_mode": "DrawOne",
|
||||
"mode": "Classic",
|
||||
"score": 0,
|
||||
"elapsed_seconds": 0,
|
||||
"seed": 42,
|
||||
"undo_count": 0,
|
||||
"recycle_count": 0,
|
||||
"take_from_foundation": true,
|
||||
"schema_version": 2,
|
||||
"saved_moves": []
|
||||
}"#;
|
||||
fs::write(&path, v2_json).expect("write v2 fixture");
|
||||
|
||||
assert!(
|
||||
load_game_state_from(&path).is_none(),
|
||||
"schema v2 game_state.json must be rejected — player must start a fresh game",
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Time Attack session persistence
|
||||
//
|
||||
|
||||
@@ -147,8 +147,13 @@ fn apply_safe_area_bottom_anchors(
|
||||
}
|
||||
}
|
||||
|
||||
/// Pads the bottom of every [`ModalScrim`] by the logical bottom inset so
|
||||
/// modal cards don't extend into the Android gesture-navigation zone.
|
||||
/// Pads both edges of every [`ModalScrim`] by the logical system-bar insets so
|
||||
/// modal cards are centred within the usable area (between the status bar at
|
||||
/// the top and the gesture-navigation bar at the bottom).
|
||||
///
|
||||
/// `padding.top` = status-bar inset; `padding.bottom` = gesture-bar inset.
|
||||
/// With `align_items: Center` / `justify_content: Center` on the scrim the
|
||||
/// `ModalCard` lands at the visual midpoint of the visible content area.
|
||||
///
|
||||
/// Fires when [`SafeAreaInsets`] changes (covers the common case of insets
|
||||
/// arriving a few frames after app start) AND when a new `ModalScrim` is
|
||||
@@ -165,8 +170,18 @@ fn apply_safe_area_to_modal_scrims(
|
||||
}
|
||||
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
||||
let window_height = windows.iter().next().map_or(800.0, |w| w.height());
|
||||
// Clamp each inset to 25% of screen height so an unexpectedly large OS
|
||||
// value can't push the modal card off the visible area entirely.
|
||||
let top_logical = (insets.top / scale).min(window_height * 0.25);
|
||||
let bottom_logical = (insets.bottom / scale).min(window_height * 0.25);
|
||||
for mut node in &mut scrims {
|
||||
// Set both edges so the scrim's content box equals the usable area
|
||||
// between the status bar and the gesture/navigation bar. With
|
||||
// `align_items: Center` / `justify_content: Center` on the scrim,
|
||||
// the modal card is centred within that usable region rather than
|
||||
// the full viewport, correcting the slight upward shift seen when
|
||||
// only the bottom inset was applied.
|
||||
node.padding.top = Val::Px(top_logical);
|
||||
node.padding.bottom = Val::Px(bottom_logical);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user