Compare commits

...

29 Commits

Author SHA1 Message Date
funman300 3388169329 fix(engine): platform-correct onboarding copy; Home waits for first-run onboarding (Phase H)
Test / fmt (pull_request) Successful in 4s
Test / test (pull_request) Successful in 4m29s
- The how-to-play slide told touch players to left/right-click; Android
  now gets tap/double-tap copy pointing at the bottom-bar Hint button.
- On a fresh profile the Home auto-show spawned underneath the
  onboarding modal, stacking two scrims (v0.44.0 emulator smoke
  finding). spawn_home_on_launch now waits until first_run_complete
  and the onboarding modal is gone, so the launch beat is onboarding,
  then Home, then the table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:52:16 -07:00
funman300 0e07e1d1ad refactor(engine): unify toasts into one bottom-anchored stack (Phase H)
Queued and immediate toasts used two subtly different geometries and
staggered anchors purely to dodge each other when simultaneous. Both
paths now spawn geometry-free ToastNodes that adopt_toasts_into_stack
slots into a single persistent ToastStackRoot flex column — one anchor,
one style, simultaneous toasts stack upward. The root rides
SafeAreaAnchoredBottom and clears the Phase F touch action bar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:48:46 -07:00
funman300 83f37bada6 docs(changelog): cut 0.45.0 — Phase F touch action bar
Android Release / build-apk (push) Successful in 6m6s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:22:33 -07:00
funman300 d989d07fe7 Merge pull request 'feat(engine): Phase F touch action bar — thumb-reach Undo/Draw/Hint + hold-to-repeat undo' (#177) from feat/bottom-action-bar into master
Test / fmt (push) Successful in 4s
Test / test (push) Successful in 4m24s
Build and Deploy / build-and-push (push) Successful in 8m53s
Web E2E / web-e2e (push) Successful in 8m3s
2026-07-13 23:22:15 +00:00
funman300 fa54c58bc4 feat(engine): Phase F touch action bar — thumb-reach Undo/Draw/Hint + hold-to-repeat undo
Test / fmt (pull_request) Successful in 5s
Test / test (pull_request) Successful in 4m25s
Touch layout (USE_TOUCH_UI_LAYOUT) restructures the bottom action bar
to five buttons: an enlarged Undo / Draw / Hint trio (96x64px targets,
1.35x labels) between compact Menu and Pause. Draw is new — it fires
the same DrawRequestEvent as tapping the stock, so the most frequent
action no longer needs a reach to the top of a tall folded screen.
Help, Modes, and New Game leave the touch bar (they live in Menu ->
System, the Home grid, and Home's hero respectively). Desktop keeps
the seven-button bar unchanged (decision 5: touch-only).

Holding Undo now steps back repeatedly after a 0.45s delay (5.5/s),
each step through the normal request path so the scoring penalty
applies. New self-ambiguous DrawRequestWriters set keeps the ambiguity
gate at zero with the fourth DrawRequestEvent writer.

6 new hud_plugin tests; workspace suite + clippy green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:07:10 -07:00
funman300 dd304913df docs(ui): record Phase F decision 5 — bottom action bar is touch-only
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:49:15 -07:00
funman300 48ad6b6618 docs(changelog): cut 0.44.0 — Phase B home hierarchy
Android Release / build-apk (push) Successful in 7m48s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:16:27 -07:00
funman300 81ac4a5383 Merge pull request 'ci(test): host-persistent target dir; fast fmt gate; drop dead rust-cache' (#176) from ci/host-persistent-target into master
Test / fmt (push) Successful in 4s
Test / test (push) Successful in 4m27s
2026-07-13 22:14:39 +00:00
funman300 f51ee7b234 Merge pull request 'feat(engine): Phase B home hierarchy — Continue card, hero New Game, deal-options disclosure' (#175) from feat/home-hierarchy into master
Build and Deploy / build-and-push (push) Successful in 9m11s
Web E2E / web-e2e (push) Successful in 8m30s
Test / test (push) Successful in 37m46s
2026-07-13 20:55:37 +00:00
funman300 d593b61af8 ci(test): host-persistent target dir; fast fmt gate; drop dead rust-cache
Test / fmt (pull_request) Successful in 5s
Test / test (pull_request) Successful in 33m27s
The Gitea actions cache on this instance stores caches (1.85 GB saves
confirmed in run 597) but never restores them — every run back through
run 584+ logs 'No cache found' even for exact keys saved an hour
earlier, including master-to-master. Every CI run has therefore been a
full cold build (~37 min), plus ~4 min tarring a cache nobody reads.

- Point CARGO_TARGET_DIR at a persistent path on the rust-host runner
  (host executor — filesystem carries over between runs), with a 40 GiB
  prune guard. Warm runs drop to minutes without touching the broken
  cache API.
- Drop Swatinem/rust-cache (pure overhead until the server is fixed).
- Split cargo fmt --check into a seconds-long fmt job gating the heavy
  test job, so a formatting slip can't burn a 35-minute build again
  (run 600).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 13:53:34 -07:00
funman300 fc87b13e5b style: cargo fmt
Test / test (pull_request) Successful in 37m46s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 13:11:54 -07:00
funman300 560470b86b feat(engine): Phase B home hierarchy — Continue card, hero New Game, deal-options disclosure
Test / test (pull_request) Failing after 10s
Restructures the Home overlay per docs/ui-redesign-2026-07.md Phase B:

- Continue card (mode / elapsed / score) when a game is in progress;
  clicking returns to the table
- Hero New Game replays the persisted Settings::last_mode (new field,
  serde-default Classic) with one tap; locked modes fall back to Classic
- Deal options (draw 1/3, winnable-only, difficulty tiers) move into a
  disclosure under the hero — off the Home top level (decision 2)
- Compact symmetric 2x3 mode grid; descriptions on wide viewports only
- Stats strip moves to the bottom (right pane on wide)
- Two-pane body on wide viewports (>= 1000 logical px) via new
  ui_modal::spawn_modal_sized (default card stays 720 px)
- Cancel becomes "Back to table" and only renders while a live
  (un-won) game exists

10 new home_plugin tests; all 936 engine tests + clippy green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 13:08:07 -07:00
funman300 1fded5ff16 docs(ui): record Phase B decisions 2-4 (deal-options disclosure, top-level Time Attack/Seed cards, in-engine iteration)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 12:40:17 -07:00
funman300 65585c61ad Merge pull request 'test(e2e): read v4 recording.instructions in cycle regression gate' (#174) from fix/cycle-metrics-v4-recording into master
Build and Deploy / build-and-push (push) Successful in 2m42s
Web E2E / web-e2e (push) Successful in 9m33s
2026-07-10 21:56:13 +00:00
funman300 0583a8ffae test(e2e): read v4 recording.instructions in cycle regression gate
Test / test (pull_request) Successful in 36m28s
The cycle regression gate still read payload.moves, which schema v4
removed in favour of recording.instructions, so every game reported
replay_history_mismatch:0/N and games_with_issues tripped the
--require-zero-issues gate on master (runs 587/594/596).

Verified locally: 12-game gate run reports 0 issues.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:18:35 -07:00
funman300 4d9d710a02 Merge pull request 'test(e2e): update replay payload specs to schema v4' (#172) from fix/web-e2e-replay-v4-shape into master
Build and Deploy / build-and-push (push) Successful in 2m32s
Web E2E / web-e2e (push) Failing after 9m10s
2026-07-10 21:01:27 +00:00
funman300 3fbee9ce30 Merge pull request 'fix(core): save files store the deal via upstream serializers (schema v6)' (#171) from fix/save-schema-v6-session-recording into master
Build and Deploy / build-and-push (push) Successful in 10m35s
Web E2E / web-e2e (push) Failing after 9m21s
Test / test (push) Successful in 36m35s
2026-07-10 18:12:28 +00:00
funman300 fd98f46267 test(e2e): update replay payload specs to schema v4
Test / test (pull_request) Successful in 37m24s
PR #170 changed the web replay payload (moves list -> embedded session
recording) but missed these Playwright specs, which only run on master
pushes and so failed post-merge. Assertions now match the v4 shape:
schema_version 4, recording.initial_state present, moves at
recording.instructions.

Verified locally against a real server with freshly built wasm bundles:
full suite 18/18 green, including the five play_canvas specs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 11:07:08 -07:00
funman300 07044f439c fix(core): save files store the deal via upstream serializers (schema v6)
Test / test (pull_request) Successful in 36m38s
GameState's save serde had the same latent flaw PR #170 removed from
replays: v5 persisted seed + saved_moves and re-dealt the board from the
seed on load, so any RNG or upstream upgrade that shifts the seed->deal
mapping would invalidate every in-progress save (graceful error, but the
player loses their game). v6 persists the existing SessionRecording
payload (upstream card_game session serde: config + dealt board +
instructions) instead; seed stays as presentation metadata only.

- v4/v5 files still load through the legacy seed path and are rewritten
  as v6 on the next save; v6 files load from the recording and replay
  with per-instruction validation, so tampered or corrupt files surface
  an error rather than a silently wrong board
- saved_moves() removed (dead once serialisation reads recording())
- tests: v6 round-trip via storage, seed-corruption immunity, v5 legacy
  load, missing-recording rejection, invalid-history rejection

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:21:01 -07:00
funman300 c60d465711 docs(changelog): cut 0.43.3 — replay schema v4
Android Release / build-apk (push) Successful in 7m6s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:57:04 -07:00
funman300 ce2b29f5df Merge pull request 'fix(replay): store the deal via upstream card_game serializers (schema v4)' (#170) from fix/replay-schema-v4-session-recording into master
Build and Deploy / build-and-push (push) Successful in 11m32s
Web E2E / web-e2e (push) Failing after 8m58s
Test / test (push) Successful in 38m3s
2026-07-10 16:56:33 +00:00
funman300 4cb4212829 fix(replay): store the deal via upstream card_game serializers (schema v4)
Test / test (pull_request) Successful in 36m34s
Replays previously persisted only seed + moves and re-dealt the board
from the seed at playback time, so any change to the seed->deal mapping
(RNG bumps, upstream upgrades) silently invalidated every existing
replay. Schema v4 instead embeds a SessionRecording - the upstream
card_game Session serde ({config, initial_state, instructions}) - so
playback rebuilds the exact recorded board; seed/draw_mode/mode remain
caption metadata only.

- core: SessionRecording newtype delegating to Session<Klondike> serde;
  GameState::recording() / from_recording(); from_instructions_unchecked
  fixture helper; serde_json added to dev-deps (tests only)
- data: Replay v4 (recording replaces moves); v1-v3 files rejected by
  the existing version gate
- engine: win-recording and sync upload freeze game.recording();
  playback rebuilds from the recording; Playing carries the extracted
  move list (+ Box<Replay> for clippy large_enum_variant)
- wasm: replay_export() builds the full v4 upload payload so JS never
  hand-assembles it (the old game.js path hardcoded schema_version: 2
  and corrupted u64 seeds via Math.round); ReplayPlayer::from_json
  enforces schema_version == 4 with a descriptive error
- web: game.js/play.html use replay_export; replay.js surfaces player
  construction errors in the caption instead of dying silently
- server: mode validation accepts data-carrying GameMode variants
  (Difficulty uploads previously 400'd against the String field)

Both replays on prod are May-era v1 rows with empty move lists - every
shared replay was already unplayable; the viewer now says why.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:10:38 -07:00
funman300 fce0266b47 Merge pull request 'chore: delete dead code approved from the PR #166 sweep' (#169) from chore/dead-code-removals into master
Build and Deploy / build-and-push (push) Successful in 11m15s
Web E2E / web-e2e (push) Successful in 9m32s
Android Release / build-apk (push) Successful in 5m18s
Test / test (push) Successful in 36m51s
2026-07-09 23:46:47 +00:00
funman300 8d80f95bd0 chore(engine): apply rustfmt after dead-code removals
Test / test (pull_request) Successful in 35m45s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 15:02:15 -07:00
funman300 48f0907f78 Merge remote-tracking branch 'origin/master' into chore/dead-code-removals 2026-07-09 14:59:29 -07:00
funman300 dda6a25439 Merge pull request 'fix(engine): double-click moves exactly the clicked run (#158)' (#167) from fix/158-double-click-exact-stack into master
Build and Deploy / build-and-push (push) Successful in 8m53s
Web E2E / web-e2e (push) Successful in 9m13s
Test / test (push) Successful in 35m35s
2026-07-09 21:58:15 +00:00
funman300 1aad3251ba Merge pull request 'ci(web): build wasm in the deploy pipeline instead of committing it (#156)' (#168) from fix/156-wasm-in-ci into master
Build and Deploy / build-and-push (push) Successful in 11m35s
Web E2E / web-e2e (push) Successful in 9m15s
Test / test (push) Successful in 37m3s
2026-07-09 21:57:56 +00:00
funman300 b26200f948 chore: delete dead code approved from the PR #166 sweep
Test / test (pull_request) Failing after 18s
Three items the multi-agent sweep flagged, now removed with user
approval (§8 for the solitaire_sync changes):

- WinCascadePlugin: never registered; handle_win_cascade in
  AnimationPlugin is the live win cascade and builds its own targets.
  Its now-orphaned helpers (win_scatter_targets, cascade_delay,
  WIN_CASCADE_INTERVAL_SECS) had no callers outside their own tests
  and go with it.
- SyncCompleteEvent: written by the pull-completion system, zero
  readers — UI reads SyncStatusResource instead.
- solitaire_sync::ApiError: unused by client and server; the merge_at
  crate-root re-export goes too (merge::merge_at stays for the merge
  module's own use).

ARCHITECTURE.md updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 13:28:09 -07:00
funman300 adbcb8f59a fix(engine): double-click moves exactly the clicked run (#158)
Test / test (pull_request) Successful in 36m48s
Double-click/tap auto-move previously tried the run's top card alone
before the full run headed by the clicked card, so a top card with a
foundation move hijacked the intended whole-stack move. Both handlers
now share auto_move_for_run: a lone top card goes foundation-first, a
multi-card run moves whole to a tableau or not at all. The double-click
key is now the clicked card, so two clicks on different cards of the
same stack no longer register as a double-click.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 12:55:43 -07:00
46 changed files with 3090 additions and 1439 deletions
+56 -7
View File
@@ -2,6 +2,19 @@
# locally, run on every master push and pull request. Until this workflow
# existed, nothing in CI ran the test suite at all — a direct push to
# master was entirely unguarded.
#
# Build caching (2026-07-13): the Gitea actions cache never restored on
# this instance — every run back through run 597 logged "No cache found"
# even for exact keys saved successfully ("Cache saved successfully") by
# a run an hour earlier, including master→master restores. Until the
# cache server on the runner host is fixed, Swatinem/rust-cache is pure
# overhead here. `rust-host` is a HOST executor (its filesystem persists
# between runs — ~/.cargo and the rustup toolchain already carry over),
# so we get warm builds by pointing CARGO_TARGET_DIR at a persistent
# path on the runner instead of tarring gigabytes through a cache API
# that never returns them. Concurrent runs are safe: cargo serialises
# on the target-dir lock.
name: Test
on:
@@ -28,8 +41,27 @@ on:
workflow_dispatch:
jobs:
# Seconds-long formatting gate in its own job so a rustfmt slip fails
# here instead of after a 35-minute cold build (run 600 spent its
# whole build budget to report an unformatted file).
fmt:
runs-on: rust-host
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust 1.95.0
uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.95.0
components: rustfmt
- name: Format check
run: cargo fmt --check
test:
runs-on: rust-host
needs: fmt
# Full debuginfo made the solitaire_engine test-binary link peak past the
# runner's memory — ld was OOM-killed (signal 9) on runs 447 and 486.
@@ -40,10 +72,19 @@ jobs:
# test binaries concurrently; as the workspace grew (runs 514/516/519)
# two+ simultaneous ld processes OOM-killed the runner again even at
# line-tables-only. Two jobs keeps at most two links in flight — the
# compile-throughput cost is small next to the cache-warm build.
# compile-throughput cost is small next to the warm build.
#
# CARGO_INCREMENTAL=0: incremental artifacts bloat the persistent
# target dir for little benefit in CI (rust-cache used to set this
# for the same reason).
#
# CARGO_TARGET_DIR: persistent on the runner host — see the header
# comment. The prune step below keeps it from growing unbounded.
env:
CARGO_PROFILE_DEV_DEBUG: line-tables-only
CARGO_BUILD_JOBS: '2'
CARGO_INCREMENTAL: '0'
CARGO_TARGET_DIR: /home/runner/.cache/ferrous-solitaire/target
steps:
- name: Checkout
@@ -53,10 +94,21 @@ jobs:
uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.95.0
components: clippy, rustfmt
components: clippy
- name: Cache cargo build
uses: Swatinem/rust-cache@v2
# Toolchain or lockfile bumps strand stale artifacts nothing will
# ever reuse; reset the dir when it crosses 40 GiB rather than
# curating it (a cold rebuild every few weeks is cheaper than the
# bookkeeping).
- name: Prune persistent target dir when oversized
run: |
limit_kb=$((40 * 1024 * 1024))
used_kb=$(du -sk "$CARGO_TARGET_DIR" 2>/dev/null | cut -f1 || echo 0)
echo "persistent target dir: $((used_kb / 1024)) MiB (limit $((limit_kb / 1024)) MiB)"
if [ "${used_kb:-0}" -gt "$limit_kb" ]; then
echo "over limit — clearing for a fresh cold build"
rm -rf "$CARGO_TARGET_DIR"
fi
# Native link deps for the Bevy crates (engine/app/web) on a bare
# ubuntu runner: ALSA + udev for input/audio, X11 + Wayland for winit.
@@ -67,9 +119,6 @@ jobs:
libasound2-dev libudev-dev pkg-config libx11-dev libxcursor-dev \
libxrandr-dev libxi-dev libwayland-dev libxkbcommon-dev
- name: Format check
run: cargo fmt --check
# SQLX_OFFLINE uses the checked-in `.sqlx/` query cache (no live DB),
# same as the web-e2e workflow's server prebuild.
- name: Clippy (deny warnings)
+1 -3
View File
@@ -145,7 +145,6 @@ Shared API contract types imported by both the game client (`solitaire_data`) an
Owns:
- `SyncPayload`, `SyncResponse`, `ConflictReport`
- `ChallengeGoal`, `LeaderboardEntry`
- `ApiError` enum
- Merge logic (pure functions, no I/O)
### `solitaire_data`
@@ -257,7 +256,7 @@ solitaire_sync::merge(local, remote)
Write merged result to disk
fires SyncCompleteEvent
Bevy main thread reads updated StatsResource
```
@@ -377,7 +376,6 @@ struct StateChangedEvent;
struct CardFlippedEvent(u32);
struct GameWonEvent { score: i32, time_seconds: u64 }
struct AchievementUnlockedEvent(AchievementRecord);
struct SyncCompleteEvent(Result<SyncResponse, String>);
```
### Layout System
+62
View File
@@ -6,6 +6,68 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [0.45.0] — 2026-07-13
### Added
- **Thumb-reach action bar on touch (menu redesign Phase F).** The bottom
bar slims to five buttons: an enlarged **Undo · Draw · Hint** trio
(96×64 px targets) between compact Menu and Pause. **Draw is new** — it
draws from the stock exactly like tapping the deck, without the reach to
the top of a tall folded screen. Help, Modes, and New Game moved off the
touch bar (Menu → System, the Home grid, and Home's hero button cover
them). Desktop keeps its seven-button bar unchanged.
- **Hold-to-repeat Undo.** Press and hold Undo to step back repeatedly
(~5.5 undos/s after a short delay) instead of tap-tap-tap. Every step
goes through the normal undo path, so scoring penalties apply as usual.
## [0.44.0] — 2026-07-13
### Added
- **Home is now a real home (menu redesign Phase B).** The mode picker became
a hierarchy: a **Continue** card (mode · elapsed · score) appears while a
game is in progress and returns to the table; a hero **New Game** button
replays your last mode with the current deal options in one tap; deal
options (draw 1/3, a new **winnable-only** toggle, difficulty tiers) moved
into a disclosure under the hero; the six modes sit in a compact symmetric
2×3 grid with descriptions on wide screens; and the stats strip moved to
the bottom. Wide viewports (desktop, unfolded foldables) get a two-pane
layout — launch surfaces left, Continue/daily/stats right. Cancel is now
**Back to table** and only appears while a live game exists. (#175)
### Fixed
- **Save files are self-contained (schema v6).** Like replays in 0.43.3, a
saved game now stores the dealt board via the upstream session serializers
instead of re-dealing from the seed on load; v4/v5 saves still load through
the legacy path and rewrite as v6 on next save. (#171)
### Internal
- **CI is ~9× faster.** Root-caused why the actions cache never restored on
the host-executor runner (per-run workdir paths poisoned the cache version
hash); the test workflow now uses a persistent on-runner target dir, and a
seconds-long `fmt` gate fails formatting mistakes before the build. Warm
full-gate runs: 4m29s, down from ~40 min. (#176)
## [0.43.3] — 2026-07-10
### Fixed
- **Replays are now self-contained (schema v4).** A replay stores the dealt
board itself via the upstream `card_game` session serializers instead of
re-dealing from the seed at playback time, so replays survive RNG and
upstream upgrades that change the seed→deal mapping — the failure that had
silently broken every stored replay. The web player and web game now
exchange the full payload through the wasm layer (the old JS path hardcoded
`schema_version: 2`, uploaded empty move lists, and corrupted u64 seeds via
`Math.round`), and the replay viewer reports unplayable old-format replays
in the caption instead of dying silently. Pre-v4 replays are rejected by a
version gate; local histories repopulate with new wins. (#170)
- **Difficulty-mode wins can upload.** The server's replay `mode` validation
now accepts data-carrying `GameMode` variants (previously a 400). (#170)
## [0.42.0] — 2026-07-06
### Added
Generated
+1
View File
@@ -7333,6 +7333,7 @@ dependencies = [
"proptest",
"rand 0.10.1",
"serde",
"serde_json",
"thiserror 2.0.18",
]
+14 -7
View File
@@ -1,7 +1,9 @@
# Menu UX Redesign — July 2026
Status: PLANNING. Visual identity (Terminal / base16-eighties) is settled and
out of scope — this is about **structure and interaction**, not colors or type.
Status: IN PROGRESS. A / E / C / G shipped in v0.43.0; B implemented on
`feat/home-hierarchy` (2026-07-13); F / H and the IM backlog remain open.
Visual identity (Terminal / base16-eighties) is settled and out of
scope — this is about **structure and interaction**, not colors or type.
## Diagnosis (from code survey, 2026-07-07)
@@ -197,8 +199,13 @@ slimming), then **B**, then **F/G/H** as independent follow-ups.
## Open decisions
1. ~~Settings: tabs vs. sub-pages~~ — **DECIDED 2026-07-07: tabs.**
2. Deal options: disclosure on Classic card (proposed) vs. keep global row?
3. Time Attack + Seed: top-level cards (proposed, grid stays symmetric) vs.
tucked under a "More" card?
4. Stitch mockups for Phase B, or iterate directly in-engine?
5. Phase F bottom bar: touch-only (proposed) or also desktop?
2. ~~Deal options: disclosure on Classic card vs. keep global row~~
**DECIDED 2026-07-09: disclosure on the Classic card / New Game hero.**
3. ~~Time Attack + Seed: top-level cards vs. "More" card~~
**DECIDED 2026-07-09: top-level cards, symmetric 2×3 grid.**
4. ~~Stitch mockups for Phase B, or iterate directly in-engine~~
**DECIDED 2026-07-09: directly in-engine.**
5. ~~Phase F bottom bar: touch-only vs. also desktop~~
**DECIDED 2026-07-13: touch-only.** Desktop keeps the current top
band; the bar is additive per §3.3 so a later desktop rollout stays
a settings flag away.
+2 -1
View File
@@ -9,7 +9,8 @@ default = []
test-support = []
[dev-dependencies]
proptest = "1"
proptest = "1"
serde_json = { workspace = true }
[dependencies]
serde = { workspace = true }
+340 -41
View File
@@ -22,11 +22,17 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// indices for enum variants. No longer loadable — v3 files are discarded.
/// - v4: `saved_moves` uses upstream `KlondikeInstruction` serde with named enum
/// variants (e.g. `"Foundation1"` instead of `0`).
/// - v5 (current): `score`, `undo_count`, and `recycle_count` are no longer
/// - v5: `score`, `undo_count`, and `recycle_count` are no longer
/// persisted. They are derived from the upstream `card_game`/`klondike` session
/// stats, which are rebuilt by replaying `saved_moves` on load. Older files that
/// still carry those keys load fine — the extra fields are ignored.
pub const GAME_STATE_SCHEMA_VERSION: u32 = 5;
/// - v6 (current): `saved_moves` replaced by `recording`, the upstream
/// `card_game` session serialisation carrying the dealt board explicitly.
/// Loading no longer re-deals from `seed`, so in-progress saves survive
/// RNG/upstream upgrades that change the seed→deal mapping (the failure
/// class that PR #170 fixed for replays). v4/v5 files still load through
/// the legacy seed path and are rewritten as v6 on next save.
pub const GAME_STATE_SCHEMA_VERSION: u32 = 6;
/// Default move budget for a solvability check. Matches the winnable-deal retry
/// loop in the engine.
@@ -95,8 +101,8 @@ pub enum GameMode {
Difficulty(DifficultyLevel),
}
/// Output struct for schema v4 serialisation. `saved_moves` uses upstream
/// `KlondikeInstruction` serde, which produces named enum variants.
/// Output struct for schema v6 serialisation. `recording` is the upstream
/// session serialisation (config + dealt board + instruction list).
#[derive(Debug, Clone, Serialize)]
struct PersistedGameState {
pub draw_mode: DrawStockConfig,
@@ -105,15 +111,16 @@ struct PersistedGameState {
pub seed: u64,
pub take_from_foundation: bool,
pub schema_version: u32,
pub saved_moves: Vec<KlondikeInstruction>,
pub recording: SessionRecording,
}
/// Input struct that accepts schema v4 and v5 `saved_moves` formats.
/// Input struct that accepts schema v4, v5, and v6 save formats.
///
/// `saved_moves` is deserialised directly as upstream `KlondikeInstruction`
/// (named-variant serde). `score`, `undo_count`, and `recycle_count` are
/// intentionally absent: all three are rebuilt by replaying the instruction
/// history through the upstream session stats. Older v4 save files still carry
/// v6 files carry `recording` (the upstream session serialisation, deal
/// included); v4/v5 files carry `saved_moves` and rebuild the deal from
/// `seed`. `score`, `undo_count`, and `recycle_count` are intentionally
/// absent: all three are rebuilt by replaying the instruction history
/// through the upstream session stats. Older v4 save files still carry
/// those keys; serde ignores them.
#[derive(Debug, Clone, Deserialize)]
struct PersistedGameStateIn {
@@ -126,7 +133,12 @@ struct PersistedGameStateIn {
pub take_from_foundation: bool,
#[serde(default = "schema_v1")]
pub schema_version: u32,
/// v4/v5 only. Replayed against a seed-dealt board.
#[serde(default)]
pub saved_moves: Vec<KlondikeInstruction>,
/// v6 only. Carries the dealt board explicitly.
#[serde(default)]
pub recording: Option<SessionRecording>,
}
#[cfg(feature = "test-support")]
@@ -222,7 +234,7 @@ impl Serialize for GameState {
seed: self.seed,
take_from_foundation: self.take_from_foundation,
schema_version: GAME_STATE_SCHEMA_VERSION,
saved_moves: self.saved_moves(),
recording: self.recording(),
}
.serialize(serializer)
}
@@ -232,34 +244,51 @@ impl<'de> Deserialize<'de> for GameState {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let persisted = PersistedGameStateIn::deserialize(deserializer)?;
// Accept v4 (upstream named-variant serde) and v5 (current, derived
// stats). v3 (legacy u8-index format) and all others are rejected.
match persisted.schema_version {
4 | 5 => {}
// Accept v6 (current, recording-based), plus v4/v5 (legacy seed-dealt
// saves — loadable while the seed→deal mapping they were written
// under still holds; rewritten as v6 on the next save). v3 (legacy
// u8-index format) and all others are rejected.
let (mut game, instructions) = match persisted.schema_version {
6 => {
let Some(recording) = persisted.recording else {
return Err(serde::de::Error::custom(
"v6 save file is missing the session recording",
));
};
// The dealt board and session config come from the recording
// itself; `seed` is presentation metadata only.
Self::from_recording(&recording, persisted.seed, persisted.mode)
}
4 | 5 => (
Self {
mode: persisted.mode,
elapsed_seconds: 0,
seed: persisted.seed,
take_from_foundation: true,
session: Self::new_session(persisted.seed, persisted.draw_mode),
#[cfg(feature = "test-support")]
test_pile_state: None,
},
persisted.saved_moves,
),
v => {
return Err(serde::de::Error::custom(format!(
"unsupported GameState schema version {v}"
)));
}
}
let mut game = Self {
mode: persisted.mode,
elapsed_seconds: persisted.elapsed_seconds,
seed: persisted.seed,
take_from_foundation: persisted.take_from_foundation,
session: Self::new_session(persisted.seed, persisted.draw_mode),
#[cfg(feature = "test-support")]
test_pile_state: None,
};
game.mode = persisted.mode;
game.elapsed_seconds = persisted.elapsed_seconds;
game.take_from_foundation = persisted.take_from_foundation;
// Replay the saved instruction history. The upstream session tracks
// score components and recycle_count as it processes each move, so the
// derived stats are correct once replay completes. `undo_count()` resets
// to 0 across save/load because undone moves are not part of the saved
// forward history.
// Replay the saved instruction history with validation — a tampered
// or corrupt file must surface an error, not a silently wrong board.
// The upstream session tracks score components and recycle_count as
// it processes each move, so the derived stats are correct once
// replay completes. `undo_count()` resets to 0 across save/load
// because undone moves are not part of the saved forward history.
let replay_config = Self::replay_config(persisted.draw_mode);
for instruction in persisted.saved_moves {
for instruction in instructions {
if !game
.session
.state()
@@ -277,6 +306,90 @@ impl<'de> Deserialize<'de> for GameState {
}
}
/// Self-contained recording of one deal plus every instruction applied to it.
///
/// Serialises via the upstream `card_game` [`Session`] serde, whose wire
/// format is `{config, initial_state, instructions}` — the dealt board is
/// stored **explicitly**, so playback never depends on the seed→deal mapping
/// staying stable across RNG or upstream-crate upgrades. This is the payload
/// replays must persist; a bare seed is only sufficient for the exact build
/// that recorded it.
#[derive(Debug, Clone)]
pub struct SessionRecording(Session<Klondike>);
impl SessionRecording {
/// Builds a recording by dealing a fresh board from `seed` and
/// force-applying `instructions` **without validation**.
///
/// Fixture/test aid only — production recordings come from
/// [`GameState::recording`], whose history is valid by construction.
/// Invalid instructions are absorbed by the upstream session's
/// `Option`-based pile pops rather than rejected, so a recording built
/// here may not replay cleanly through
/// [`GameState::apply_instruction`]'s validation.
pub fn from_instructions_unchecked(
seed: u64,
draw_mode: DrawStockConfig,
instructions: impl IntoIterator<Item = KlondikeInstruction>,
) -> Self {
let mut session = GameState::new_session(seed, draw_mode);
for instruction in instructions {
session.process_instruction(instruction);
}
Self(session)
}
/// The dealt board the recording starts from (before any instruction).
fn initial_state(&self) -> &Klondike {
self.0
.history()
.first()
.map(|snapshot| snapshot.state())
.unwrap_or_else(|| self.0.state().state())
}
/// Ordered instruction list, replayable via
/// [`GameState::apply_instruction`] against the game returned by
/// [`GameState::from_recording`].
pub fn instructions(&self) -> Vec<KlondikeInstruction> {
self.0
.history()
.iter()
.map(|snapshot| *snapshot.instruction())
.collect()
}
/// Number of recorded instructions.
pub fn len(&self) -> usize {
self.0.history().len()
}
/// `true` when no instructions have been recorded.
pub fn is_empty(&self) -> bool {
self.0.history().is_empty()
}
}
impl PartialEq for SessionRecording {
fn eq(&self, other: &Self) -> bool {
self.initial_state() == other.initial_state() && self.instructions() == other.instructions()
}
}
impl Eq for SessionRecording {}
impl Serialize for SessionRecording {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.0.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for SessionRecording {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
Session::deserialize(deserializer).map(Self)
}
}
impl GameState {
/// Creates a new Classic-mode game dealt from the given seed and draw mode.
pub fn new(seed: u64, draw_mode: DrawStockConfig) -> Self {
@@ -296,6 +409,44 @@ impl GameState {
}
}
/// Snapshot of the live session for replay persistence: the dealt board
/// plus the forward instruction history (undone moves are absent — the
/// session pops them). Serialise the returned [`SessionRecording`] with
/// the upstream `card_game` serializers; rebuild playback with
/// [`Self::from_recording`].
pub fn recording(&self) -> SessionRecording {
SessionRecording(self.session.clone())
}
/// Rebuilds the initial-deal game plus the ordered instruction list from
/// a [`SessionRecording`].
///
/// The board and the session config (including draw mode) come from the
/// recording itself, so playback is independent of the current build's
/// seed→deal mapping. `seed` and `mode` are presentation metadata carried
/// alongside the recording by the replay file. Step through the returned
/// instructions with [`Self::apply_instruction`], which re-validates each
/// one and fails gracefully on corrupt input.
pub fn from_recording(
recording: &SessionRecording,
seed: u64,
mode: GameMode,
) -> (Self, Vec<KlondikeInstruction>) {
let game = Self {
mode,
elapsed_seconds: 0,
seed,
take_from_foundation: true,
session: Session::new(
recording.initial_state().clone(),
recording.0.config().clone(),
),
#[cfg(feature = "test-support")]
test_pile_state: None,
};
(game, recording.instructions())
}
/// Whether the player draws one or three cards from the stock per turn.
/// Derived from the underlying session config (set once at deal time).
pub fn draw_mode(&self) -> DrawStockConfig {
@@ -402,16 +553,6 @@ impl GameState {
KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation)
}
/// 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
/// upstream [`KlondikeInstruction`] values.
///
@@ -1512,4 +1653,162 @@ mod tests {
assert!(easy.is_err());
assert!(matches!(medium, Ok(Some(_))));
}
/// Play a few real moves on a fresh deal and return the game.
fn game_with_some_moves(seed: u64) -> GameState {
let mut game = GameState::new(seed, DrawStockConfig::DrawOne);
for _ in 0..40 {
let instructions = game.possible_instructions();
let applied = instructions
.into_iter()
.find(|i| game.clone().apply_instruction(*i).is_ok())
.and_then(|i| game.apply_instruction(i).ok());
if applied.is_none() && game.draw().is_err() {
break;
}
}
assert!(
!game.instruction_history().is_empty(),
"test needs at least one recorded move"
);
game
}
#[test]
fn recording_round_trips_through_upstream_serde() {
let game = game_with_some_moves(51);
let recording = game.recording();
let json = serde_json::to_string(&recording).expect("serialize recording");
let restored: SessionRecording = serde_json::from_str(&json).expect("parse recording");
assert_eq!(recording, restored);
assert_eq!(recording.instructions(), game.instruction_history());
}
#[test]
fn from_recording_replays_to_identical_board_without_seed_dealing() {
let game = game_with_some_moves(145);
let recording = game.recording();
let json = serde_json::to_string(&recording).expect("serialize recording");
let restored: SessionRecording = serde_json::from_str(&json).expect("parse recording");
// Deliberately pass a DIFFERENT seed: the board must come from the
// recording, proving playback no longer depends on seed→deal mapping.
let (mut replayed, instructions) =
GameState::from_recording(&restored, 0xDEAD_BEEF, game.mode);
assert_eq!(replayed.draw_mode(), game.draw_mode());
for instruction in instructions {
replayed
.apply_instruction(instruction)
.expect("recorded instruction must replay cleanly");
}
for pile in [KlondikePile::Stock]
.into_iter()
.chain(crate::TABLEAUS.map(KlondikePile::Tableau))
.chain(crate::FOUNDATIONS.map(KlondikePile::Foundation))
{
assert_eq!(
replayed.pile(pile),
game.pile(pile),
"pile {pile:?} differs"
);
}
assert_eq!(replayed.waste_cards(), game.waste_cards());
assert_eq!(replayed.move_count(), game.move_count());
}
/// v6 save files carry the deal in the recording; the `seed` field is
/// presentation metadata. Corrupting it must not change the loaded board.
#[test]
fn v6_save_load_ignores_seed_for_dealing() {
let game = game_with_some_moves(51);
let mut value = serde_json::to_value(&game).expect("serialise");
assert_eq!(value["schema_version"], 6);
value["seed"] = serde_json::Value::from(0xDEAD_BEEF_u64);
let loaded: GameState =
serde_json::from_value(value).expect("v6 save with wrong seed must still load");
assert_eq!(loaded.stock_cards(), game.stock_cards());
assert_eq!(loaded.waste_cards(), game.waste_cards());
for index in 0..7 {
let t = GameState::tableau_from_index(index).expect("tableau index");
assert_eq!(
loaded.pile(KlondikePile::Tableau(t)),
game.pile(KlondikePile::Tableau(t)),
"tableau {index} differs"
);
}
assert_eq!(loaded.move_count(), game.move_count());
assert_eq!(loaded.score(), game.score());
}
/// Legacy v5 files (seed + saved_moves, no recording) must still load
/// through the seed-dealt path until players resave as v6.
#[test]
fn v5_legacy_save_still_loads() {
let game = game_with_some_moves(145);
let v5 = serde_json::json!({
"draw_mode": game.draw_mode(),
"mode": game.mode,
"elapsed_seconds": 12,
"seed": game.seed,
"take_from_foundation": true,
"schema_version": 5,
"saved_moves": game.instruction_history(),
});
let loaded: GameState =
serde_json::from_value(v5).expect("v5 save must load via the legacy seed path");
assert_eq!(loaded.move_count(), game.move_count());
assert_eq!(loaded.stock_cards(), game.stock_cards());
assert_eq!(loaded.elapsed_seconds, 12);
}
/// A v6 file without the recording payload is malformed and must be
/// rejected with an error rather than silently re-dealt from the seed.
#[test]
fn v6_save_without_recording_is_rejected() {
let game = GameState::new(7, DrawStockConfig::DrawOne);
let mut value = serde_json::to_value(&game).expect("serialise");
value.as_object_mut().expect("object").remove("recording");
let err = serde_json::from_value::<GameState>(value)
.expect_err("v6 save without recording must fail");
assert!(err.to_string().contains("recording"), "got: {err}");
}
/// A recording whose instruction list is invalid for its own deal must
/// be rejected on load — tampered or corrupt files surface an error,
/// never a silently wrong board.
#[test]
fn v6_save_with_invalid_history_is_rejected() {
use klondike::{DstFoundation, Foundation, KlondikePile};
// A foundation move from an empty tableau is never legal on a fresh
// deal's first instruction slot for this source shape.
let bogus = KlondikeInstruction::DstFoundation(DstFoundation {
src: KlondikePile::Foundation(Foundation::Foundation1),
foundation: Foundation::Foundation2,
});
let recording =
SessionRecording::from_instructions_unchecked(7, DrawStockConfig::DrawOne, [bogus]);
let v6 = serde_json::json!({
"draw_mode": DrawStockConfig::DrawOne,
"mode": GameMode::Classic,
"elapsed_seconds": 0,
"seed": 7,
"take_from_foundation": true,
"schema_version": 6,
"recording": recording,
});
let err =
serde_json::from_value::<GameState>(v6).expect_err("invalid history must be rejected");
assert!(err.to_string().contains("invalid"), "got: {err}");
}
#[test]
fn from_recording_of_fresh_deal_returns_empty_instructions() {
let game = GameState::new(7, DrawStockConfig::DrawThree);
let recording = game.recording();
assert!(recording.is_empty());
let (replayed, instructions) = GameState::from_recording(&recording, 7, game.mode);
assert!(instructions.is_empty());
assert_eq!(replayed.stock_cards(), game.stock_cards());
assert_eq!(replayed.draw_mode(), DrawStockConfig::DrawThree);
}
}
+3 -1
View File
@@ -20,7 +20,9 @@ pub use klondike::{
// Solvability check API (delegates to `card_game::Session::solve`); replaces the
// former `solitaire_data::solver` wrapper module.
pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
pub use game_state::{
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SessionRecording, SolveOutcome,
};
// Spider rules (second `card_game::Game` implementation; engine UI is a
// later phase — nothing outside solitaire_core consumes these yet).
+88 -80
View File
@@ -12,22 +12,24 @@
//! carries any other version so older replays are silently dropped instead
//! of crashing the loader.
//!
//! The recording is intentionally minimal — only the
//! [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) inputs that
//! successfully advanced the game. `Undo` is **not** recorded: a replay
//! represents the canonical path the player ultimately took to win, so
//! backed-out missteps simply do not appear in the move list. The starting
//! deal is not stored either — the [`seed`](Replay::seed) +
//! [`draw_mode`](Replay::draw_mode) + [`mode`](Replay::mode) are sufficient
//! for `GameState::new_with_mode` to rebuild the identical layout.
//! The payload is a [`SessionRecording`](solitaire_core::SessionRecording):
//! the upstream `card_game` session serialisation, which stores the dealt
//! board **explicitly** plus the ordered instruction list. `Undo` is not
//! recorded: a replay represents the canonical path the player ultimately
//! took to win, so backed-out missteps simply do not appear (the session
//! pops them from its history).
//!
//! Storing the deal (rather than re-dealing from [`seed`](Replay::seed) at
//! playback time, as schemas ≤ v3 did) makes replays immune to seed→deal
//! mapping drift across RNG or upstream-crate upgrades — the exact failure
//! that silently broke every pre-upgrade replay. `seed`, `draw_mode`, and
//! `mode` remain as presentation/indexing metadata only.
//!
//! Each recorded move is the player's atomic *input*, not its outcome.
//! `KlondikeInstruction::RotateStock` covers every click on the stock pile;
//! the engine resolves draw-vs-recycle deterministically from the current
//! stock state during playback, so the same input always produces the same
//! effect on the same starting deal. Runtime-only pile-position types are
//! never serialised — the instruction itself serialises via its compact
//! upstream serde representation.
//! effect on the same starting deal.
use std::fs;
use std::io;
@@ -35,7 +37,7 @@ use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
use solitaire_core::{DrawStockConfig, SessionRecording, game_state::GameMode};
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
@@ -77,13 +79,19 @@ fn history_schema_v0() -> u32 {
/// variants which carried the *outcome* of a stock interaction rather
/// than the player's atomic input.
/// - v2: `Draw` + `Recycle` collapsed into a single `StockClick` variant.
/// - v3 (current): the bespoke `ReplayMove` serde mirror was dropped. Moves
/// are now stored directly as upstream
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) (compact
/// int serde); `StockClick` is now `RotateStock`. Pile-position types are
/// runtime-only and are never serialised. v1/v2 files fail to deserialise
/// and are discarded by the loader.
pub const REPLAY_SCHEMA_VERSION: u32 = 3;
/// - v3: the bespoke `ReplayMove` serde mirror was dropped. Moves
/// were stored directly as upstream `KlondikeInstruction` (compact
/// int serde); `StockClick` became `RotateStock`. Pile-position types are
/// runtime-only and are never serialised. The starting deal was still
/// rebuilt from the seed at playback time.
/// - v4 (current): the bare `moves` list was replaced by a
/// [`SessionRecording`](solitaire_core::SessionRecording) — the upstream
/// `card_game` session serialisation carrying the dealt board explicitly
/// plus the instruction list. Playback no longer re-deals from the seed,
/// so replays survive RNG/upstream upgrades that change the seed→deal
/// mapping (which invalidated every v3 replay). v1v3 files fail the
/// version gate and are discarded by the loader.
pub const REPLAY_SCHEMA_VERSION: u32 = 4;
/// Default value for [`Replay::schema_version`] when deserialising files
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`]
@@ -94,9 +102,10 @@ fn schema_v0() -> u32 {
/// A complete recording of a single winning game.
///
/// Replays are reconstructed by rebuilding a fresh
/// `GameState::new_with_mode(seed, draw_mode, mode)` and applying the
/// [`moves`](Self::moves) in order. The presentation fields
/// Replays are reconstructed via
/// `GameState::from_recording(&replay.recording, replay.seed, replay.mode)`,
/// which rebuilds the recorded deal directly and returns the instruction
/// list to step through. The presentation fields
/// ([`time_seconds`](Self::time_seconds), [`final_score`](Self::final_score),
/// [`recorded_at`](Self::recorded_at)) drive the Stats UI caption such as
/// "Replay (2:14 win on 2026-05-02)".
@@ -105,8 +114,9 @@ pub struct Replay {
/// Schema version. See [`REPLAY_SCHEMA_VERSION`].
#[serde(default = "schema_v0")]
pub schema_version: u32,
/// Seed used for the deal — replay rasterises the deck via
/// `GameState::new_with_mode(seed, draw_mode, mode)`.
/// Seed the recorded game was originally dealt from. Presentation /
/// indexing metadata only — playback rebuilds the board from
/// [`recording`](Self::recording), never by re-dealing this seed.
pub seed: u64,
/// Draw mode the recorded game was played in.
pub draw_mode: DrawStockConfig,
@@ -119,11 +129,10 @@ pub struct Replay {
pub final_score: i32,
/// ISO-8601 date the win was recorded.
pub recorded_at: NaiveDate,
/// Ordered move list. Each entry is the atomic
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) the player
/// issued, replayable against a fresh `GameState` constructed from the
/// seed via `GameState::apply_instruction`.
pub moves: Vec<KlondikeInstruction>,
/// The dealt board plus the ordered instruction list, serialised via the
/// upstream `card_game` session serializers. Self-contained: playback
/// needs nothing else to reproduce the game move-for-move.
pub recording: SessionRecording,
/// Public share URL for this replay on the active sync backend, set
/// by `sync_plugin::poll_replay_upload_result` when the upload
/// task resolves. `None` when the player won on a local-only
@@ -133,11 +142,12 @@ pub struct Replay {
/// [`REPLAY_SCHEMA_VERSION`].
#[serde(default)]
pub share_url: Option<String>,
/// Index into [`moves`](Self::moves) of the move that triggered
/// the win condition (i.e. completed the last foundation pile).
/// Index into the [`recording`](Self::recording)'s instruction list
/// of the move that triggered the win condition (i.e. completed the
/// last foundation pile).
///
/// For replays recorded by the live engine this is always
/// `Some(moves.len() - 1)` because recording freezes on win — but
/// `Some(recording.len() - 1)` because recording freezes on win — but
/// the field is stored explicitly so the playback UI can read it
/// directly without re-deriving "the last move was the win" each
/// time, and to leave room for future recording semantics that
@@ -172,7 +182,7 @@ impl Replay {
time_seconds: u64,
final_score: i32,
recorded_at: NaiveDate,
moves: Vec<KlondikeInstruction>,
recording: SessionRecording,
) -> Self {
Self {
schema_version: REPLAY_SCHEMA_VERSION,
@@ -182,7 +192,7 @@ impl Replay {
time_seconds,
final_score,
recorded_at,
moves,
recording,
share_url: None,
win_move_index: None,
}
@@ -193,7 +203,7 @@ impl Replay {
/// [`Replay::new`]:
///
/// ```ignore
/// let replay = Replay::new(...).with_win_move_index(Some(recording.moves.len() - 1));
/// let replay = Replay::new(...).with_win_move_index(recording.len().checked_sub(1));
/// ```
///
/// `None` is a valid input — useful for tests that don't care about
@@ -430,7 +440,8 @@ pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
mod tests {
use super::*;
use klondike::{
DstFoundation, DstTableau, Foundation, KlondikePile, KlondikePileStack, Tableau,
DstFoundation, DstTableau, Foundation, KlondikeInstruction, KlondikePile,
KlondikePileStack, Tableau,
};
use std::env;
@@ -447,18 +458,22 @@ mod tests {
134,
5_120,
date,
vec![
KlondikeInstruction::RotateStock,
KlondikeInstruction::DstTableau(DstTableau {
src: KlondikePileStack::Stock,
tableau: Tableau::Tableau4,
}),
KlondikeInstruction::RotateStock,
KlondikeInstruction::DstFoundation(DstFoundation {
src: KlondikePile::Tableau(Tableau::Tableau4),
foundation: Foundation::Foundation1,
}),
],
SessionRecording::from_instructions_unchecked(
12345,
DrawStockConfig::DrawThree,
[
KlondikeInstruction::RotateStock,
KlondikeInstruction::DstTableau(DstTableau {
src: KlondikePileStack::Stock,
tableau: Tableau::Tableau4,
}),
KlondikeInstruction::RotateStock,
KlondikeInstruction::DstFoundation(DstFoundation {
src: KlondikePile::Tableau(Tableau::Tableau4),
foundation: Foundation::Foundation1,
}),
],
),
)
}
@@ -518,25 +533,22 @@ mod tests {
/// rolling history wiped on the v0.19.0 update.
#[test]
fn replay_loads_when_share_url_field_is_absent() {
let pre_v019_json = format!(
r#"{{
"schema_version": {schema},
"seed": 1,
"draw_mode": "DrawOne",
"mode": "Classic",
"time_seconds": 60,
"final_score": 100,
"recorded_at": "2025-01-01",
"moves": []
}}"#,
schema = REPLAY_SCHEMA_VERSION,
);
let parsed: Replay = serde_json::from_str(&pre_v019_json)
.expect("pre-v0.19.0 replay JSON must still deserialise");
// Build a current-schema JSON object, then strip the optional
// fields to simulate a file written before they existed.
let mut value = serde_json::to_value(sample_replay()).expect("serialise sample");
let obj = value.as_object_mut().expect("replay serialises as object");
obj.remove("share_url");
obj.remove("win_move_index");
let parsed: Replay = serde_json::from_value(value)
.expect("replay JSON without optional fields must still deserialise");
assert!(
parsed.share_url.is_none(),
"missing share_url field must default to None",
);
assert!(
parsed.win_move_index.is_none(),
"missing win_move_index field must default to None",
);
}
/// Atomic-write contract — `.tmp` must not be left behind after
@@ -588,7 +600,11 @@ mod tests {
60,
id,
date,
vec![KlondikeInstruction::RotateStock],
SessionRecording::from_instructions_unchecked(
id as u64,
DrawStockConfig::DrawOne,
[KlondikeInstruction::RotateStock],
),
)
}
@@ -824,22 +840,14 @@ mod tests {
let path = tmp_path("legacy_no_win_move_index");
let _ = fs::remove_file(&path);
// Hand-rolled minimal current-schema replay JSON with no
// win_move_index field — the additive field must still default to None.
let no_field = format!(
r#"{{
"schema_version": {schema},
"seed": 1,
"draw_mode": "DrawOne",
"mode": "Classic",
"time_seconds": 60,
"final_score": 100,
"recorded_at": "2026-05-02",
"moves": []
}}"#,
schema = REPLAY_SCHEMA_VERSION,
);
fs::write(&path, no_field).expect("write fixture");
// Current-schema replay JSON with the win_move_index field stripped —
// the additive field must still default to None.
let mut value = serde_json::to_value(sample_replay()).expect("serialise sample");
value
.as_object_mut()
.expect("replay serialises as object")
.remove("win_move_index");
fs::write(&path, serde_json::to_string(&value).expect("to_string")).expect("write fixture");
let loaded = load_latest_replay_from(&path).expect("load");
assert_eq!(loaded.win_move_index, None);
+11 -1
View File
@@ -9,7 +9,10 @@ use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use solitaire_core::{DrawStockConfig, game_state::DifficultyLevel};
use solitaire_core::{
DrawStockConfig,
game_state::{DifficultyLevel, GameMode},
};
const SETTINGS_FILE_NAME: &str = "settings.json";
@@ -248,6 +251,12 @@ pub struct Settings {
/// cleanly to `None` via `#[serde(default)]`.
#[serde(default)]
pub last_difficulty: Option<DifficultyLevel>,
/// Mode of the last game the player launched from the home overlay.
/// The home hero "New Game" button replays this mode with one tap.
/// Older `settings.json` files written before this field existed
/// deserialize cleanly to `GameMode::Classic` via `#[serde(default)]`.
#[serde(default)]
pub last_mode: GameMode,
/// Custom public name displayed on the leaderboard. When `None`, the
/// player's server `username` is used instead. Trimmed to 32 characters
/// before submission. Older `settings.json` files written before this
@@ -415,6 +424,7 @@ impl Default for Settings {
disable_smart_default_size: false,
replay_move_interval_secs: default_replay_move_interval_secs(),
last_difficulty: None,
last_mode: GameMode::Classic,
leaderboard_display_name: None,
leaderboard_opted_in: false,
take_from_foundation: true,
+10 -5
View File
@@ -477,7 +477,7 @@ mod tests {
/// again must reproduce byte-identical JSON. `undo_count` deliberately resets
/// to 0 on load because only the forward instruction history is persisted.
#[test]
fn game_state_v5_mid_game_round_trip() {
fn game_state_v6_mid_game_round_trip() {
use solitaire_core::KlondikeInstruction;
use solitaire_core::game_state::GameState;
@@ -518,11 +518,16 @@ mod tests {
save_game_state_to(&path, &gs).expect("save");
// Verify the file carries the v5 schema marker.
// Verify the file carries the v6 schema marker and the recording.
let json = fs::read_to_string(&path).expect("read json");
let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse saved json");
assert_eq!(
parsed["schema_version"], 6,
"saved file must use schema version 6",
);
assert!(
json.contains("\"schema_version\"") && json.contains('5'),
"saved file must use schema version 5",
parsed["recording"].is_object(),
"saved file must embed the session recording",
);
let loaded =
@@ -530,7 +535,7 @@ mod tests {
// The forward instruction history round-trips, so the reconstructed board
// re-serialises to byte-identical JSON.
let path_reload = gs_path("v5_mid_game_reload");
let path_reload = gs_path("v6_mid_game_reload");
let _ = fs::remove_file(&path_reload);
save_game_state_to(&path_reload, &loaded).expect("re-save loaded");
assert_eq!(
+18 -32
View File
@@ -1343,7 +1343,9 @@ mod tests {
use crate::replay_playback::ReplayPlaybackState;
use chrono::NaiveDate;
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
use solitaire_core::{
DrawStockConfig, KlondikeInstruction, SessionRecording, game_state::GameMode,
};
use solitaire_data::Replay;
/// Headless app variant that injects a default `ReplayPlaybackState`
@@ -1364,7 +1366,11 @@ mod tests {
10,
100,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![KlondikeInstruction::RotateStock],
SessionRecording::from_instructions_unchecked(
1,
DrawStockConfig::DrawOne,
[KlondikeInstruction::RotateStock],
),
)
}
@@ -1408,12 +1414,8 @@ mod tests {
// Frame 1: enter Playing. The observer's first sample sees
// `last_was_playing = false` and `now_playing = true`.
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
app.update();
assert!(
!cinephile_unlocked(&app),
@@ -1442,12 +1444,8 @@ mod tests {
fn cinephile_does_not_unlock_on_stop_button_abort() {
let mut app = cinephile_app();
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
app.update();
// Direct Playing → Inactive — the path the Stop button takes via
@@ -1473,12 +1471,8 @@ mod tests {
let mut app = cinephile_app();
// First completion cycle to unlock.
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
app.update();
@@ -1496,12 +1490,8 @@ mod tests {
// Second cycle: Inactive → Playing → Completed once more.
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Inactive;
app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
app.update();
@@ -1520,12 +1510,8 @@ mod tests {
fn cinephile_fires_once_across_completed_linger() {
let mut app = cinephile_app();
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
app.update();
+158 -37
View File
@@ -29,7 +29,9 @@ use crate::events::{
use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource;
use crate::pause_plugin::PausedResource;
use crate::platform::USE_TOUCH_UI_LAYOUT;
use crate::progress_plugin::LevelUpEvent;
use crate::safe_area::SafeAreaAnchoredBottom;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
use crate::time_attack_plugin::TimeAttackEndedEvent;
use crate::ui_theme::{
@@ -160,6 +162,68 @@ pub struct ActiveToast {
/// Duration of each queued info-toast in seconds.
const QUEUED_TOAST_SECS: f32 = 2.5;
/// Marker on the persistent bottom-anchored flex column every toast
/// spawns into (Phase H). Stacking through one container gives queued
/// and immediate toasts a single shared anchor — simultaneous toasts
/// stack upward instead of relying on the old staggered-percentage
/// anchors to dodge each other.
#[derive(Component, Debug)]
pub struct ToastStackRoot;
/// Marker on every toast card node (both paths). Freshly spawned toasts
/// start `Visibility::Hidden` and unparented; [`adopt_toasts_into_stack`]
/// re-parents them under [`ToastStackRoot`] and reveals them — one frame
/// of latency, imperceptible at toast timescales, in exchange for the 14
/// toast handlers keeping their `Commands`-only signatures.
#[derive(Component, Debug)]
pub struct ToastNode;
/// Logical-pixel gap between the screen bottom and the toast stack,
/// before safe-area insets. Clears the Phase F bottom action bar on
/// touch (compact 44px buttons + primary 64px trio + bar padding);
/// desktop's shorter bar needs less.
const TOAST_STACK_BASE_BOTTOM_PX: f32 = if USE_TOUCH_UI_LAYOUT { 112.0 } else { 72.0 };
/// Spawns the persistent [`ToastStackRoot`] container at startup.
fn spawn_toast_stack_root(mut commands: Commands) {
commands.spawn((
ToastStackRoot,
Node {
position_type: PositionType::Absolute,
bottom: Val::Px(TOAST_STACK_BASE_BOTTOM_PX),
left: Val::Px(0.0),
width: Val::Percent(100.0),
// Newest toast sits nearest the bottom edge; older ones
// push upward.
flex_direction: FlexDirection::ColumnReverse,
align_items: AlignItems::Center,
row_gap: VAL_SPACE_2,
..default()
},
SafeAreaAnchoredBottom {
base_bottom: TOAST_STACK_BASE_BOTTOM_PX,
},
ZIndex(Z_TOAST),
));
}
/// Re-parents freshly spawned [`ToastNode`]s under the stack root and
/// reveals them. No-op when every toast is already adopted.
fn adopt_toasts_into_stack(
mut commands: Commands,
orphans: Query<Entity, (With<ToastNode>, Without<ChildOf>)>,
root: Query<Entity, With<ToastStackRoot>>,
) {
let Ok(root) = root.single() else {
return;
};
for toast in &orphans {
commands
.entity(toast)
.insert((ChildOf(root), Visibility::Inherited));
}
}
/// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence.
pub struct AnimationPlugin;
@@ -185,7 +249,7 @@ impl Plugin for AnimationPlugin {
.init_resource::<EffectiveSlideDuration>()
.init_resource::<ToastQueue>()
.init_resource::<ActiveToast>()
.add_systems(Startup, init_slide_duration)
.add_systems(Startup, (init_slide_duration, spawn_toast_stack_root))
.add_systems(
Update,
(
@@ -206,6 +270,7 @@ impl Plugin for AnimationPlugin {
handle_warning_toast,
tick_toasts,
(enqueue_toasts, drive_toast_display).chain(),
adopt_toasts_into_stack,
)
.after(GameMutation),
);
@@ -637,26 +702,14 @@ impl ToastVariant {
}
}
/// Spawns a bottom-anchored `ToastEntity` for the queued toast system.
/// Spawns a `ToastEntity` for the queued toast system.
///
/// Queued toasts always carry [`ToastVariant::Info`] — the queue is fed
/// by [`InfoToastEvent`] which is by definition neutral system info.
/// Variants other than `Info` belong on the immediate-fire path
/// ([`spawn_toast`]) where the call site knows the semantic intent.
fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity {
spawn_toast_node(
commands,
ToastEntity,
message,
ToastVariant::Info,
// Slightly taller anchor than the immediate-fire path so a
// queued info banner doesn't collide with a celebration toast
// fired in the same frame.
Val::Percent(6.0),
Val::Percent(15.0),
Val::Percent(70.0),
UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
)
spawn_toast_node(commands, ToastEntity, message, ToastVariant::Info)
}
fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpAwardedEvent>) {
@@ -744,12 +797,6 @@ fn spawn_toast(
(ToastOverlay, ToastTimer(duration_secs)),
message,
variant,
// Sits above the queued banner so a celebration toast spawned
// alongside a queued info message remains readable.
Val::Percent(14.0),
Val::Percent(25.0),
Val::Percent(50.0),
UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
);
}
@@ -766,31 +813,25 @@ fn spawn_toast(
/// rungs; 18 is the closest rung that preserves the scale invariants
/// tested in `ui_theme::tests`.
/// - [`RADIUS_MD`] corners.
/// - Bottom-anchored absolute position; `bottom_pct` differs between
/// queued and immediate paths so they layer instead of overlap.
// The 8-argument signature is intentional — these are the per-toast
// layout values that genuinely differ between the queued and fire-and-
// forget call sites. A struct wrapper would just rename the same data.
#[allow(clippy::too_many_arguments)]
///
/// Layout is owned by [`ToastStackRoot`] (Phase H): the node spawns
/// hidden and unpositioned, and [`adopt_toasts_into_stack`] slots it
/// into the shared bottom-anchored column — one anchor for every toast,
/// simultaneous toasts stack instead of overlapping.
fn spawn_toast_node<B: Bundle>(
commands: &mut Commands,
bundle: B,
message: String,
variant: ToastVariant,
bottom_pct: Val,
left_pct: Val,
width_pct: Val,
padding: UiRect,
) -> Entity {
commands
.spawn((
bundle,
ToastNode,
Visibility::Hidden,
Node {
position_type: PositionType::Absolute,
left: left_pct,
bottom: bottom_pct,
width: width_pct,
padding,
max_width: Val::Percent(70.0),
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border: UiRect::all(Val::Px(1.0)),
@@ -799,7 +840,6 @@ fn spawn_toast_node<B: Bundle>(
},
BackgroundColor(BG_ELEVATED),
BorderColor::all(variant.border_color()),
ZIndex(Z_TOAST),
))
.with_children(|b| {
b.spawn((
@@ -1315,4 +1355,85 @@ mod tests {
fn cascade_duration_instant_is_zero() {
assert_eq!(cascade_duration_secs(AnimSpeed::Instant), 0.0);
}
// -----------------------------------------------------------------------
// Phase H: unified toast stack
// -----------------------------------------------------------------------
/// Both toast paths must end up as visible children of the single
/// [`ToastStackRoot`] — the Phase H "one anchor" contract.
#[test]
fn queued_and_immediate_toasts_stack_under_one_root() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
app.update(); // Startup: spawns the stack root.
// One immediate (error) toast + one queued (info) toast in the
// same frame — the exact collision case the old staggered
// anchors existed to dodge.
use solitaire_core::{KlondikePile, Tableau};
app.world_mut().write_message(MoveRejectedEvent {
from: KlondikePile::Tableau(Tableau::Tableau1),
to: KlondikePile::Tableau(Tableau::Tableau2),
count: 1,
});
app.world_mut()
.write_message(InfoToastEvent("stacked info".to_string()));
app.update(); // handlers spawn both toasts (hidden, unparented)
app.update(); // adopt_toasts_into_stack re-parents + reveals
let root = app
.world_mut()
.query_filtered::<Entity, With<ToastStackRoot>>()
.single(app.world())
.expect("exactly one ToastStackRoot must exist");
let toasts: Vec<(Entity, &ChildOf, &Visibility)> = app
.world_mut()
.query_filtered::<(Entity, &ChildOf, &Visibility), With<ToastNode>>()
.iter(app.world())
.collect();
assert_eq!(
toasts.len(),
2,
"both the immediate and the queued toast must be adopted"
);
for (entity, child_of, visibility) in toasts {
assert_eq!(
child_of.parent(),
root,
"toast {entity} must be a child of the shared stack root"
);
assert_eq!(
*visibility,
Visibility::Inherited,
"adopted toast {entity} must be revealed"
);
}
}
/// Toast nodes must not carry their own absolute positioning — the
/// stack root owns layout (regression guard against reintroducing
/// per-path anchors).
#[test]
fn toast_nodes_have_no_absolute_position() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
app.update();
app.world_mut()
.write_message(WarningToastEvent("layout check".to_string()));
app.update();
let node = app
.world_mut()
.query_filtered::<&Node, With<ToastNode>>()
.single(app.world())
.expect("warning toast must spawn a ToastNode");
assert_eq!(
node.position_type,
PositionType::Relative,
"toast nodes are flex children of the stack, not absolute overlays"
);
}
}
@@ -201,27 +201,6 @@ pub(crate) fn advance_card_animations(
}
}
// ---------------------------------------------------------------------------
// Win cascade
// ---------------------------------------------------------------------------
/// Win-cascade scatter targets — 8 points beyond the window edges.
///
/// Scaled by `radius` (pass `layout.card_size.x * 8.0` for a good result).
pub fn win_scatter_targets(radius: f32) -> [Vec2; 8] {
let r = radius;
[
Vec2::new(r, r),
Vec2::new(-r, r),
Vec2::new(r, -r),
Vec2::new(-r, -r),
Vec2::new(0.0, r),
Vec2::new(0.0, -r),
Vec2::new(r, 0.0),
Vec2::new(-r, 0.0),
]
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -304,21 +283,4 @@ mod tests {
.with_z_lift(12.0);
assert!((anim.z_lift - 12.0).abs() < 1e-6);
}
#[test]
fn win_scatter_has_eight_targets() {
let targets = win_scatter_targets(800.0);
assert_eq!(targets.len(), 8);
}
#[test]
fn win_scatter_targets_are_off_center() {
for t in win_scatter_targets(400.0) {
let dist = t.length();
assert!(
dist > 100.0,
"scatter target should be well off-center: {t:?}"
);
}
}
}
+2 -87
View File
@@ -31,16 +31,6 @@
//! ));
//! ```
//!
//! # Win cascade with `Expressive` curve
//!
//! The existing `AnimationPlugin` drives the win cascade with `CardAnim`
//! (linear). To use the curve-based cascade instead, disable
//! `handle_win_cascade` in `AnimationPlugin` and register `WinCascadePlugin`
//! (declared below) which uses `CardAnimation` + `MotionCurve::Expressive`.
//!
//! They **must not both be active** — both write to `Transform` on the same
//! 52 entities and will race.
//!
//! # Coexistence rules
//!
//! | Condition | Safe? |
@@ -58,24 +48,21 @@ pub mod interaction;
pub mod timing;
pub mod tuning;
pub use animation::{CardAnimation, win_scatter_targets};
pub use animation::CardAnimation;
pub use chain::AnimationChain;
pub use curves::{MotionCurve, sample_curve};
pub use diagnostics::{FrameTimeDiagnostics, WINDOW_SIZE as DIAG_WINDOW_SIZE};
pub use interaction::{BufferedInput, HoverState, InputBuffer};
pub use timing::{
DEAL_INTERVAL_SECS, MAX_DURATION_SECS, MIN_DURATION_SECS, WIN_CASCADE_INTERVAL_SECS,
cascade_delay, compute_duration, micro_vary,
DEAL_INTERVAL_SECS, MAX_DURATION_SECS, MIN_DURATION_SECS, compute_duration, micro_vary,
};
pub use tuning::{AnimationTuning, InputPlatform};
use bevy::prelude::*;
use bevy::window::RequestRedraw;
use crate::card_plugin::CardEntity;
use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, UndoRequestEvent};
use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource;
use crate::resources::DragState;
use animation::advance_card_animations;
@@ -144,63 +131,6 @@ impl Plugin for CardAnimationPlugin {
}
}
// ---------------------------------------------------------------------------
// Optional: win cascade with Expressive curve
// ---------------------------------------------------------------------------
/// Optional plugin that replaces the linear win cascade in `AnimationPlugin`
/// with an `Expressive`-curve cascade.
///
/// **Do not register this alongside `AnimationPlugin`'s win cascade** — they
/// will race on the same card entities. To use this plugin, prevent
/// `AnimationPlugin` from handling `GameWonEvent` (or remove it and manage
/// win toasts manually).
pub struct WinCascadePlugin;
impl Plugin for WinCascadePlugin {
fn build(&self, app: &mut App) {
app.add_systems(Update, trigger_expressive_win_cascade.after(GameMutation));
}
}
/// Inserts `CardAnimation` (Expressive curve) on every card when `GameWonEvent` fires.
///
/// Cards scatter to 8 off-screen positions with per-card stagger. The z-lift
/// creates a "burst" effect as cards fly outward.
fn trigger_expressive_win_cascade(
mut events: MessageReader<GameWonEvent>,
cards: Query<(Entity, &Transform), With<CardEntity>>,
layout: Option<Res<LayoutResource>>,
mut commands: Commands,
) {
if events.read().next().is_none() {
return;
}
let radius = layout.as_ref().map_or(800.0, |l| l.0.card_size.x * 8.0);
let targets = win_scatter_targets(radius);
for (index, (entity, transform)) in cards.iter().enumerate() {
let start_xy = transform.translation.truncate();
let start_z = transform.translation.z;
let target = targets[index % targets.len()];
commands.entity(entity).insert(
CardAnimation::slide(
start_xy,
start_z,
target,
start_z + 60.0,
MotionCurve::Expressive,
)
.with_delay(cascade_delay(index, WIN_CASCADE_INTERVAL_SECS))
.with_duration(0.65)
.with_z_lift(25.0),
);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -433,19 +363,4 @@ mod tests {
let state = HoverState::default();
assert!(state.entity.is_none());
}
#[test]
fn win_scatter_produces_eight_distinct_points() {
let targets = win_scatter_targets(600.0);
assert_eq!(targets.len(), 8);
// All must be different.
for i in 0..8 {
for j in (i + 1)..8 {
assert_ne!(
targets[i], targets[j],
"scatter targets {i} and {j} must be distinct"
);
}
}
}
}
@@ -49,17 +49,6 @@ pub fn micro_vary(duration: f32, entity_index: u32) -> f32 {
duration * (1.0 + variation)
}
/// Returns the pre-animation delay for card at `index` in a staggered cascade.
///
/// `delay = index × interval_secs`.
#[inline]
pub fn cascade_delay(index: usize, interval_secs: f32) -> f32 {
index as f32 * interval_secs
}
/// Recommended per-card interval for the win cascade (Normal speed).
pub const WIN_CASCADE_INTERVAL_SECS: f32 = 0.018;
/// Recommended per-card interval for deal animations (Normal speed).
pub const DEAL_INTERVAL_SECS: f32 = 0.022;
@@ -137,22 +126,4 @@ mod tests {
"micro_vary should differ for different indices"
);
}
#[test]
fn cascade_delay_zero_index_is_zero() {
assert_eq!(cascade_delay(0, 0.018), 0.0);
}
#[test]
fn cascade_delay_scales_linearly() {
let interval = 0.018;
for i in 0..52usize {
let expected = i as f32 * interval;
let actual = cascade_delay(i, interval);
assert!(
(actual - expected).abs() < 1e-6,
"cascade_delay({i}) = {actual}, expected {expected}"
);
}
}
}
-11
View File
@@ -5,7 +5,6 @@ use solitaire_core::KlondikePile;
use solitaire_core::game_state::GameMode;
use solitaire_core::{Card, Suit};
use solitaire_data::AchievementRecord;
use solitaire_sync::SyncResponse;
/// Request to move `count` cards from `from` to `to`. Fired by input systems,
/// consumed by `GamePlugin`.
@@ -248,16 +247,6 @@ pub struct ToggleLeaderboardRequestEvent;
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ToggleHomeRequestEvent;
/// Fired by `SyncPlugin` after a pull task resolves and the merged result has
/// been persisted to disk. `Ok(SyncResponse)` carries the merged payload plus
/// any `ConflictReport`s the merge produced. `Err(String)` carries a
/// human-readable failure message (network, auth, serialization, etc.).
///
/// UI systems listen for this to refresh views without polling
/// `SyncStatusResource`. See [ARCHITECTURE.md §4](../../ARCHITECTURE.md).
#[derive(Message, Debug, Clone)]
pub struct SyncCompleteEvent(pub Result<SyncResponse, String>);
/// Generic informational toast message. Any system can fire this to display
/// a short string to the player, e.g. "Locked — reach level 5".
#[derive(Message, Debug, Clone)]
+15 -2
View File
@@ -86,6 +86,14 @@ pub struct NewGameRequestWriters;
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct UndoRequestWriters;
/// Self-ambiguous set for writers of `DrawRequestEvent` — same rationale as
/// [`NewGameRequestWriters`]: consumers drain the queue, append order is
/// meaningless (#143). Members: keyboard `D`/`Space`, stock click, touch
/// stock tap (input_plugin), and the touch action bar's Draw button
/// (hud_plugin, Phase F).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct DrawRequestWriters;
/// Self-ambiguous set for writers of `InfoToastEvent` — toasts queue in
/// arrival order and any same-frame order is fine (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
@@ -1062,11 +1070,16 @@ pub fn record_replay_on_win(
if recording.moves.is_empty() {
continue;
}
// The session itself is the authoritative recording: its history
// holds the dealt board plus the forward instruction list (undos
// already popped), and it serialises via the upstream card_game
// serializers so playback never re-deals from the seed.
let session_recording = game.0.recording();
// Recording freezes on win, so the move that triggered the
// win condition is the last one in the list. Storing the
// index explicitly lets the playback UI read the WIN MOVE
// position directly instead of re-deriving it on every render.
let win_move_index = recording.moves.len().checked_sub(1);
let win_move_index = session_recording.len().checked_sub(1);
let replay = Replay::new(
game.0.seed,
game.0.draw_mode(),
@@ -1074,7 +1087,7 @@ pub fn record_replay_on_win(
ev.time_seconds,
ev.score,
Utc::now().date_naive(),
recording.moves.clone(),
session_recording,
)
.with_win_move_index(win_move_index);
let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) else {
+18 -14
View File
@@ -853,17 +853,15 @@ fn replay_recording_freezes_into_replay_on_game_won() {
let mut app = test_app(7654);
app.insert_resource(ReplayPath(Some(path.clone())));
// Push two recorded instructions manually so we can verify they
// survive the freeze/save round-trip without having to drive a
// real win. Both are `RotateStock` the only instruction
// constructible without the runtime-only `klondike` pile-stack
// types (which the engine intentionally does not depend on); the
// round-trip shape is identical for any instruction variant.
{
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
recording.moves.push(KlondikeInstruction::RotateStock);
recording.moves.push(KlondikeInstruction::RotateStock);
}
// Drive two real draws so the *session* history (the source the
// freeze now serialises from, via `GameState::recording()`) holds
// two instructions. `RotateStock` is the only instruction the
// engine can drive without the runtime-only `klondike` pile-stack
// types; the round-trip shape is identical for any variant.
app.world_mut().write_message(DrawRequestEvent);
app.update();
app.world_mut().write_message(DrawRequestEvent);
app.update();
// Fire the win event the engine emits when the last foundation
// completes — `record_replay_on_win` listens for it.
@@ -895,9 +893,15 @@ fn replay_recording_freezes_into_replay_on_game_won() {
loaded.time_seconds, 250,
"time_seconds must come from the win event"
);
assert_eq!(loaded.moves.len(), 2, "every recorded move must round-trip");
assert!(matches!(loaded.moves[0], KlondikeInstruction::RotateStock));
assert!(matches!(loaded.moves[1], KlondikeInstruction::RotateStock));
let instructions = loaded.recording.instructions();
assert_eq!(instructions.len(), 2, "every recorded move must round-trip");
assert!(matches!(instructions[0], KlondikeInstruction::RotateStock));
assert!(matches!(instructions[1], KlondikeInstruction::RotateStock));
assert_eq!(
loaded.win_move_index,
Some(1),
"win move index must point at the last recorded instruction",
);
#[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(&path);
File diff suppressed because it is too large Load Diff
@@ -30,6 +30,86 @@ pub(super) fn handle_undo_button(
}
}
/// Seconds the Undo button must be continuously held before hold-to-repeat
/// kicks in. Long enough that a normal tap (press + release inside one or
/// two frames) never triggers a second undo.
const UNDO_HOLD_INITIAL_DELAY_SECS: f32 = 0.45;
/// Interval between repeated undos while the hold continues. ~5.5 undos/s —
/// fast enough to unwind a long line, slow enough to release in time when
/// the board reaches the state the player wants.
const UNDO_HOLD_REPEAT_INTERVAL_SECS: f32 = 0.18;
/// Hold-to-repeat undo (Phase F): while the Undo button stays pressed,
/// fire additional [`UndoRequestEvent`]s after an initial delay, one per
/// repeat interval. The plain tap path stays in [`handle_undo_button`]
/// (its `Changed<Interaction>` filter fires exactly once per press);
/// this system only adds events once the hold outlives the delay, so a
/// tap never double-undoes.
///
/// Each repeat goes through the normal request queue — the scoring
/// penalty and No-Undo-mode gating in the consumer apply to every step.
pub(super) fn repeat_undo_on_hold(
time: Res<Time>,
buttons: Query<&Interaction, With<UndoButton>>,
mut state: ResMut<UndoHoldState>,
mut undo: MessageWriter<UndoRequestEvent>,
) {
let held = buttons.iter().any(|i| *i == Interaction::Pressed);
if !held {
if state.held_secs != 0.0 {
state.held_secs = 0.0;
}
return;
}
let before = state.held_secs;
state.held_secs += time.delta_secs();
if undo_hold_crossed_fire_boundary(before, state.held_secs) {
undo.write(UndoRequestEvent);
}
}
/// `true` when a hold that lasted `before` seconds at the previous frame
/// and `now` seconds this frame should fire a repeat undo: once when the
/// hold first outlives the initial delay, then once per repeat-interval
/// boundary. Pure so the timing contract is unit-testable without
/// fighting `Time`.
pub(super) fn undo_hold_crossed_fire_boundary(before: f32, now: f32) -> bool {
if now < UNDO_HOLD_INITIAL_DELAY_SECS {
return false;
}
if before < UNDO_HOLD_INITIAL_DELAY_SECS {
// Crossed the initial-delay threshold this frame — first repeat.
return true;
}
// Fire once each time the hold crosses another repeat-interval boundary.
let intervals_before =
((before - UNDO_HOLD_INITIAL_DELAY_SECS) / UNDO_HOLD_REPEAT_INTERVAL_SECS).floor();
let intervals_now =
((now - UNDO_HOLD_INITIAL_DELAY_SECS) / UNDO_HOLD_REPEAT_INTERVAL_SECS).floor();
intervals_now > intervals_before
}
/// Click on the touch action bar's Draw button — same
/// [`DrawRequestEvent`] the stock-pile tap writes, so the consumer's
/// rules (recycle, won-game rejection) apply identically. Skipped while
/// paused, mirroring `handle_stock_click`'s guard.
pub(super) fn handle_draw_button(
interaction_query: Query<&Interaction, (With<DrawButton>, Changed<Interaction>)>,
paused: Option<Res<PausedResource>>,
mut draw: MessageWriter<DrawRequestEvent>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
for interaction in &interaction_query {
if *interaction == Interaction::Pressed {
draw.write(DrawRequestEvent);
}
}
}
pub(super) fn handle_pause_button(
interaction_query: Query<&Interaction, (With<PauseButton>, Changed<Interaction>)>,
mut pause: MessageWriter<PauseRequestEvent>,
+37 -19
View File
@@ -28,7 +28,7 @@ struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::events::{
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
DrawRequestEvent, HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleHomeRequestEvent,
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
@@ -313,6 +313,18 @@ pub struct UndoButton;
#[derive(Component, Debug)]
pub struct PauseButton;
/// Accumulated hold time on the Undo button, driving hold-to-repeat undo
/// (Phase F). `handle_undo_button` owns the instant first undo on press;
/// `repeat_undo_on_hold` starts firing additional [`UndoRequestEvent`]s
/// once the hold passes `UNDO_HOLD_INITIAL_DELAY_SECS` and then every
/// `UNDO_HOLD_REPEAT_INTERVAL_SECS`. Each repeated undo goes through the
/// normal request path, so the existing scoring penalty applies per step.
#[derive(Resource, Debug, Default)]
pub struct UndoHoldState {
/// Seconds the Undo button has been continuously held.
held_secs: f32,
}
/// Marker on the "Help" action button. Click fires [`HelpRequestEvent`],
/// mirroring the `F1` keyboard accelerator.
#[derive(Component, Debug)]
@@ -323,31 +335,27 @@ pub struct HelpButton;
#[derive(Component, Debug)]
pub struct HintButton;
/// Marker on the touch action bar's "Draw" button (Phase F). Click fires
/// [`DrawRequestEvent`] — same queue as tapping the stock pile — so the
/// draw action is within thumb reach without stretching to the top of a
/// tall folded screen. Touch layout only; desktop draws via stock
/// click / `D` / `Space`.
#[derive(Component, Debug)]
pub struct DrawButton;
/// Android HUD label for the Hint button — shared with the help screen's
/// controls reference so both always agree.
#[cfg(target_os = "android")]
pub(crate) const ANDROID_HINT_LABEL: &str = "Hint";
/// Hint label used by the touch action bar. Aliases [`ANDROID_HINT_LABEL`]
/// on Android so the bar and the help screen's controls reference can
/// never drift; the non-Android value only exists so the touch spawn
/// path stays host-compilable for tests.
#[cfg(target_os = "android")]
const ACTION_BAR_LABELS: [&str; 7] = [
"Menu",
"Undo",
"Pause",
"Help",
ANDROID_HINT_LABEL,
"Mode",
"New",
];
const TOUCH_HINT_LABEL: &str = ANDROID_HINT_LABEL;
#[cfg(not(target_os = "android"))]
const ACTION_BAR_LABELS: [&str; 7] = [
"Menu \u{2193}",
"Undo",
"Pause",
"Help",
"Hint",
"Modes \u{2193}",
"New Game",
];
const TOUCH_HINT_LABEL: &str = "Hint";
#[cfg(target_os = "android")]
const ACTION_BAR_COLUMN_GAP: Val = Val::Px(4.0);
#[cfg(not(target_os = "android"))]
@@ -455,6 +463,7 @@ impl Plugin for HudPlugin {
// idempotent.
app.add_message::<NewGameRequestEvent>()
.add_message::<UndoRequestEvent>()
.add_message::<DrawRequestEvent>()
.add_message::<PauseRequestEvent>()
.add_message::<HelpRequestEvent>()
.add_message::<StartZenRequestEvent>()
@@ -471,6 +480,7 @@ impl Plugin for HudPlugin {
.init_resource::<PreviousScore>()
.init_resource::<HudActionFade>()
.init_resource::<HudVisibility>()
.init_resource::<UndoHoldState>()
// Escape-close handlers for popovers read this; init defensively
// so HudPlugin works under MinimalPlugins in tests.
.init_resource::<ButtonInput<KeyCode>>()
@@ -560,6 +570,14 @@ impl Plugin for HudPlugin {
.in_set(crate::game_plugin::UndoRequestWriters)
.ambiguous_with(crate::game_plugin::UndoRequestWriters)
.before(GameMutation),
repeat_undo_on_hold
.in_set(crate::game_plugin::UndoRequestWriters)
.ambiguous_with(crate::game_plugin::UndoRequestWriters)
.before(GameMutation),
handle_draw_button
.in_set(crate::game_plugin::DrawRequestWriters)
.ambiguous_with(crate::game_plugin::DrawRequestWriters)
.before(GameMutation),
handle_pause_button,
handle_help_button,
handle_hint_button
+201 -76
View File
@@ -400,83 +400,152 @@ pub(super) fn spawn_action_buttons(
HudActionBar,
))
.with_children(|row| {
// The trailing `order` argument feeds `Focusable { group: Hud, order }`
// so Tab cycles the action bar in visual reading order.
// Undo and Pause are the primary gameplay actions — full brightness.
// Menu, Help, Hint, Modes, New are navigation/utility — dimmed.
spawn_action_button(
row,
MenuButton,
ACTION_BAR_LABELS[0],
None,
"Open Stats, Achievements, Profile, Settings, or Leaderboard.",
&font,
0,
TEXT_SECONDARY,
);
spawn_action_button(
row,
UndoButton,
ACTION_BAR_LABELS[1],
Some("U"),
"Take back your last move. Costs points and blocks No Undo.",
&font,
1,
TEXT_PRIMARY,
);
spawn_action_button(
row,
PauseButton,
ACTION_BAR_LABELS[2],
Some("Esc"),
"Pause the game and freeze the timer.",
&font,
2,
TEXT_PRIMARY,
);
spawn_action_button(
row,
HelpButton,
ACTION_BAR_LABELS[3],
Some("F1"),
"Show controls, rules, and keyboard shortcuts.",
&font,
3,
TEXT_SECONDARY,
);
spawn_action_button(
row,
HintButton,
ACTION_BAR_LABELS[4],
Some("H"),
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
&font,
4,
TEXT_SECONDARY,
);
spawn_action_button(
row,
ModesButton,
ACTION_BAR_LABELS[5],
None,
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
&font,
5,
TEXT_SECONDARY,
);
spawn_action_button(
row,
NewGameButton,
ACTION_BAR_LABELS[6],
Some("N"),
"Start a fresh deal. Confirms first if a game is in progress.",
&font,
6,
TEXT_SECONDARY,
);
if USE_TOUCH_UI_LAYOUT {
spawn_touch_action_bar(row, &font);
} else {
spawn_desktop_action_bar(row, &font);
}
});
}
/// Phase F touch action bar — five buttons, three of them big.
///
/// The core gameplay trio (**Undo · Draw · Hint**) gets enlarged
/// thumb-reach targets; Menu and Pause stay compact at the edges. The
/// utility actions the desktop bar carries are reachable elsewhere on
/// touch and are deliberately absent here: Help lives in Menu → System,
/// mode switching lives on the Home screen (Phase B), and New Game is
/// Home's hero button. Draw duplicates the stock-pile tap so the most
/// frequent action of all no longer requires reaching the top half of a
/// tall folded screen.
pub(super) fn spawn_touch_action_bar(row: &mut ChildSpawnerCommands, font: &TextFont) {
// The trailing `order` argument feeds `Focusable { group: Hud, order }`
// so Tab (external keyboard) cycles the bar in visual reading order.
spawn_action_button(
row,
MenuButton,
"Menu",
None,
"Open Home, Stats, Achievements, Profile, Settings, or Leaderboard.",
font,
0,
TEXT_SECONDARY,
);
spawn_primary_action_button(
row,
UndoButton,
"Undo",
"Take back your last move. Hold to keep undoing. Costs points and blocks No Undo.",
font,
1,
);
spawn_primary_action_button(
row,
DrawButton,
"Draw",
"Draw from the stock — same as tapping the deck.",
font,
2,
);
spawn_primary_action_button(
row,
HintButton,
TOUCH_HINT_LABEL,
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
font,
3,
);
spawn_action_button(
row,
PauseButton,
"Pause",
None,
"Pause the game and freeze the timer.",
font,
4,
TEXT_SECONDARY,
);
}
/// Desktop action bar — unchanged by Phase F (decision 5: the touch bar
/// is touch-only). All seven actions, uniform sizing.
pub(super) fn spawn_desktop_action_bar(row: &mut ChildSpawnerCommands, font: &TextFont) {
// The trailing `order` argument feeds `Focusable { group: Hud, order }`
// so Tab cycles the action bar in visual reading order.
// Undo and Pause are the primary gameplay actions — full brightness.
// Menu, Help, Hint, Modes, New are navigation/utility — dimmed.
spawn_action_button(
row,
MenuButton,
"Menu \u{2193}",
None,
"Open Stats, Achievements, Profile, Settings, or Leaderboard.",
font,
0,
TEXT_SECONDARY,
);
spawn_action_button(
row,
UndoButton,
"Undo",
Some("U"),
"Take back your last move. Hold to keep undoing. Costs points and blocks No Undo.",
font,
1,
TEXT_PRIMARY,
);
spawn_action_button(
row,
PauseButton,
"Pause",
Some("Esc"),
"Pause the game and freeze the timer.",
font,
2,
TEXT_PRIMARY,
);
spawn_action_button(
row,
HelpButton,
"Help",
Some("F1"),
"Show controls, rules, and keyboard shortcuts.",
font,
3,
TEXT_SECONDARY,
);
spawn_action_button(
row,
HintButton,
"Hint",
Some("H"),
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
font,
4,
TEXT_SECONDARY,
);
spawn_action_button(
row,
ModesButton,
"Modes \u{2193}",
None,
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
font,
5,
TEXT_SECONDARY,
);
spawn_action_button(
row,
NewGameButton,
"New Game",
Some("N"),
"Start a fresh deal. Confirms first if a game is in progress.",
font,
6,
TEXT_SECONDARY,
);
}
/// Spawns a single action button as a child of `row`. Each button shares
/// the same node geometry, idle colour, and `ActionButton` marker so
/// `paint_action_buttons` can recolour all of them with one query.
@@ -510,13 +579,69 @@ pub(super) fn spawn_action_button<M: Component>(
None
};
let (pad, min_w, min_h) = action_button_metrics();
spawn_action_button_sized(
row, marker, label, hotkey, tooltip, font, order, text_color, pad, min_w, min_h,
);
}
/// Enlarged variant for the touch bar's core gameplay trio (Phase F):
/// bigger padding / minimum target and a scaled-up label so Undo, Draw,
/// and Hint read (and hit) as the primary actions. Hotkey chips are
/// irrelevant on touch, so the variant takes none.
pub(super) fn spawn_primary_action_button<M: Component>(
row: &mut ChildSpawnerCommands,
marker: M,
label: &str,
tooltip: &'static str,
font: &TextFont,
order: i32,
) {
let (pad, min_w, min_h) = primary_action_button_metrics();
let primary_font = TextFont {
font: font.font.clone(),
// 1.35× the bar's responsive size, capped so landscape tablets
// don't blow the row height out.
font_size: (font.font_size * 1.35).min(24.0),
..default()
};
spawn_action_button_sized(
row,
marker,
label,
None,
tooltip,
&primary_font,
order,
TEXT_PRIMARY,
pad,
min_w,
min_h,
);
}
/// Shared body of [`spawn_action_button`] / [`spawn_primary_action_button`]
/// — one place owns the component set so `paint_action_buttons`, tooltips,
/// and the focus ring treat every bar button identically.
#[allow(clippy::too_many_arguments)]
fn spawn_action_button_sized<M: Component>(
row: &mut ChildSpawnerCommands,
marker: M,
label: &str,
hotkey: Option<&'static str>,
tooltip: &'static str,
font: &TextFont,
order: i32,
text_color: Color,
pad: UiRect,
min_w: Val,
min_h: Val,
) {
let hotkey_font = TextFont {
font: font.font.clone(),
font_size: TYPE_CAPTION,
..default()
};
let (pad, min_w, min_h) = action_button_metrics();
row.spawn((
marker,
ActionButton,
+155 -1
View File
@@ -670,7 +670,7 @@ fn hud_elements_carry_expected_tooltip_strings() {
);
assert_eq!(
tooltip_for::<UndoButton>(&mut app),
"Take back your last move. Costs points and blocks No Undo."
"Take back your last move. Hold to keep undoing. Costs points and blocks No Undo."
);
assert_eq!(
tooltip_for::<PauseButton>(&mut app),
@@ -899,3 +899,157 @@ fn hud_focus_only_engages_when_button_hovered() {
"Hud-engaged Tab must focus a Hud-grouped entity"
);
}
// ---------------------------------------------------------------------------
// Phase F: touch action bar + hold-to-repeat undo
// ---------------------------------------------------------------------------
/// Bare app (no `HudPlugin`) so bar-content assertions count only the
/// buttons the tested spawn function creates — `headless_app` would
/// pre-spawn the platform-default bar and pollute the counts.
fn bar_only_app(touch: bool) -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.update();
let font = TextFont::default();
let world = app.world_mut();
let mut commands = world.commands();
commands.spawn(Node::default()).with_children(|row| {
if touch {
spawn_touch_action_bar(row, &font);
} else {
spawn_desktop_action_bar(row, &font);
}
});
app.update();
app
}
fn count_buttons<C: Component>(app: &mut App) -> usize {
app.world_mut()
.query_filtered::<(), With<C>>()
.iter(app.world())
.count()
}
#[test]
fn touch_action_bar_has_thumb_trio_plus_menu_and_pause() {
let mut app = bar_only_app(true);
assert_eq!(count_buttons::<UndoButton>(&mut app), 1);
assert_eq!(count_buttons::<DrawButton>(&mut app), 1);
assert_eq!(count_buttons::<HintButton>(&mut app), 1);
assert_eq!(count_buttons::<MenuButton>(&mut app), 1);
assert_eq!(count_buttons::<PauseButton>(&mut app), 1);
// Utility actions live elsewhere on touch: Help in Menu → System,
// modes on the Home screen, New Game on Home's hero button.
assert_eq!(count_buttons::<HelpButton>(&mut app), 0);
assert_eq!(count_buttons::<ModesButton>(&mut app), 0);
assert_eq!(count_buttons::<NewGameButton>(&mut app), 0);
}
#[test]
fn desktop_action_bar_keeps_all_seven_and_no_draw() {
let mut app = bar_only_app(false);
for (count, name) in [
(count_buttons::<MenuButton>(&mut app), "Menu"),
(count_buttons::<UndoButton>(&mut app), "Undo"),
(count_buttons::<PauseButton>(&mut app), "Pause"),
(count_buttons::<HelpButton>(&mut app), "Help"),
(count_buttons::<HintButton>(&mut app), "Hint"),
(count_buttons::<ModesButton>(&mut app), "Modes"),
(count_buttons::<NewGameButton>(&mut app), "New Game"),
] {
assert_eq!(count, 1, "desktop bar must keep its {name} button");
}
assert_eq!(
count_buttons::<DrawButton>(&mut app),
0,
"Draw is touch-only (decision 5); desktop draws via stock click / D / Space"
);
}
#[test]
fn draw_button_press_fires_draw_request() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<Messages<DrawRequestEvent>>()
.clear();
app.world_mut().spawn((DrawButton, Interaction::Pressed));
app.update();
let events = app.world().resource::<Messages<DrawRequestEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(
cursor.read(events).count(),
1,
"pressing the Draw button must fire exactly one DrawRequestEvent"
);
}
#[test]
fn draw_button_press_is_noop_while_paused() {
let mut app = headless_app();
app.insert_resource(PausedResource(true));
app.world_mut()
.resource_mut::<Messages<DrawRequestEvent>>()
.clear();
app.world_mut().spawn((DrawButton, Interaction::Pressed));
app.update();
let events = app.world().resource::<Messages<DrawRequestEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(
cursor.read(events).count(),
0,
"the Draw button must be inert while paused (mirrors the stock-click guard)"
);
}
/// The timing contract for hold-to-repeat undo, exercised through the
/// pure boundary function so no wall-clock is involved. Delay = 0.45 s,
/// interval = 0.18 s → boundaries at 0.45, 0.63, 0.81, …
#[test]
fn undo_hold_boundary_math() {
// A tap never repeats.
assert!(!undo_hold_crossed_fire_boundary(0.0, 0.05));
assert!(!undo_hold_crossed_fire_boundary(0.2, 0.44));
// Crossing the initial delay fires the first repeat.
assert!(undo_hold_crossed_fire_boundary(0.44, 0.46));
// Inside the first repeat interval: quiet.
assert!(!undo_hold_crossed_fire_boundary(0.46, 0.62));
// Each interval boundary fires exactly once.
assert!(undo_hold_crossed_fire_boundary(0.62, 0.64));
assert!(!undo_hold_crossed_fire_boundary(0.64, 0.80));
assert!(undo_hold_crossed_fire_boundary(0.80, 0.82));
}
#[test]
fn undo_hold_state_accumulates_and_resets_on_release() {
let mut app = headless_app();
let button = app
.world_mut()
.spawn((UndoButton, Interaction::Pressed))
.id();
app.update();
app.update();
app.update();
assert!(
app.world().resource::<UndoHoldState>().held_secs > 0.0,
"held time must accumulate while the Undo button stays pressed"
);
app.world_mut().entity_mut(button).insert(Interaction::None);
app.update();
assert_eq!(
app.world().resource::<UndoHoldState>().held_secs,
0.0,
"releasing the Undo button must reset the hold state"
);
}
+19 -5
View File
@@ -554,11 +554,11 @@ pub(super) fn action_bar_font_size(window_width: f32) -> f32 {
pub(super) fn action_button_metrics() -> (UiRect, Val, Val) {
if USE_TOUCH_UI_LAYOUT {
// Tight 3 px horizontal padding (down from 4) trims 14 px off the row
// total across 7 buttons, and a 44 px min_width (down from 52) lets the
// shortest labels ("New", "Help") shrink to their text rather than
// padding the row out past the 900 logical-px viewport. min_height
// stays at 44 px to preserve the comfortable touch target.
// Tight 3 px horizontal padding keeps the compact buttons narrow;
// Phase F trimmed the touch bar to 5 buttons (2 compact + 3
// primary), so width pressure is lower than the old 7-button row.
// min_height stays at 44 px to preserve the comfortable touch
// target on the compact buttons.
(
UiRect::axes(Val::Px(3.0), Val::Px(4.0)),
Val::Px(44.0),
@@ -573,6 +573,20 @@ pub(super) fn action_button_metrics() -> (UiRect, Val, Val) {
}
}
/// Metrics for the touch bar's enlarged Undo / Draw / Hint trio (Phase F).
/// 96×64 px targets — comfortably past the 44 px accessibility floor and
/// big enough to hit one-handed without looking. Only the touch layout
/// spawns primary buttons, so no desktop branch is needed; the desktop
/// values exist purely so host-side tests can exercise the touch spawn
/// path with sensible numbers.
pub(super) fn primary_action_button_metrics() -> (UiRect, Val, Val) {
(
UiRect::axes(Val::Px(10.0), Val::Px(8.0)),
Val::Px(96.0),
Val::Px(64.0),
)
}
pub(super) fn spawn_action_button_label(
parent: &mut ChildSpawnerCommands,
label: &str,
+65 -78
View File
@@ -136,11 +136,22 @@ impl Plugin for InputPlugin {
.add_systems(
Update,
(
handle_keyboard_core,
// The three `DrawRequestEvent` writers join the
// self-ambiguous `DrawRequestWriters` set so the HUD's
// touch Draw button (hud_plugin, Phase F) can write the
// same queue without tripping the ambiguity gate. The
// `.chain()` still orders them relative to each other.
handle_keyboard_core
.in_set(crate::game_plugin::DrawRequestWriters)
.ambiguous_with(crate::game_plugin::DrawRequestWriters),
handle_keyboard_hint,
handle_keyboard_forfeit,
handle_stock_click,
handle_touch_stock_tap,
handle_stock_click
.in_set(crate::game_plugin::DrawRequestWriters)
.ambiguous_with(crate::game_plugin::DrawRequestWriters),
handle_touch_stock_tap
.in_set(crate::game_plugin::DrawRequestWriters)
.ambiguous_with(crate::game_plugin::DrawRequestWriters),
handle_double_click,
// Mouse drag pipeline.
start_drag,
@@ -1370,17 +1381,37 @@ pub fn best_tableau_destination_for_stack(
None
}
/// Decide the auto-move for the face-up run headed by the clicked/tapped card.
///
/// The move covers **exactly** `run_len` cards — the run from the clicked
/// card to the top of its pile. A lone top card goes to its best foundation
/// (or tableau) destination; a multi-card run goes whole to the best tableau
/// column. Runs larger or smaller than the clicked one are never considered.
///
/// Returns `(destination, count)`, or `None` when the clicked run has no
/// legal destination.
pub fn auto_move_for_run(
clicked_card: &Card,
pile: &KlondikePile,
game: &GameState,
run_len: usize,
) -> Option<(KlondikePile, usize)> {
if run_len == 1 {
best_destination(clicked_card, game).map(|dest| (dest, 1))
} else {
best_tableau_destination_for_stack(clicked_card, pile, game, run_len)
}
}
/// System that detects double-clicks on face-up cards and fires `MoveRequestEvent`
/// to the best legal destination.
///
/// Move priority:
/// 1. Move the single **top** card to its best foundation (or tableau) destination.
/// 2. If no single-card move exists and the clicked card is the base of a
/// multi-card face-up stack, move the whole stack to the best tableau column.
/// The move covers exactly the face-up run headed by the clicked card —
/// see [`auto_move_for_run`].
///
/// When a multi-card stack double-click finds no legal destination (Priority 2
/// returns `None`), fires `MoveRejectedEvent` with `from == to == pile` so the
/// invalid-move sound plays and the source pile cards shake as feedback.
/// When the clicked run has no legal destination, fires `MoveRejectedEvent`
/// with `from == to == pile` so the invalid-move sound plays and the source
/// pile cards shake as feedback.
#[allow(clippy::too_many_arguments)]
fn handle_double_click(
buttons: Res<ButtonInput<MouseButton>>,
@@ -1411,7 +1442,11 @@ fn handle_double_click(
return;
};
// The topmost card in the draggable run — used as the double-click key.
// The clicked card heads the run and keys the double-click: two clicks
// on different cards of the same stack are not a double-click.
let Some(clicked_card) = card_ids.first() else {
return;
};
let Some(top_card) = card_ids.last() else {
return;
};
@@ -1426,31 +1461,15 @@ fn handle_double_click(
let now = time.elapsed_secs();
let prev = last_click
.get(top_card)
.get(clicked_card)
.copied()
.unwrap_or(f32::NEG_INFINITY);
if now - prev <= DOUBLE_CLICK_WINDOW {
// Double-click confirmed.
last_click.remove(top_card);
last_click.remove(clicked_card);
// Priority 1: move the single top card (foundation preferred, then tableau).
if let Some(dest) = best_destination(top_card, &game.0) {
moves.write(MoveRequestEvent {
from: pile,
to: dest,
count: 1,
});
return;
}
// Priority 2: if the player clicked the base of a multi-card face-up
// stack (card_ids.len() > 1), try moving the whole stack to another
// tableau column.
if card_ids.len() > 1
&& let Some((bottom_card, _)) = pile_cards.get(stack_index)
&& let Some((dest, count)) =
best_tableau_destination_for_stack(bottom_card, &pile, &game.0, card_ids.len())
if let Some((dest, count)) = auto_move_for_run(clicked_card, &pile, &game.0, card_ids.len())
{
moves.write(MoveRequestEvent {
from: pile,
@@ -1460,14 +1479,10 @@ fn handle_double_click(
return;
}
// Both priorities failed — play the invalid-move sound and shake
// the source pile as feedback. `MoveRejectedEvent` with
// `from == to` routes the shake to the source pile (which
// `start_shake_anim` reads from `ev.to`). Pre-fix, this branch
// only fired for multi-card stacks, so a double-click on a
// single card with no legal destination did nothing — no
// sound, no shake. Now both single-card and stack misses get
// the same feedback.
// No legal destination for the clicked run — play the invalid-move
// sound and shake the source pile as feedback. `MoveRejectedEvent`
// with `from == to` routes the shake to the source pile (which
// `start_shake_anim` reads from `ev.to`).
rejected.write(MoveRejectedEvent {
from: pile,
to: pile,
@@ -1475,7 +1490,7 @@ fn handle_double_click(
});
} else {
// Single click — record the time.
last_click.insert(top_card.clone(), now);
last_click.insert(clicked_card.clone(), now);
}
}
@@ -1491,10 +1506,9 @@ fn handle_double_click(
/// `cards`, and `origin_pile`; once `touch_end_drag` fires those fields
/// are cleared and the tap/drag distinction is permanently lost.
///
/// Move priority:
/// 1. Single top card to its best foundation (or tableau).
/// 2. Whole face-up run to best tableau column when no single-card move exists.
/// 3. `MoveRejectedEvent` for audio + shake feedback when no legal move found.
/// The move covers exactly the face-up run headed by the tapped card —
/// see [`auto_move_for_run`]. Fires `MoveRejectedEvent` for audio + shake
/// feedback when the tapped run has no legal destination.
#[allow(clippy::too_many_arguments)]
fn handle_double_tap(
mut touch_events: MessageReader<TouchInput>,
@@ -1554,8 +1568,7 @@ fn handle_double_tap(
return;
}
let Some((found_card, found_face_up)) = pile_cards.iter().find(|(c, _)| c == top_card)
else {
let Some((_, found_face_up)) = pile_cards.iter().find(|(c, _)| c == top_card) else {
return;
};
if !*found_face_up {
@@ -1591,53 +1604,27 @@ fn handle_double_tap(
// --- One-tap auto-move (original behaviour) ---
// Priority 1: move single top card.
if let Some(dest) = best_destination(found_card, &game.0) {
// Move exactly the run headed by the tapped card.
if let Some(tapped_card) = drag.cards.first()
&& let Some((dest, count)) =
auto_move_for_run(tapped_card, tapped_pile, &game.0, drag.cards.len())
{
for (entity, ce, mut sprite) in card_sprites.iter_mut() {
if ce.card == *top_card {
if drag.cards.contains(&ce.card) {
sprite.color = STATE_SUCCESS;
commands.entity(entity).insert(HintHighlight {
remaining: DOUBLE_TAP_FLASH_SECS,
});
break;
}
}
moves.write(MoveRequestEvent {
from: *tapped_pile,
to: dest,
count: 1,
count,
});
return;
}
// Priority 2: move whole face-up stack to best tableau column.
if drag.cards.len() > 1 {
let stack_index = pile_cards.len() - drag.cards.len();
if let Some((bottom_card, _)) = pile_cards.get(stack_index)
&& let Some((dest, count)) = best_tableau_destination_for_stack(
bottom_card,
tapped_pile,
&game.0,
drag.cards.len(),
)
{
for (entity, ce, mut sprite) in card_sprites.iter_mut() {
if drag.cards.contains(&ce.card) {
sprite.color = STATE_SUCCESS;
commands.entity(entity).insert(HintHighlight {
remaining: DOUBLE_TAP_FLASH_SECS,
});
}
}
moves.write(MoveRequestEvent {
from: *tapped_pile,
to: dest,
count,
});
return;
}
}
rejected.write(MoveRejectedEvent {
from: *tapped_pile,
to: *tapped_pile,
+112
View File
@@ -429,6 +429,118 @@ fn best_tableau_destination_for_stack_returns_none_when_no_legal_move() {
);
}
// -----------------------------------------------------------------------
// auto_move_for_run pure-function tests (issue #158)
// -----------------------------------------------------------------------
//
// These need real positions — `can_move_cards` validates against the live
// session, not the `set_test_*` overlays. Seeds 51 and 145 both deal an
// Ace and its Two on tableau tops plus an opposite-color Three elsewhere,
// letting two moves build a face-up [Three, Two] run whose top card is
// foundation-eligible (the bait the pre-#158 code would take).
/// Deal `seed`, send the Ace on tableau 1 to its foundation, then stack the
/// matching Two from `two_from` onto the opposite-color Three on `run_on`.
/// Returns the game with a 2-card face-up run on `run_on`.
fn deal_run_with_foundation_bait(seed: u64, two_from: Tableau, run_on: Tableau) -> GameState {
let mut game = GameState::new(seed, DrawStockConfig::DrawOne);
let (ace, _) = game
.pile(KlondikePile::Tableau(Tableau::Tableau1))
.last()
.cloned()
.expect("seed deals a card on tableau 1");
let foundation = best_destination(&ace, &game).expect("ace has a foundation home");
game.move_cards(KlondikePile::Tableau(Tableau::Tableau1), foundation, 1)
.expect("ace moves to foundation");
game.move_cards(
KlondikePile::Tableau(two_from),
KlondikePile::Tableau(run_on),
1,
)
.expect("two stacks onto three");
game
}
#[test]
fn auto_move_for_run_moves_exact_clicked_run_not_top_card() {
let game = deal_run_with_foundation_bait(51, Tableau::Tableau6, Tableau::Tableau5);
let run_pile = KlondikePile::Tableau(Tableau::Tableau5);
let cards = game.pile(run_pile);
let (top, _) = cards.last().cloned().expect("run pile has cards");
let (clicked, _) = cards[cards.len() - 2].clone();
// The bait: the lone top card has a foundation move available.
assert!(
matches!(
best_destination(&top, &game),
Some(KlondikePile::Foundation(_))
),
"precondition: run top card must be foundation-eligible"
);
// Clicking the run base must move exactly the 2-card run to a tableau.
match auto_move_for_run(&clicked, &run_pile, &game, 2) {
Some((KlondikePile::Tableau(dest), 2)) => assert_ne!(dest, Tableau::Tableau5),
other => panic!("expected a whole-run tableau move, got {other:?}"),
}
}
#[test]
fn auto_move_for_run_rejects_when_clicked_run_cannot_move() {
let game = deal_run_with_foundation_bait(145, Tableau::Tableau4, Tableau::Tableau7);
let run_pile = KlondikePile::Tableau(Tableau::Tableau7);
let cards = game.pile(run_pile);
let (top, _) = cards.last().cloned().expect("run pile has cards");
let (clicked, _) = cards[cards.len() - 2].clone();
assert!(
matches!(
best_destination(&top, &game),
Some(KlondikePile::Foundation(_))
),
"precondition: run top card must be foundation-eligible"
);
// The 2-card run has no legal home — the top card's foundation move
// must NOT be taken as a substitute.
assert_eq!(
auto_move_for_run(&clicked, &run_pile, &game, 2),
None,
"an immovable clicked run must not fall back to a top-card move"
);
}
#[test]
fn auto_move_for_run_single_card_prefers_foundation() {
// Seed 51 after the Ace reaches the foundation: the Two on tableau 6
// is a lone face-up card that could go to the foundation OR onto the
// Three on tableau 5. Foundation must win.
let mut game = GameState::new(51, DrawStockConfig::DrawOne);
let (ace, _) = game
.pile(KlondikePile::Tableau(Tableau::Tableau1))
.last()
.cloned()
.expect("seed 51 deals a card on tableau 1");
let foundation = best_destination(&ace, &game).expect("ace has a foundation home");
game.move_cards(KlondikePile::Tableau(Tableau::Tableau1), foundation, 1)
.expect("ace moves to foundation");
let two_pile = KlondikePile::Tableau(Tableau::Tableau6);
let (two, _) = game.pile(two_pile).last().cloned().expect("two on top");
assert!(
game.can_move_cards(&two_pile, &KlondikePile::Tableau(Tableau::Tableau5), 1),
"precondition: a tableau destination also exists"
);
assert!(
matches!(
auto_move_for_run(&two, &two_pile, &game, 1),
Some((KlondikePile::Foundation(_), 1))
),
"a lone top card still prefers the foundation"
);
}
// -----------------------------------------------------------------------
// Task #28 — find_hint pure-function tests
// -----------------------------------------------------------------------
+3 -4
View File
@@ -83,9 +83,8 @@ pub use avatar_plugin::{AvatarFetchEvent, AvatarPlugin, AvatarResource};
pub use card_animation::{
AnimationChain, AnimationTuning, BufferedInput, CardAnimation, CardAnimationPlugin,
DEAL_INTERVAL_SECS, DIAG_WINDOW_SIZE, FrameTimeDiagnostics, HoverState, InputBuffer,
InputPlatform, MAX_DURATION_SECS, MIN_DURATION_SECS, MotionCurve, WIN_CASCADE_INTERVAL_SECS,
WinCascadePlugin, cascade_delay, compute_duration, micro_vary, sample_curve,
win_scatter_targets,
InputPlatform, MAX_DURATION_SECS, MIN_DURATION_SECS, MotionCurve, compute_duration, micro_vary,
sample_curve,
};
pub use card_plugin::{
CardEntity, CardImageSet, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer,
@@ -107,7 +106,7 @@ pub use events::{
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
StartTimeAttackRequestEvent, StartZenRequestEvent, StateChangedEvent, SyncCompleteEvent,
StartTimeAttackRequestEvent, StartZenRequestEvent, StateChangedEvent,
ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent,
ToggleSettingsRequestEvent, ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent,
XpAwardedEvent,
+17 -10
View File
@@ -382,20 +382,27 @@ fn spawn_slide_welcome(commands: &mut Commands, font_res: Option<&FontResource>)
});
}
/// How-to-play body copy, phrased for the platform's input vocabulary —
/// a touch player never left-clicks (Phase H polish; spotted in the
/// emulator smoke of v0.44.0).
#[cfg(target_os = "android")]
const HOW_TO_PLAY_BODY: &str = "Drag any face-up card to move it between piles. \
You can drag a whole column at once by grabbing the topmost card \
you want to move. Double-tap a face-up card to send it to a \
foundation pile automatically (when the move is legal). \
Tap Hint in the bottom bar for a suggested move.";
#[cfg(not(target_os = "android"))]
const HOW_TO_PLAY_BODY: &str = "Left-click and drag any face-up card to move it between piles. \
You can drag a whole column at once by grabbing the topmost card \
you want to move. Double-click a face-up card to send it to a \
foundation pile automatically (when the move is legal). \
Right-click a card for a hint valid destinations will highlight.";
/// Slide 2 — How to play.
fn spawn_slide_how_to_play(commands: &mut Commands, font_res: Option<&FontResource>) {
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
spawn_modal_header(card, "Drag cards to play", font_res);
spawn_modal_body_text(
card,
"Left-click and drag any face-up card to move it between piles. \
You can drag a whole column at once by grabbing the topmost card \
you want to move. Double-click a face-up card to send it to a \
foundation pile automatically (when the move is legal). \
Right-click a card for a hint valid destinations will highlight.",
TEXT_SECONDARY,
font_res,
);
spawn_modal_body_text(card, HOW_TO_PLAY_BODY, TEXT_SECONDARY, font_res);
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
+8 -12
View File
@@ -122,12 +122,8 @@ pub(crate) fn format_move_body(instruction: &KlondikeInstruction) -> String {
/// `▌ MOVE LOG · COMPLETE` in `Completed`.
pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String {
match state {
ReplayPlaybackState::Playing { replay, cursor, .. } => {
format!(
"\u{258C} MOVE LOG \u{00B7} {}/{}",
cursor,
replay.moves.len()
)
ReplayPlaybackState::Playing { moves, cursor, .. } => {
format!("\u{258C} MOVE LOG \u{00B7} {}/{}", cursor, moves.len())
}
ReplayPlaybackState::Completed => "\u{258C} MOVE LOG \u{00B7} COMPLETE".to_string(),
ReplayPlaybackState::Inactive => String::new(),
@@ -135,7 +131,7 @@ pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String {
}
/// Pure helper — formats the kth-most-recently-applied move's row
/// text. `k = 1` is the active row (`replay.moves[cursor - 1]`,
/// text. `k = 1` is the active row (`moves[cursor - 1]`,
/// displayed as `"{cursor} │ {body}"`). `k = 2` is the row above
/// that (`moves[cursor - 2]` displayed as `"{cursor - 1} │ {body}"`),
/// and so on.
@@ -147,14 +143,14 @@ pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String {
/// for k=1 and k=2 only, k=3 returns empty).
/// - The move list is shorter than expected (defensive guard).
pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> String {
let ReplayPlaybackState::Playing { replay, cursor, .. } = state else {
let ReplayPlaybackState::Playing { moves, cursor, .. } = state else {
return String::new();
};
if k == 0 || k > *cursor {
return String::new();
}
let zero_idx = *cursor - k;
let Some(m) = replay.moves.get(zero_idx) else {
let Some(m) = moves.get(zero_idx) else {
return String::new();
};
let display_idx = *cursor - k + 1;
@@ -162,7 +158,7 @@ pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> St
}
/// Pure helper — formats the kth-NEXT move's row text. `k = 1`
/// is the move that will apply next (`replay.moves[cursor]`,
/// is the move that will apply next (`moves[cursor]`,
/// displayed as `cursor + 1`); `k = 2` is the move after that,
/// and so on.
///
@@ -174,14 +170,14 @@ pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> St
/// replay — late in the move list, the trailing next rows
/// stay empty).
pub(crate) fn format_kth_next_row(state: &ReplayPlaybackState, k: usize) -> String {
let ReplayPlaybackState::Playing { replay, cursor, .. } = state else {
let ReplayPlaybackState::Playing { moves, cursor, .. } = state else {
return String::new();
};
if k == 0 {
return String::new();
}
let zero_idx = *cursor + k - 1;
let Some(m) = replay.moves.get(zero_idx) else {
let Some(m) = moves.get(zero_idx) else {
return String::new();
};
let display_idx = *cursor + k;
+3 -3
View File
@@ -383,7 +383,7 @@ pub struct ReplayOverlayMoveLogPrevRow {
/// Marker on a "next move" row below the active row. `offset`
/// is the 1-based distance forward from the active row:
/// `offset = 1` is the move that will apply next
/// (`replay.moves[cursor]`, displayed as `cursor + 1`),
/// (`moves[cursor]`, displayed as `cursor + 1`),
/// `offset = 2` is the one after that, and so on. Up to
/// [`MOVE_LOG_NEXT_ROWS`] rows render below the active row.
///
@@ -1258,11 +1258,11 @@ fn keybind_footer_hint_text() -> &'static str {
/// `win_move_index >= total` (defensive — shouldn't happen) doesn't
/// position the marker outside the track.
fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option<f32> {
let ReplayPlaybackState::Playing { replay, .. } = state else {
let ReplayPlaybackState::Playing { replay, moves, .. } = state else {
return None;
};
let idx = replay.win_move_index?;
let total = replay.moves.len();
let total = moves.len();
if total == 0 {
return None;
}
+109 -352
View File
@@ -1,7 +1,7 @@
use super::*;
use chrono::NaiveDate;
use solitaire_core::{DrawStockConfig, game_state::GameMode};
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, SessionRecording, Tableau};
use solitaire_core::{Rank, Suit};
use solitaire_data::Replay;
@@ -17,9 +17,11 @@ fn synthetic_replay(move_count: usize) -> Replay {
120,
1_000,
NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
(0..move_count)
.map(|_| KlondikeInstruction::RotateStock)
.collect(),
SessionRecording::from_instructions_unchecked(
42,
DrawStockConfig::DrawOne,
(0..move_count).map(|_| KlondikeInstruction::RotateStock),
),
)
}
@@ -93,12 +95,7 @@ fn overlay_spawns_when_playback_starts() {
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
);
app.update();
@@ -117,12 +114,7 @@ fn overlay_progress_text_reflects_cursor() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 5,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false),
);
app.update();
@@ -138,12 +130,7 @@ fn overlay_stop_button_click_clears_playback() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
);
app.update();
assert_eq!(overlay_root_count(&mut app), 1);
@@ -195,12 +182,7 @@ fn floating_chip_spawns_and_despawns_with_overlay() {
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(5),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false),
);
app.update();
assert_eq!(
@@ -232,12 +214,7 @@ fn overlay_despawns_when_playback_returns_to_inactive() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(3),
cursor: 1,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(3), 1, 0.5, false),
);
app.update();
assert_eq!(overlay_root_count(&mut app), 1);
@@ -260,12 +237,7 @@ fn overlay_text_changes_on_completed() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(7),
cursor: 7,
secs_to_next: 0.0,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(7), 7, 0.0, false),
);
app.update();
assert_eq!(banner_text(&mut app), "\u{258C} replay");
@@ -314,30 +286,30 @@ fn scrub_pct_covers_state_corners() {
assert_eq!(scrub_pct(&ReplayPlaybackState::Inactive), 0.0);
assert_eq!(scrub_pct(&ReplayPlaybackState::Completed), 100.0);
assert_eq!(
scrub_pct(&ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
}),
scrub_pct(&ReplayPlaybackState::playing(
synthetic_replay(10),
0,
0.5,
false,
)),
0.0,
);
assert_eq!(
scrub_pct(&ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 5,
secs_to_next: 0.5,
paused: false,
}),
scrub_pct(&ReplayPlaybackState::playing(
synthetic_replay(10),
5,
0.5,
false,
)),
50.0,
);
assert_eq!(
scrub_pct(&ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 10,
secs_to_next: 0.5,
paused: false,
}),
scrub_pct(&ReplayPlaybackState::playing(
synthetic_replay(10),
10,
0.5,
false,
)),
100.0,
);
}
@@ -367,12 +339,12 @@ fn format_game_caption_covers_state_corners() {
// Mar = 31, Apr = 30, May 2 = 122). Synthetic_replay always
// uses this date so the assertion is stable.
assert_eq!(
format_game_caption(&ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 5,
secs_to_next: 0.5,
paused: false,
}),
format_game_caption(&ReplayPlaybackState::playing(
synthetic_replay(10),
5,
0.5,
false,
)),
Some("GAME #2026-122".to_string()),
);
@@ -381,12 +353,7 @@ fn format_game_caption_covers_state_corners() {
let mut early_january = synthetic_replay(10);
early_january.recorded_at = NaiveDate::from_ymd_opt(2026, 1, 5).expect("valid date");
assert_eq!(
format_game_caption(&ReplayPlaybackState::Playing {
replay: early_january,
cursor: 0,
secs_to_next: 0.5,
paused: false,
}),
format_game_caption(&ReplayPlaybackState::playing(early_january, 0, 0.5, false,)),
Some("GAME #2026-005".to_string()),
);
}
@@ -399,12 +366,7 @@ fn overlay_game_caption_shows_replay_date() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
);
app.update();
assert_eq!(game_caption_text(&mut app), "GAME #2026-122");
@@ -431,12 +393,7 @@ fn overlay_scrub_fill_tracks_cursor() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(8),
cursor: 2,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(8), 2, 0.5, false),
);
app.update();
assert_eq!(
@@ -447,12 +404,7 @@ fn overlay_scrub_fill_tracks_cursor() {
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(8),
cursor: 6,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(8), 6, 0.5, false),
);
app.update();
assert_eq!(
@@ -497,12 +449,7 @@ fn win_move_marker_pct_is_none_for_completed() {
fn win_move_marker_pct_is_none_when_replay_lacks_field() {
// Synthetic replay constructor leaves win_move_index as None
// (legacy / pre-`ab857bb` path).
let state = ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
};
let state = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false);
assert_eq!(win_move_marker_pct(&state), None);
}
@@ -511,12 +458,12 @@ fn win_move_marker_pct_is_some_at_correct_position() {
// 10 moves, win at index 9 → marker sits at 90 % of the track.
// Matches the recording semantic: cursor reaches the marker
// exactly when the about-to-apply move IS the win move.
let state = ReplayPlaybackState::Playing {
replay: synthetic_replay(10).with_win_move_index(Some(9)),
cursor: 0,
secs_to_next: 0.5,
paused: false,
};
let state = ReplayPlaybackState::playing(
synthetic_replay(10).with_win_move_index(Some(9)),
0,
0.5,
false,
);
assert_eq!(win_move_marker_pct(&state), Some(90.0));
}
@@ -524,12 +471,12 @@ fn win_move_marker_pct_is_some_at_correct_position() {
fn win_move_marker_pct_clamps_to_track_bounds() {
// Defensive: if a malformed replay carried `win_move_index >=
// total`, the marker must still sit on the track, not past it.
let state = ReplayPlaybackState::Playing {
replay: synthetic_replay(5).with_win_move_index(Some(99)),
cursor: 0,
secs_to_next: 0.5,
paused: false,
};
let state = ReplayPlaybackState::playing(
synthetic_replay(5).with_win_move_index(Some(99)),
0,
0.5,
false,
);
assert_eq!(win_move_marker_pct(&state), Some(100.0));
}
@@ -538,12 +485,12 @@ fn marker_spawned_when_replay_has_win_move_index() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(8).with_win_move_index(Some(7)),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(
synthetic_replay(8).with_win_move_index(Some(7)),
0,
0.5,
false,
),
);
app.update();
assert_eq!(
@@ -559,12 +506,7 @@ fn marker_not_spawned_when_replay_lacks_win_move_index() {
// Default constructor → win_move_index: None (legacy replay).
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(8),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(8), 0, 0.5, false),
);
app.update();
assert_eq!(
@@ -579,12 +521,12 @@ fn marker_despawns_when_replay_state_returns_to_inactive() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(8).with_win_move_index(Some(7)),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(
synthetic_replay(8).with_win_move_index(Some(7)),
0,
0.5,
false,
),
);
app.update();
assert_eq!(win_marker_count(&mut app), 1);
@@ -608,12 +550,12 @@ fn win_move_marker_carries_hc_background_marker() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(8).with_win_move_index(Some(7)),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(
synthetic_replay(8).with_win_move_index(Some(7)),
0,
0.5,
false,
),
);
app.update();
@@ -669,12 +611,7 @@ fn scrub_notches_spawn_with_overlay() {
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
);
app.update();
assert_eq!(
@@ -693,12 +630,7 @@ fn scrub_notches_carry_high_contrast_background_marker() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
);
app.update();
@@ -723,12 +655,7 @@ fn scrub_track_carries_high_contrast_background_marker() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
);
app.update();
@@ -759,12 +686,7 @@ fn scrub_notches_despawn_with_overlay() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
);
app.update();
assert_eq!(scrub_notch_count(&mut app), 5);
@@ -821,12 +743,7 @@ fn scrub_notch_labels_spawn_with_overlay() {
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
);
app.update();
assert_eq!(
@@ -844,12 +761,7 @@ fn scrub_notch_labels_carry_helper_strings() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
);
app.update();
@@ -870,12 +782,7 @@ fn scrub_notch_labels_despawn_with_overlay() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
);
app.update();
assert_eq!(scrub_notch_label_count(&mut app), 5);
@@ -947,12 +854,7 @@ fn keybind_footer_spawns_with_overlay() {
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
);
app.update();
assert_eq!(
@@ -970,12 +872,7 @@ fn keybind_footer_paints_helper_strings() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
);
app.update();
@@ -999,12 +896,7 @@ fn keybind_footer_carries_high_contrast_border_marker() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
);
app.update();
@@ -1025,12 +917,7 @@ fn keybind_footer_despawns_with_overlay() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
);
app.update();
assert_eq!(keybind_footer_count(&mut app), 1);
@@ -1054,12 +941,7 @@ fn scrub_notches_spawn_even_without_win_marker() {
// Default constructor → win_move_index: None.
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(8),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(8), 0, 0.5, false),
);
app.update();
assert_eq!(
@@ -1144,12 +1026,7 @@ fn format_move_body_handles_stock_cycle() {
/// `Inactive` → empty.
#[test]
fn format_move_log_header_covers_state_branches() {
let playing = ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 3,
secs_to_next: 0.5,
paused: false,
};
let playing = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false);
assert_eq!(
format_move_log_header(&playing),
"\u{258C} MOVE LOG \u{00B7} 3/10"
@@ -1167,24 +1044,14 @@ fn format_move_log_header_covers_state_branches() {
/// `replay.moves[N - 1]` and the row reads `"N | ..."`.
#[test]
fn format_active_move_row_handles_cursor_zero_and_positive() {
let cursor_zero = ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
};
let cursor_zero = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false);
assert_eq!(
format_active_move_row(&cursor_zero),
"",
"cursor=0 means no move applied yet; row stays empty",
);
let cursor_three = ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 3,
secs_to_next: 0.5,
paused: false,
};
let cursor_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false);
// synthetic_replay produces all StockClicks, so the body
// is "stock cycle". The displayed index is 3 (cursor),
// matching the most-recently-applied move at moves[2].
@@ -1208,12 +1075,7 @@ fn move_log_panel_spawns_with_overlay() {
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
);
app.update();
assert_eq!(
@@ -1232,12 +1094,7 @@ fn move_log_panel_header_paints_helper_string() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(8),
cursor: 2,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(8), 2, 0.5, false),
);
app.update();
assert_eq!(
@@ -1254,12 +1111,7 @@ fn move_log_active_row_repaints_on_cursor_advance() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
);
app.update();
assert_eq!(
@@ -1271,12 +1123,7 @@ fn move_log_active_row_repaints_on_cursor_advance() {
// Advance cursor to 2 (most-recently-applied move is moves[1]).
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 2,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(10), 2, 0.5, false),
);
app.update();
assert_eq!(
@@ -1292,12 +1139,7 @@ fn move_log_active_row_repaints_on_cursor_advance() {
/// early in a replay don't paint stale text.
#[test]
fn format_kth_recent_row_handles_in_range_and_out_of_range() {
let state_at_three = ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 3,
secs_to_next: 0.5,
paused: false,
};
let state_at_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false);
// k=1 → active (most recent applied). cursor=3 → display=3.
assert_eq!(
format_kth_recent_row(&state_at_three, 1),
@@ -1348,12 +1190,7 @@ fn move_log_prev_rows_spawn_with_panel() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 3,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false),
);
app.update();
assert_eq!(
@@ -1371,12 +1208,7 @@ fn move_log_prev_rows_paint_helper_strings_at_spawn() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 5,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false),
);
app.update();
@@ -1401,12 +1233,7 @@ fn move_log_prev_rows_repaint_on_cursor_advance() {
// Start at cursor=2: offset 1 → k=2 → display=1, offset 2 → k=3 → empty (k > cursor).
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 2,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(10), 2, 0.5, false),
);
app.update();
assert_eq!(
@@ -1422,12 +1249,7 @@ fn move_log_prev_rows_repaint_on_cursor_advance() {
// Advance to cursor=5 — both offsets now have history.
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 5,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false),
);
app.update();
assert_eq!(
@@ -1465,12 +1287,7 @@ fn move_log_next_row_text_at_offset(app: &mut App, offset: u8) -> String {
/// alongside in-range correctness.
#[test]
fn format_kth_next_row_handles_in_range_and_out_of_range() {
let state_at_three = ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 3,
secs_to_next: 0.5,
paused: false,
};
let state_at_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false);
// k=1 → moves[3], display=4
assert_eq!(
format_kth_next_row(&state_at_three, 1),
@@ -1498,12 +1315,7 @@ fn move_log_next_rows_spawn_with_panel() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 3,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false),
);
app.update();
assert_eq!(
@@ -1520,12 +1332,7 @@ fn move_log_next_rows_paint_helper_strings_at_spawn() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 5,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false),
);
app.update();
@@ -1549,12 +1356,7 @@ fn move_log_next_rows_underfill_at_replay_end() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 9,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(10), 9, 0.5, false),
);
app.update();
assert_eq!(
@@ -1578,12 +1380,7 @@ fn active_row_wrapper_carries_accent_primary_background() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 3,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false),
);
app.update();
@@ -1621,12 +1418,7 @@ fn active_row_text_uses_high_contrast_color_for_highlight() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 3,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false),
);
app.update();
@@ -1647,24 +1439,14 @@ fn active_row_text_uses_high_contrast_color_for_highlight() {
/// dropping it has to also update this test.
#[test]
fn active_row_format_includes_focus_prefix() {
let state = ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 5,
secs_to_next: 0.5,
paused: false,
};
let state = ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false);
let row = format_active_move_row(&state);
assert!(
row.starts_with('\u{25B6}'),
"active-row format must start with ▶ focus marker; got {row:?}",
);
// Cursor=0 still returns empty, never just the prefix.
let cursor_zero = ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
};
let cursor_zero = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false);
assert_eq!(
format_active_move_row(&cursor_zero),
"",
@@ -1679,12 +1461,7 @@ fn move_log_panel_despawns_with_overlay() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
);
app.update();
assert_eq!(move_log_panel_count(&mut app), 1);
@@ -1728,21 +1505,11 @@ fn unique_button<M: Component>(app: &mut App) -> Entity {
}
fn pressed_paused_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState {
ReplayPlaybackState::Playing {
replay: synthetic_replay(replay_len),
cursor,
secs_to_next: 0.5,
paused: true,
}
ReplayPlaybackState::playing(synthetic_replay(replay_len), cursor, 0.5, true)
}
fn running_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState {
ReplayPlaybackState::Playing {
replay: synthetic_replay(replay_len),
cursor,
secs_to_next: 0.5,
paused: false,
}
ReplayPlaybackState::playing(synthetic_replay(replay_len), cursor, 0.5, false)
}
#[test]
@@ -2191,12 +1958,7 @@ fn dim_layer_spawns_and_despawns_with_overlay() {
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(5),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false),
);
app.update();
assert_eq!(
@@ -2255,12 +2017,7 @@ fn mini_tableau_panel_spawns_and_despawns_with_overlay() {
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(5),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false),
);
app.update();
assert_eq!(
@@ -83,11 +83,11 @@ pub(crate) fn update_floating_progress_chip(
// any). `cursor` is the index of the *next* move to apply, so
// the most-recently-applied move sits at `cursor - 1`.
let dest_pile = match state.as_ref() {
ReplayPlaybackState::Playing { replay, cursor, .. } if *cursor > 0 => {
ReplayPlaybackState::Playing { moves, cursor, .. } if *cursor > 0 => {
// The destination pile is recoverable directly from the
// instruction — no live state needed. `RotateStock` has no
// destination (the chip hides over the stock pile).
match &replay.moves[cursor - 1] {
match &moves[cursor - 1] {
KlondikeInstruction::DstFoundation(dst) => {
Some(KlondikePile::Foundation(dst.foundation))
}
+68 -32
View File
@@ -1,11 +1,12 @@
//! In-engine replay playback core.
//!
//! When the player clicks "Watch replay" on the Stats overlay, the live
//! game state is reset to the deal seeded from the replay's `seed` /
//! `mode` / `draw_mode`, and the engine ticks through `replay.moves` at a
//! steady cadence — firing the canonical [`MoveRequestEvent`] /
//! [`DrawRequestEvent`] for each one. The existing animation pipeline
//! plays back identically to a live game.
//! game state is reset to the recorded deal — rebuilt directly from
//! `replay.recording` via `GameState::from_recording`, never by
//! re-dealing the seed — and the engine ticks through the recording's
//! instruction list at a steady cadence, firing the canonical
//! [`MoveRequestEvent`] / [`DrawRequestEvent`] for each one. The
//! existing animation pipeline plays back identically to a live game.
//!
//! ## Public surface
//!
@@ -95,7 +96,7 @@ pub const REPLAY_COMPLETION_LINGER_SECS: f32 = 5.0;
/// 3. The tick system [`tick_replay_playback`] advances `cursor` once
/// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event
/// for each [`KlondikeInstruction`].
/// 4. When `cursor == replay.moves.len()`, the state transitions to
/// 4. When `cursor == moves.len()`, the state transitions to
/// [`Completed`](Self::Completed). It lingers for
/// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by
/// [`auto_clear_completed_replay`]) before returning to
@@ -109,14 +110,20 @@ pub enum ReplayPlaybackState {
#[default]
Inactive,
/// A replay is currently being played back. The overlay reads
/// `replay.moves.len()` for the denominator of the progress
/// `moves.len()` for the denominator of the progress
/// indicator and `cursor` for the numerator.
Playing {
/// The replay being played back. Owned so the state is the
/// only place playback metadata lives — no separate resource
/// needed.
replay: Replay,
/// Index of the next move to apply, in `[0, replay.moves.len()]`.
/// needed. Boxed: the v4 recording embeds the dealt board and
/// would otherwise dwarf the other variants
/// (`clippy::large_enum_variant`).
replay: Box<Replay>,
/// Instruction list extracted once from `replay.recording` at
/// [`start_replay_playback`] time, so the per-frame tick indexes
/// a plain slice instead of re-walking the recording.
moves: Vec<KlondikeInstruction>,
/// Index of the next move to apply, in `[0, moves.len()]`.
cursor: usize,
/// Seconds remaining until the next move is dispatched.
secs_to_next: f32,
@@ -138,6 +145,19 @@ pub enum ReplayPlaybackState {
}
impl ReplayPlaybackState {
/// Construct a [`Self::Playing`] state, extracting the instruction
/// list from `replay.recording` once up front.
pub fn playing(replay: Replay, cursor: usize, secs_to_next: f32, paused: bool) -> Self {
let moves = replay.recording.instructions();
Self::Playing {
replay: Box::new(replay),
moves,
cursor,
secs_to_next,
paused,
}
}
/// Returns `true` when a replay is currently being played back.
pub fn is_playing(&self) -> bool {
matches!(self, Self::Playing { .. })
@@ -156,7 +176,7 @@ impl ReplayPlaybackState {
/// the total is no longer available in `Completed`.
pub fn progress(&self) -> Option<(usize, usize)> {
match self {
Self::Playing { replay, cursor, .. } => Some((*cursor, replay.moves.len())),
Self::Playing { moves, cursor, .. } => Some((*cursor, moves.len())),
Self::Inactive | Self::Completed => None,
}
}
@@ -191,7 +211,11 @@ pub fn start_replay_playback(
) {
use solitaire_core::game_state::GameState;
let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode, replay.mode);
// The recording carries the dealt board and the session config
// (including draw mode), so the fresh game is bit-identical to the
// recorded deal regardless of how the current build maps seeds to
// deals — `replay.seed` is presentation metadata only.
let (fresh, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode);
commands.insert_resource(GameStateResource(fresh));
// Initial `secs_to_next` uses the constant rather than reading
@@ -201,7 +225,8 @@ pub fn start_replay_playback(
// unusually short setting; subsequent ticks read the live setting
// every frame via [`tick_replay_playback`].
**state = ReplayPlaybackState::Playing {
replay,
replay: Box::new(replay),
moves,
cursor: 0,
secs_to_next: REPLAY_MOVE_INTERVAL_SECS,
paused: false,
@@ -256,7 +281,7 @@ pub fn step_replay_playback(
draws_writer: &mut MessageWriter<DrawRequestEvent>,
) -> bool {
let ReplayPlaybackState::Playing {
replay,
moves,
cursor,
paused: true,
..
@@ -264,10 +289,10 @@ pub fn step_replay_playback(
else {
return false;
};
if *cursor >= replay.moves.len() {
if *cursor >= moves.len() {
return false;
}
let instruction = replay.moves[*cursor];
let instruction = moves[*cursor];
dispatch_instruction(instruction, *cursor, game, moves_writer, draws_writer);
*cursor += 1;
true
@@ -362,7 +387,7 @@ pub fn step_backwards_replay_playback(
/// Drains `secs_to_next` by `time.delta_secs()`. When the countdown
/// expires, fires the canonical event for the move at `cursor`,
/// increments `cursor`, and resets `secs_to_next`. When `cursor`
/// reaches `replay.moves.len()`, transitions to
/// reaches `moves.len()`, transitions to
/// [`ReplayPlaybackState::Completed`].
///
/// The advance loop is a `while`, not an `if`, so coarse time steps
@@ -384,10 +409,11 @@ fn tick_replay_playback(
let mut transition_to_completed = false;
if let ReplayPlaybackState::Playing {
replay,
moves,
cursor,
secs_to_next,
paused,
..
} = state.as_mut()
{
// While paused, the cursor and the timer freeze together —
@@ -397,8 +423,8 @@ fn tick_replay_playback(
// path.
if !*paused {
*secs_to_next -= dt;
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
let instruction = replay.moves[*cursor];
while *secs_to_next <= 0.0 && *cursor < moves.len() {
let instruction = moves[*cursor];
dispatch_instruction(
instruction,
*cursor,
@@ -410,7 +436,7 @@ fn tick_replay_playback(
*secs_to_next += interval;
}
if *cursor >= replay.moves.len() {
if *cursor >= moves.len() {
transition_to_completed = true;
}
}
@@ -562,8 +588,7 @@ mod tests {
use crate::game_plugin::GamePlugin;
use bevy::time::TimeUpdateStrategy;
use chrono::NaiveDate;
use solitaire_core::KlondikeInstruction;
use solitaire_core::{DrawStockConfig, game_state::GameMode};
use solitaire_core::{DrawStockConfig, SessionRecording, game_state::GameMode};
use std::time::Duration;
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
@@ -612,11 +637,11 @@ mod tests {
60,
500,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![
KlondikeInstruction::RotateStock,
KlondikeInstruction::RotateStock,
KlondikeInstruction::RotateStock,
],
SessionRecording::from_instructions_unchecked(
12345,
DrawStockConfig::DrawOne,
[KlondikeInstruction::RotateStock; 3],
),
)
}
@@ -673,11 +698,14 @@ mod tests {
let state = app.world().resource::<ReplayPlaybackState>();
match state {
ReplayPlaybackState::Playing {
cursor, replay: r, ..
cursor,
replay: r,
moves,
..
} => {
assert_eq!(*cursor, 0);
assert_eq!(r.seed, replay.seed);
assert_eq!(r.moves.len(), 3);
assert_eq!(moves.len(), 3);
}
other => panic!("expected Playing, got {other:?}"),
}
@@ -778,7 +806,11 @@ mod tests {
10,
100,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![KlondikeInstruction::RotateStock],
SessionRecording::from_instructions_unchecked(
42,
DrawStockConfig::DrawOne,
[KlondikeInstruction::RotateStock],
),
);
start_playback(&mut app, one_move);
app.update();
@@ -887,7 +919,11 @@ mod tests {
10,
100,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![KlondikeInstruction::RotateStock; 10],
SessionRecording::from_instructions_unchecked(
7,
DrawStockConfig::DrawOne,
[KlondikeInstruction::RotateStock; 10],
),
)
}
+5 -1
View File
@@ -1939,7 +1939,11 @@ mod tests {
time_seconds,
0,
date,
vec![],
solitaire_core::SessionRecording::from_instructions_unchecked(
1,
solitaire_core::DrawStockConfig::DrawOne,
[],
),
);
r.share_url = share_url;
r
+18 -21
View File
@@ -22,12 +22,11 @@ use solitaire_data::{
AchievementRecord, PlayerProgress, Replay, StatsSnapshot, SyncError, SyncProvider,
save_achievements_to, save_progress_to, save_replay_history_to, save_stats_to,
};
use solitaire_sync::{SyncPayload, SyncResponse, merge};
use solitaire_sync::{SyncPayload, merge};
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
use crate::events::{
GameWonEvent, ManualSyncRequestEvent, SyncCompleteEvent, SyncConfigureRequestEvent,
WarningToastEvent,
GameWonEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent, WarningToastEvent,
};
use crate::game_plugin::RecordingReplay;
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
@@ -108,7 +107,6 @@ impl Plugin for SyncPlugin {
.init_resource::<PullTask>()
.init_resource::<PendingReplayUpload>()
.add_message::<ManualSyncRequestEvent>()
.add_message::<SyncCompleteEvent>()
.add_message::<SyncConfigureRequestEvent>()
.add_message::<WarningToastEvent>();
@@ -198,7 +196,6 @@ fn poll_pull_result(
achievements_path: Res<AchievementsStoragePath>,
mut progress: ResMut<ProgressResource>,
progress_path: Res<ProgressStoragePath>,
mut complete_writer: MessageWriter<SyncCompleteEvent>,
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
mut warning_toast: MessageWriter<WarningToastEvent>,
) {
@@ -213,7 +210,7 @@ fn poll_pull_result(
match result {
Ok(remote) => {
let local = build_payload(&stats.0, &achievements.0, &progress.0);
let (merged, conflicts) = merge(&local, &remote);
let (merged, _conflicts) = merge(&local, &remote);
// Persist merged state atomically.
if let Some(p) = &stats_path.0
@@ -233,17 +230,10 @@ fn poll_pull_result(
}
// Update in-world resources.
let now = Utc::now();
stats.0 = merged.stats.clone();
achievements.0 = merged.achievements.clone();
progress.0 = merged.progress.clone();
status.0 = SyncStatus::LastSynced(now);
complete_writer.write(SyncCompleteEvent(Ok(SyncResponse {
merged,
server_time: now,
conflicts,
})));
stats.0 = merged.stats;
achievements.0 = merged.achievements;
progress.0 = merged.progress;
status.0 = SyncStatus::LastSynced(Utc::now());
}
Err(SyncError::UnsupportedPlatform) => {
// No backend configured — not an error, just leave status as Idle.
@@ -266,8 +256,7 @@ fn poll_pull_result(
if matches!(e, SyncError::Auth(_)) {
configure_sync.write(SyncConfigureRequestEvent);
}
status.0 = SyncStatus::Error(msg.clone());
complete_writer.write(SyncCompleteEvent(Err(msg)));
status.0 = SyncStatus::Error(msg);
}
}
}
@@ -346,7 +335,11 @@ fn push_replay_on_win(
ev.time_seconds,
ev.score,
Utc::now().date_naive(),
recording.moves.clone(),
// The live session is the authoritative recording; it
// serialises via the upstream card_game serializers so web
// playback rebuilds the exact deal instead of re-dealing
// the seed.
game.0.recording(),
);
let provider = provider.0.clone();
let rt = rt.0.clone();
@@ -632,7 +625,11 @@ mod tests {
60,
500,
chrono::NaiveDate::from_ymd_opt(2026, 5, 6).expect("valid date"),
vec![],
solitaire_core::SessionRecording::from_instructions_unchecked(
7,
DrawStockConfig::DrawOne,
[],
),
);
let history = ReplayHistory {
schema_version: solitaire_data::REPLAY_HISTORY_SCHEMA_VERSION,
+27 -1
View File
@@ -178,6 +178,32 @@ pub fn spawn_modal<M: Component, F>(
z_panel: i32,
build_card: F,
) -> Entity
where
F: FnOnce(&mut ChildSpawnerCommands),
{
spawn_modal_sized(
commands,
plugin_marker,
z_panel,
MODAL_CARD_MAX_WIDTH,
build_card,
)
}
/// Default maximum card width in logical pixels — every [`spawn_modal`]
/// caller gets this. Surfaces that lay out side-by-side panes (e.g. the
/// two-pane Home on wide viewports) can raise it via [`spawn_modal_sized`].
pub const MODAL_CARD_MAX_WIDTH: f32 = 720.0;
/// [`spawn_modal`] with an explicit card `max_width`. Behaviour is
/// otherwise identical — same scrim, enter animation, and card chrome.
pub fn spawn_modal_sized<M: Component, F>(
commands: &mut Commands,
plugin_marker: M,
z_panel: i32,
max_width: f32,
build_card: F,
) -> Entity
where
F: FnOnce(&mut ChildSpawnerCommands),
{
@@ -235,7 +261,7 @@ where
padding: UiRect::all(VAL_SPACE_5),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_LG)),
max_width: Val::Px(720.0),
max_width: Val::Px(max_width),
align_items: AlignItems::Stretch,
..default()
},
@@ -206,7 +206,9 @@ async function main() {
invariant_ok: !!snap?.invariants?.state_ok,
history_len: Array.isArray(snap?.move_history) ? snap.move_history.length : null,
replay_payload_present: payload !== null,
replay_moves_len: Array.isArray(payload?.moves) ? payload.moves.length : 0,
replay_moves_len: Array.isArray(payload?.recording?.instructions)
? payload.recording.instructions.length
: 0,
};
}, { stepCap: maxSteps, policyName: policy, maxVisits: maxVisitsPerState });
@@ -69,9 +69,9 @@ test("draw-mode toggle affects replay payload draw_mode", async ({ page }) => {
const payload = await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload());
expect(payload.draw_mode).toBe("DrawThree");
expect(payload.schema_version).toBe(2);
expect(Array.isArray(payload.moves)).toBeTruthy();
expect(payload.moves.length).toBeGreaterThan(0);
expect(payload.schema_version).toBe(4);
expect(Array.isArray(payload.recording?.instructions)).toBeTruthy();
expect(payload.recording.instructions.length).toBeGreaterThan(0);
});
test("autonomous play keeps invariants stable across seed batch", async ({ page }) => {
+9 -5
View File
@@ -47,7 +47,7 @@ test("debug failure report contains replay diagnostics", async ({ page }) => {
expect(report.invariants).toBeTruthy();
});
test("replay payload builder exports schema-v2 moves", async ({ page }) => {
test("replay payload builder exports a schema-v4 recording", async ({ page }) => {
await page.goto("/play-classic?seed=42");
await page.waitForFunction(() => typeof window.__FERROUS_DEBUG__ === "object");
@@ -57,10 +57,14 @@ test("replay payload builder exports schema-v2 moves", async ({ page }) => {
.poll(async () => await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload() !== null))
.toBe(true);
const payload = await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload());
expect(payload.schema_version).toBe(2);
// Schema v4: the wasm layer assembles the whole payload; the deal is
// embedded in `recording` and moves live at recording.instructions.
expect(payload.schema_version).toBe(4);
expect(payload.draw_mode).toMatch(/Draw(One|Three)/);
expect(payload.mode).toBe("Classic");
expect(Array.isArray(payload.moves)).toBeTruthy();
expect(payload.moves.length).toBeGreaterThan(0);
expect(payload.win_move_index).toBe(payload.moves.length - 1);
expect(payload.recording).toBeTruthy();
expect(payload.recording.initial_state).toBeTruthy();
expect(Array.isArray(payload.recording.instructions)).toBeTruthy();
expect(payload.recording.instructions.length).toBeGreaterThan(0);
expect(payload.win_move_index).toBe(payload.recording.instructions.length - 1);
});
+33 -8
View File
@@ -31,17 +31,37 @@ use crate::{AppState, error::AppError, middleware::AuthenticatedUser};
const KNOWN_MODES: &[&str] = &["Classic", "Zen", "TimeAttack", "Challenge", "Difficulty"];
const KNOWN_DRAW_MODES: &[&str] = &["DrawOne", "DrawThree"];
fn validate_header(h: &ReplayHeader) -> Result<(), AppError> {
/// Extract the mode label from the client's serde representation.
///
/// Unit `GameMode` variants serialise as plain strings (`"Classic"`),
/// but data-carrying variants serialise as single-key objects
/// (`{"Difficulty": "Easy"}`) — a plain-`String` field rejected those
/// uploads with a 400 even though `"Difficulty"` sits in `KNOWN_MODES`.
fn mode_label(mode: &serde_json::Value) -> Result<String, AppError> {
if let Some(s) = mode.as_str() {
return Ok(s.to_string());
}
if let Some(obj) = mode.as_object()
&& obj.len() == 1
&& let Some(key) = obj.keys().next()
{
return Ok(key.clone());
}
Err(AppError::BadRequest(format!(
"invalid mode '{mode}'; expected a mode string or single-variant object"
)))
}
fn validate_header(h: &ReplayHeader, mode: &str) -> Result<(), AppError> {
if !KNOWN_DRAW_MODES.contains(&h.draw_mode.as_str()) {
return Err(AppError::BadRequest(format!(
"invalid draw_mode '{}'; expected one of {:?}",
h.draw_mode, KNOWN_DRAW_MODES
)));
}
if !KNOWN_MODES.contains(&h.mode.as_str()) {
if !KNOWN_MODES.contains(&mode) {
return Err(AppError::BadRequest(format!(
"invalid mode '{}'; expected one of {:?}",
h.mode, KNOWN_MODES
"invalid mode '{mode}'; expected one of {KNOWN_MODES:?}"
)));
}
if h.time_seconds <= 0 || h.time_seconds > 86_400 {
@@ -72,7 +92,11 @@ fn validate_header(h: &ReplayHeader) -> Result<(), AppError> {
struct ReplayHeader {
seed: u64,
draw_mode: String,
mode: String,
/// Kept as raw JSON: unit variants arrive as strings, data-carrying
/// variants (`GameMode::Difficulty(level)`) as single-key objects.
/// [`mode_label`] projects both onto the label stored in the `mode`
/// column.
mode: serde_json::Value,
time_seconds: i64,
final_score: i64,
recorded_at: String,
@@ -126,7 +150,8 @@ pub async fn upload(
let header: ReplayHeader = serde_json::from_value(payload.clone())
.map_err(|e| AppError::BadRequest(format!("replay JSON missing fields: {e}")))?;
validate_header(&header)?;
let mode = mode_label(&header.mode)?;
validate_header(&header, &mode)?;
let id = Uuid::new_v4().to_string();
let received_at = Utc::now().to_rfc3339();
@@ -144,7 +169,7 @@ pub async fn upload(
user.user_id,
seed_i64,
header.draw_mode,
header.mode,
mode,
header.time_seconds,
header.final_score,
header.recorded_at,
@@ -158,7 +183,7 @@ pub async fn upload(
// beats their existing best. Only classic mode counts for the leaderboard.
// Use `received_at` (server-computed) rather than `header.recorded_at`
// (client-supplied) so clients cannot spoof the timestamp.
if header.mode == "Classic" {
if mode == "Classic" {
sqlx::query!(
r#"UPDATE leaderboard
SET best_score = ?,
+15 -18
View File
@@ -445,33 +445,30 @@ function showWin(s) {
submitReplay(s);
}
function buildReplayPayload(s) {
if (!game || !s) return null;
let moves;
function buildReplayPayload() {
if (!game) return null;
// The wasm side assembles the entire schema-v4 payload (including the
// session recording and the u64 seed, which JS numbers can't hold);
// JS only supplies the wall-clock time and today's date.
try {
moves = game.replay_moves();
if (!Array.isArray(moves) || moves.length === 0) return null;
const json = game.replay_export(
Math.max(1, elapsedSecs),
new Date().toISOString().slice(0, 10),
);
const payload = JSON.parse(json);
if (!Array.isArray(payload?.recording?.instructions)
|| payload.recording.instructions.length === 0) return null;
return payload;
} catch (e) {
console.warn("fs: replay export failed", e);
return null;
}
return {
schema_version: 2,
seed: Math.round(game.seed()),
draw_mode: drawThree ? "DrawThree" : "DrawOne",
mode: "Classic",
time_seconds: Math.max(1, elapsedSecs),
final_score: s.score,
recorded_at: new Date().toISOString().slice(0, 10),
moves,
win_move_index: moves.length - 1,
};
}
async function submitReplay(s) {
const token = localStorage.getItem('fs_token');
if (!token || !game) return;
const payload = buildReplayPayload(s);
const payload = buildReplayPayload();
if (!payload) return;
try {
await fetch('/api/replays', {
@@ -1029,7 +1026,7 @@ window.__FERROUS_DEBUG__ = {
},
replayPayload() {
if (!game) return null;
return buildReplayPayload(snap ?? game.state());
return buildReplayPayload();
},
runAutoplay(options) {
return runDebugAutoplay(options);
+10 -9
View File
@@ -159,16 +159,17 @@
function buildReplayPayload() {
if (!game) return null;
// Schema v4: the wasm side assembles the full payload,
// including the session recording and the u64 seed.
try {
const moves = game.replay_moves();
if (!Array.isArray(moves) || moves.length === 0) return null;
return {
schema_version: 2,
seed: Math.round(game.seed()),
draw_mode: game.debug_snapshot()?.draw_mode ?? "DrawOne",
mode: "Classic",
moves,
};
const json = game.replay_export(
1,
new Date().toISOString().slice(0, 10),
);
const payload = JSON.parse(json);
if (!Array.isArray(payload?.recording?.instructions)
|| payload.recording.instructions.length === 0) return null;
return payload;
} catch { return null; }
}
+12 -1
View File
@@ -121,7 +121,18 @@ function resetPlayer() {
playInterval = null;
btnPlay.textContent = "▶ Play";
}
player = new ReplayPlayer(replayJson);
// Old replays (schema < 4) are rejected by the wasm player with a
// descriptive error — surface it instead of leaving a dead board.
try {
player = new ReplayPlayer(replayJson);
} catch (e) {
captionEl.textContent = `Cannot play this replay: ${e}`;
btnStep.disabled = true;
btnPlay.disabled = true;
btnPrev.disabled = true;
btnRestart.disabled = true;
return;
}
btnPrev.disabled = true;
btnRestart.disabled = true;
btnStep.disabled = false;
+1 -28
View File
@@ -13,7 +13,7 @@ pub mod stats;
pub mod theme_store;
pub use achievements::AchievementRecord;
pub use merge::{merge, merge_at};
pub use merge::merge;
pub use progress::{PlayerProgress, level_for_xp};
pub use stats::StatsSnapshot;
pub use theme_store::{ThemeCatalogEntry, ThemeCatalogResponse};
@@ -98,30 +98,3 @@ pub struct LeaderboardEntry {
/// When this entry was last recorded.
pub recorded_at: DateTime<Utc>,
}
// ---------------------------------------------------------------------------
// Error types
// ---------------------------------------------------------------------------
/// Errors returned by the sync server in `application/json` error bodies.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
pub enum ApiError {
/// The request could not be authenticated (missing or invalid JWT).
#[error("unauthorized")]
Unauthorized,
/// The supplied credentials were incorrect.
#[error("invalid credentials")]
InvalidCredentials,
/// A username that was requested for registration is already taken.
#[error("username already taken")]
UsernameTaken,
/// The request payload was too large (> 1 MB).
#[error("payload too large")]
PayloadTooLarge,
/// The request body could not be parsed.
#[error("bad request: {0}")]
BadRequest(String),
/// An unexpected server-side error occurred.
#[error("internal server error")]
Internal,
}
+146 -46
View File
@@ -26,16 +26,21 @@ use solitaire_core::{
DrawStockConfig,
game_state::{GameMode, GameState},
};
use solitaire_core::{KlondikeInstruction, KlondikePile};
use solitaire_core::{KlondikeInstruction, KlondikePile, SessionRecording};
use wasm_bindgen::prelude::*;
/// Mirrors `solitaire_data::Replay` v3.
/// Replay schema version this player understands. Mirrors
/// `solitaire_data::REPLAY_SCHEMA_VERSION`; the loader rejects any
/// other version with a descriptive error instead of desyncing.
pub const REPLAY_SCHEMA_VERSION: u32 = 4;
/// Mirrors `solitaire_data::Replay` v4.
///
/// `moves` is a list of upstream [`KlondikeInstruction`]s — the same
/// move-currency `solitaire_core` persists. A stock click is
/// `KlondikeInstruction::RotateStock`; a card move is a
/// `DstFoundation` / `DstTableau` instruction. Pile-position types are
/// runtime-only and intentionally not part of the wire format.
/// `recording` is the upstream `card_game` session serialisation
/// (`{config, initial_state, instructions}`): the dealt board is stored
/// explicitly, so playback rebuilds the exact deal instead of re-dealing
/// from `seed` — schemas ≤ v3 did the latter and silently broke whenever
/// an RNG or upstream upgrade changed the seed→deal mapping.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Replay {
#[serde(default)]
@@ -46,7 +51,9 @@ pub struct Replay {
pub time_seconds: u64,
pub final_score: i32,
pub recorded_at: NaiveDate,
pub moves: Vec<KlondikeInstruction>,
pub recording: SessionRecording,
#[serde(default)]
pub win_move_index: Option<usize>,
}
/// JS-friendly snapshot of a `GameState` at a particular replay step.
@@ -125,10 +132,20 @@ impl ReplayPlayer {
pub fn from_json(replay_json: &str) -> Result<Self, String> {
let replay: Replay =
serde_json::from_str(replay_json).map_err(|e| format!("invalid replay JSON: {e}"))?;
let game = GameState::new_with_mode(replay.seed, replay.draw_mode, replay.mode);
if replay.schema_version != REPLAY_SCHEMA_VERSION {
return Err(format!(
"unsupported replay schema_version {} (this player requires {}); \
replays recorded by older clients cannot be replayed",
replay.schema_version, REPLAY_SCHEMA_VERSION
));
}
// The recording carries the dealt board and session config, so the
// rebuilt game is bit-identical to the recorded deal no matter how
// the current build maps seeds to deals.
let (game, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode);
Ok(Self {
game,
moves: replay.moves,
moves,
step_idx: 0,
})
}
@@ -510,6 +527,36 @@ impl SolitaireGame {
self.game.instruction_history()
}
/// Builds the complete schema-v4 replay upload payload for the live
/// game as a JSON string, ready to `POST /api/replays` verbatim.
///
/// The JS layer must not assemble this payload itself: the recording
/// serialises through the upstream `card_game` serializers, and the
/// `u64` seed exceeds JS number precision (`Math.round(game.seed())`
/// silently corrupts it).
///
/// `recorded_at` is an ISO-8601 date (`YYYY-MM-DD`); the browser
/// supplies it because the wasm build has no reliable local clock.
fn replay_export_native(&self, time_seconds: u64, recorded_at: &str) -> Result<String, String> {
let recorded_at: NaiveDate = recorded_at
.parse()
.map_err(|e| format!("invalid recorded_at date '{recorded_at}': {e}"))?;
let recording = self.game.recording();
let win_move_index = recording.len().checked_sub(1);
let replay = Replay {
schema_version: REPLAY_SCHEMA_VERSION,
seed: self.game.seed,
draw_mode: self.game.draw_mode(),
mode: self.game.mode,
time_seconds,
final_score: self.game.score(),
recorded_at,
recording,
win_move_index,
};
serde_json::to_string(&replay).map_err(|e| format!("replay serialisation failed: {e}"))
}
fn debug_snapshot_native(&self) -> DebugSnapshot {
let legal_moves = self.legal_moves_native();
let invariants = invariant_report_for_game(&self.game, &legal_moves);
@@ -694,6 +741,17 @@ impl SolitaireGame {
serde_wasm_bindgen::to_value(&moves).map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Complete schema-v4 replay payload for the live game, as a JSON
/// string ready to `POST /api/replays` verbatim. See
/// [`Self::replay_export_native`] for why JS must not assemble the
/// payload itself. `recorded_at` is an ISO-8601 `YYYY-MM-DD` date.
/// `time_seconds` is `u32` so JS can pass a plain number (a `u64`
/// would demand a `BigInt`).
pub fn replay_export(&self, time_seconds: u32, recorded_at: String) -> Result<String, JsValue> {
self.replay_export_native(u64::from(time_seconds), &recorded_at)
.map_err(|e| JsValue::from_str(&e))
}
/// Returns all currently-legal debug moves as a JS array.
///
/// Includes [`DebugMove::StockClick`] when stock interaction is legal.
@@ -897,44 +955,25 @@ mod tests {
"progressed game must export a non-empty replay move list"
);
let moves_json = match serde_json::to_value(&exported_moves) {
Ok(value) => value,
Err(err) => panic!("failed to serialise exported replay moves: {err}"),
};
assert!(
moves_json.is_array(),
"exported replay moves must serialise as a JSON array"
);
let parsed_back: Vec<KlondikeInstruction> = match serde_json::from_value(moves_json) {
Ok(parsed) => parsed,
Err(err) => {
panic!("failed to parse replay move JSON as KlondikeInstruction list: {err}")
}
};
assert_eq!(
parsed_back, exported_moves,
"replay move JSON must round-trip through KlondikeInstruction"
);
let recorded_at = match NaiveDate::from_ymd_opt(2026, 6, 1) {
Some(date) => date,
None => panic!("invalid recorded_at date in test"),
};
let replay = Replay {
schema_version: 3,
seed,
draw_mode,
mode: GameMode::Classic,
time_seconds: 120,
final_score: game.game.score(),
recorded_at,
moves: exported_moves,
};
let replay_json = match serde_json::to_string(&replay) {
let replay_json = match game.replay_export_native(120, "2026-06-01") {
Ok(json) => json,
Err(err) => panic!("failed to serialise replay JSON: {err}"),
Err(err) => panic!("failed to export replay JSON: {err}"),
};
let parsed: Replay = match serde_json::from_str(&replay_json) {
Ok(parsed) => parsed,
Err(err) => panic!("exported replay JSON must parse back as Replay: {err}"),
};
assert_eq!(parsed.schema_version, REPLAY_SCHEMA_VERSION);
assert_eq!(
parsed.recording.instructions(),
exported_moves,
"exported recording must carry the exact instruction history"
);
assert_eq!(
parsed.win_move_index,
Some(exported_moves.len() - 1),
"win_move_index must point at the last instruction"
);
let mut player = match ReplayPlayer::from_json(&replay_json) {
Ok(value) => value,
@@ -962,6 +1001,67 @@ mod tests {
);
}
/// Pre-v4 replays re-dealt from the seed at playback time — the exact
/// mechanism that broke when the seed→deal mapping changed. The player
/// must refuse them with a version error, never desync silently.
#[test]
fn replay_player_rejects_pre_v4_schema_versions() {
let v3_json = r#"{
"schema_version": 3,
"seed": 7,
"draw_mode": "DrawOne",
"mode": "Classic",
"time_seconds": 60,
"final_score": 100,
"recorded_at": "2026-05-01",
"recording": null,
"moves": []
}"#;
// v3 files carry `moves`, not `recording`; either way the version
// gate (or the missing field) must produce an error, not a player.
let err = match ReplayPlayer::from_json(v3_json) {
Err(err) => err,
Ok(_) => panic!("v3 replay must be rejected"),
};
assert!(
err.contains("schema_version") || err.contains("invalid replay JSON"),
"error must name the version/format problem, got: {err}"
);
}
/// The whole point of v4: playback rebuilds the deal from the
/// recording, so a replay stays correct even when the top-level
/// `seed` no longer maps to the same deal (RNG upgrades, or a
/// corrupted seed from the old JS `Math.round` path).
#[test]
fn replay_playback_ignores_seed_for_dealing() {
let game = SolitaireGame {
game: GameState::new_with_mode(51, DrawStockConfig::DrawOne, GameMode::Classic),
};
let replay_json = game
.replay_export_native(60, "2026-06-01")
.expect("export must succeed");
// Corrupt the seed field only — playback must be unaffected.
let mut value: serde_json::Value =
serde_json::from_str(&replay_json).expect("parse exported JSON");
value["seed"] = serde_json::Value::from(0_u64);
let corrupted = serde_json::to_string(&value).expect("reserialise");
let player = ReplayPlayer::from_json(&corrupted).expect("player must construct");
let original_deal = serde_json::to_string(&game.snap()).expect("serialise original deal");
let replayed_deal =
serde_json::to_string(&player.snapshot()).expect("serialise replayed deal");
// Compare the board projections (piles), not the GameState wrapper
// (whose serde includes the now-different seed metadata).
let orig: serde_json::Value = serde_json::from_str(&original_deal).expect("parse");
let repl: serde_json::Value = serde_json::from_str(&replayed_deal).expect("parse");
assert_eq!(
orig["tableaus"], repl["tableaus"],
"tableau deal must come from the recording, not the seed"
);
assert_eq!(orig["stock"], repl["stock"], "stock deal must match");
}
#[test]
fn debug_api_autonomous_seed_batch_smoke() {
for seed in 0_u64..128_u64 {