Compare commits

..

71 Commits

Author SHA1 Message Date
funman300 65913de2cb feat(engine): contextual one-time tips — stall hint, radial menu teach (Phase I)
Test / fmt (pull_request) Successful in 4s
Test / test (pull_request) Successful in 4m15s
Two situation-fired teaches, each shown exactly once and recorded in
Settings like shown_achievement_onboarding:

- Stall tip: 45s with no board change in an active, started game
  (clock frozen while paused / a modal is open) points at Hint with
  platform-correct copy (H on desktop, bottom-bar Hint on touch).
- Radial teach: a player 15 moves into a game who has never opened the
  radial menu learns the long-press / right-click gesture. Organic
  radial use marks the tip done silently — nobody is taught what they
  already know.

Tips ride the queued InfoToastEvent path, so they render in the unified
toast stack and never interrupt play. This completes Phase I.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:16:56 -07:00
funman300 25f1fd27d9 feat(engine): one-shot What's-new card after updates (Phase I)
Test / fmt (pull_request) Successful in 5s
Test / test (pull_request) Successful in 5m0s
ObtainX updates install silently, so shipped features went unnoticed.
On the first launch where the running release differs from the new
Settings::last_seen_whats_new, a dismissible card summarises the
latest CHANGELOG.md section (embedded; its top version doubles as the
app's release identity — no build-time version plumbing). Internal
sections are dropped and bullets reduce to their bold lead sentence.

Launch beat: splash -> onboarding (first run) -> what's-new -> Home;
spawn_home_on_launch waits on the new WhatsNewPending resource. Fresh
installs never see the card — onboarding completion stamps the current
version silently. The seen-stamp persists on spawn, not dismissal, so
the card can never nag twice.

8 new tests (changelog parsing incl. the real embedded file, upgrade/
seen/fresh-install gating, dismissal). Workspace + clippy green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:05:15 -07:00
funman300 9a0d6496c5 docs(changelog): cut 0.46.0 — Phase H feedback polish
Android Release / build-apk (push) Successful in 5m36s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:50:29 -07:00
funman300 7669a1bb56 Merge pull request 'feat(engine): theme-store preview thumbnails (Phase H part 2)' (#179) from feat/theme-store-previews into master
Test / fmt (push) Successful in 4s
Test / test (push) Successful in 4m41s
Build and Deploy / build-and-push (push) Successful in 8m56s
Web E2E / web-e2e (push) Successful in 7m33s
2026-07-14 00:46:46 +00:00
funman300 ddee605874 feat(engine): hint ghost-motion preview (Phase H)
Test / fmt (pull_request) Successful in 4s
Test / test (pull_request) Successful in 4m20s
A hint now spawns a translucent copy of the hinted card that glides to
the suggested destination twice (0.7s per pass, SmoothSnap easing, tail
fade so the loop reads as a repeat), alongside the existing static
source/destination highlights. Auto-disabled under reduce-motion — the
static highlights remain the whole story. The ghost despawns on timer,
on a fresh hint, or the moment the board changes (a preview of a stale
board is worse than none).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:40:53 -07:00
funman300 ae8118d7df feat(engine): theme-store preview thumbnails (Phase H part 2)
Test / fmt (pull_request) Successful in 4s
Test / test (pull_request) Successful in 4m36s
The live catalog has advertised preview_url per theme since the store
shipped, but the client never fetched it — rows were text-only. The
modal now downloads each advertised preview PNG off the main thread
(same AsyncComputeTaskPool + Tokio pattern as the catalog fetch and
avatar image), decodes it into an Image asset, and renders a thumbnail
sized by the theme's own card_aspect at the head of the row. Previews
pop in as they arrive and are cached for the session, so reopening the
store is instant. Failures log and leave the row text-only; the decode
path is skipped entirely under MinimalPlugins (no Assets<Image>).

solitaire_data grows ThemeStoreClient::fetch_preview with a 512 KB cap
(previews carry no checksum — decorative only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:26:47 -07:00
funman300 739c94d3bb Merge pull request 'polish(engine): Phase H part 1 — unified toast stack, onboarding fixes' (#178) from feat/feedback-polish into master
Test / fmt (push) Successful in 4s
Test / test (push) Successful in 4m24s
Build and Deploy / build-and-push (push) Successful in 9m8s
Web E2E / web-e2e (push) Successful in 8m15s
2026-07-14 00:14:03 +00:00
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 ea8d8eee9a ci(web): build wasm in the deploy pipeline instead of committing it (#156)
Test / test (pull_request) Successful in 35m33s
The wasm bundles in solitaire_server/web/pkg/ are no longer tracked. The
web-wasm-rebuild workflow (which rebuilt them in CI and committed them
back to master) is gone; instead:

- solitaire_server/Dockerfile gains a wasm-builder stage that runs
  build_wasm.sh with the same pinned toolchain (wasm-bindgen 0.2.120,
  wasm-pack 0.14.0, binaryen 130) and the runtime image copies pkg/
  from it — the image build is now the artifacts' single source of truth.
- web-e2e builds the wasm before Playwright runs, and its trigger paths
  now include the wasm-feeding crates it actually tests.
- docker-build triggers on solitaire_data/** and build_wasm.sh too, so
  every wasm-affecting change redeploys.
- Self-hosters serving /web or /play from a source checkout run
  ./build_wasm.sh once (script header and ARCHITECTURE.md updated).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 13:15:37 -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
Gitea CI 15c924c3dc chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m13s
Web E2E / web-e2e (push) Successful in 5m8s
2026-07-09 19:11:44 +00:00
funman300 ede58f9666 Merge pull request 'fix(engine): restore 60 fps Android card animations + dead-code sweep' (#166) from fix/android-anim-redraw into master
Build and Deploy / build-and-push (push) Successful in 5m52s
Web E2E / web-e2e (push) Successful in 6m42s
Web WASM Rebuild / rebuild (push) Successful in 8m56s
Android Release / build-apk (push) Successful in 5m53s
Test / test (push) Successful in 36m7s
2026-07-09 18:51:06 +00:00
funman300 ccfb9394e0 chore: remove dead code and stale doc claims found in multi-agent sweep
Test / test (pull_request) Successful in 37m58s
Compiler-verified via RUSTFLAGS=--force-warn dead_code plus workspace-wide
reference greps; three parallel audit agents covered the engine crate, the
other eight crates, and Copilot commit-message-vs-diff drift.

Removed:
- replay_overlay/input.rs: 19 orphaned twins (~950 lines) of items also
  defined in mod.rs — the glob re-export made the mod.rs copies win and
  the file-level #![allow(dead_code)] hid the corpses. The live keyboard/
  button handlers and ReplayScrubKeyHold stay; the allow is retired.
- retarget_animation (never called; doc examples were its only refs)
- ScanThemesRequestEvent (never registered/written/read; its doc claimed
  a handle_scan_themes consumer that does not exist)
- _VEC3_REFERENCED workaround const + now-unneeded Vec3 import
- solitaire_data: load_stats/save_stats/time_attack_session_with_now
  default-path wrappers (the _from/_to variants are the live API) and
  surplus re-export names (settings MIN/MAX bounds, token loaders)
- solitaire_core: Session re-export (no external consumer)
- solitaire_wasm: ReplayPlayer::is_finished (no JS caller)
- solitaire_app: build_app wrapper (real entry is run())

Doc fixes:
- audio_plugin: WAV count 5→7, add FoundationCompletedEvent table row,
  drop bogus 'placeholder' label, bevy_kira_audio→kira
- ToastVariant::Warning: variant is live (5 writer plugins); dropped the
  stale allow(dead_code) and its 'currently unused' comment

Deliberately kept: Spider module (staged forward work), WinCascadePlugin
(documented alternative cascade, pending owner decision), SyncCompleteEvent
and solitaire_sync ApiError/merge_at (§8 change-controlled, flagged to owner).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 11:48:33 -07:00
funman300 b5c1ba4867 fix(engine): emit RequestRedraw from animation systems — restores 60 fps card animation on Android
Commit 38e4c03 (shipped in v0.40.0) switched Android's focused_mode to
reactive_low_power(100 ms) on the premise that every animation tick
system writes RequestRedraw while it has active work. The writers were
never actually added — the diff only contained the WinitSettings change,
imports, and add_message registrations. Result: with no touch input,
nothing wakes the winit loop during a card slide except the 100 ms
fallback ceiling, so animations render at ~10 fps on Android. Desktop
and web keep Continuous mode, which is why only Android was affected
(reported by Rhys the day v0.40.0 shipped; confirmed absent on v0.39.1).

Adds the missing MessageWriter<RequestRedraw> to all seven systems the
original commit message named:
- advance_card_animations (CardAnimationPlugin)
- advance_card_anims (AnimationPlugin — deal/win cascade/slides)
- tick_shake_anim, tick_settle_anim, tick_foundation_flourish
  (FeedbackAnimPlugin)
- drive_toast_display (AnimationPlugin — toast countdown)
- drive_auto_complete (AutoCompletePlugin — step-interval keepalive)

Each writes one RequestRedraw per frame while active work exists
(including delay phases, which also need per-frame ticks). Regression
test asserts an active CardAnimation emits RequestRedraw and an idle
board does not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 11:25:23 -07:00
Gitea CI d179d9d582 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m46s
Web E2E / web-e2e (push) Successful in 8m27s
2026-07-09 05:53:00 +00:00
funman300 db1cc58f3a Merge pull request 'refactor(core): align Spider with upstream card_game idioms' (#162) from refactor/spider-upstream-idioms into master
Build and Deploy / build-and-push (push) Successful in 6m17s
Web E2E / web-e2e (push) Successful in 6m11s
Web WASM Rebuild / rebuild (push) Successful in 10m7s
Android Release / build-apk (push) Successful in 6m21s
Test / test (push) Successful in 36m4s
2026-07-09 05:26:50 +00:00
funman300 255b781420 Merge pull request 'feat(engine): group HUD menu into Play/You/Community/System + Esc dismissal audit' (#165) from feat/menu-grouping into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 2m12s
Test / test (push) Successful in 36m2s
2026-07-09 05:10:02 +00:00
funman300 5b5d587818 Merge pull request 'feat(engine): win-summary action hierarchy — Play Again, Watch, Share' (#164) from feat/win-flow into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 2m22s
Test / test (push) Successful in 36m55s
2026-07-09 05:09:35 +00:00
Gitea CI 2b2e7a7f2c chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 5m57s
Web E2E / web-e2e (push) Successful in 9m40s
2026-07-09 05:09:10 +00:00
funman300 5c4d440b31 Merge pull request 'feat(engine): You hub — Profile/Stats/Achievements/Replays in one tabbed modal (Phase E)' (#161) from feat/you-hub into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 2m18s
Test / test (push) Successful in 38m25s
2026-07-09 05:02:07 +00:00
funman300 374858ab6d Merge pull request 'feat(engine): tabbed Settings panel — Phase A of the menu redesign' (#160) from feat/settings-tabs into master
Build and Deploy / build-and-push (push) Successful in 2m23s
Web WASM Rebuild / rebuild (push) Waiting to run
Test / test (push) Successful in 37m11s
2026-07-09 04:59:11 +00:00
funman300 38b82d4858 feat(engine): win-summary action hierarchy — Play Again, Watch, Share
Test / test (pull_request) Successful in 36m24s
Phase G of docs/ui-redesign-2026-07.md. The win modal now leads with
actions and reads the stats recap quietly below them:

- Play Again: primary, full-row, Enter accelerator unchanged; still
  fires NewGameRequestEvent::default() (same mode; deal options from
  Settings) so the rematch is one tap.
- Watch Replay / Share Replay: secondary pair reusing the global
  stats_plugin markers (WatchReplayButton / CopyShareLinkButton), so
  both act on the just-won replay — SelectedReplayIndex snaps to 0 on
  every win. This also fixes the old win-modal handler picking
  replays.last(), which after the newest-first history refactor was
  the OLDEST replay, not the newest.
- Watch closes the celebration overlay (new close_overlay_on_watch_-
  replay system); Share keeps it open and relies on the existing
  copy-feedback toasts.
- Stats recap (score breakdown reveal, time, XP, achievements) moves
  below the actions; the Time line drops from headline/primary to
  body/secondary styling.

Tests: 4 new (action presence, Play Again close+request, Watch closes,
Share keeps open) on a manual-clock fixture that steps the 0.5 s
celebration delay deterministically. Workspace suite green, workspace
clippy -D warnings clean, fmt applied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 20:37:32 -07:00
funman300 4700bd7912 fix(engine): close Settings, Help, Leaderboard, and theme store on Esc
Test / test (pull_request) Successful in 10m16s
Phase C dismissal audit: Esc / scrim-tap / Done must behave the same
on every modal. Stragglers found and fixed — none of these had any
Esc path (pause's toggle guard swallowed the key while they were
open):

- Settings: Esc clears SettingsScreen, gated on being the topmost
  modal so a stacked sync-setup / theme-store dialog owns Esc
- Help: Esc closes alongside F1/Done (the code comment already
  claimed an Esc path existed — now it does)
- Leaderboard: Esc closes when topmost; the display-name dialog
  stacked above it now Esc-cancels like sync-setup's dialog
- Theme store: Esc closes (always topmost when open)

Scrim-tap opt-ins are unchanged — ui_modal documents which modals
deliberately stay non-dismissible on outside clicks.

Tests: escape_closes_help_screen, escape_closes_settings_screen_flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 20:31:55 -07:00
funman300 d4448bf0cd feat(engine): group HUD menu popover into Play/You/Community/System sections
Phase C of docs/ui-redesign-2026-07.md. The Modes row is gone — Home
owns mode selection, so the popover's Play section carries a Home row
firing the new ToggleHomeRequestEvent (read by toggle_home_screen
alongside the existing M accelerator). Section headers are quiet
caption-size labels inside the existing panel widget, not a new
widget. The action-bar Modes button and its popover are untouched
(their removal is Phase B territory when Home gains hierarchy).

- MenuOption: Modes variant replaced by Home; rows grouped Play (Home)
  · You (Profile, Stats, Achievements) · Community (Leaderboard) ·
  System (Settings, Help)
- handle_menu_option_click no longer chains into spawn_modes_popover
- Tests: tooltip sweep updated (7 rows still), new
  toggle_home_event_opens_home_screen covers the popover's open path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 20:31:44 -07:00
Gitea CI e1d91bee73 ci(web-wasm-rebuild): keep on Docker runner (host executor incompatible with taiki-e) [skip ci] 2026-07-08 15:18:40 -07:00
Gitea CI 9dcd25b3e7 ci(web-wasm-rebuild): set RUNNER_OS/ARCH on the taiki-e step (step-level env)
Web WASM Rebuild / rebuild (push) Failing after 1m54s
2026-07-08 15:01:12 -07:00
Gitea CI 2ef0bce1ea ci(web-wasm-rebuild): set RUNNER_OS/RUNNER_ARCH for host-executor runner
Web WASM Rebuild / rebuild (push) Failing after 1m54s
2026-07-08 14:53:42 -07:00
Gitea CI 326ef6894a ci: route web-wasm-rebuild workflow to the rust-host runner (CT 107)
Web WASM Rebuild / rebuild (push) Failing after 2m0s
2026-07-08 13:27:34 -07:00
Gitea CI caaafe34e0 ci(test): route test workflow to the rust-host runner (CT 107)
Test / test (push) Failing after 1m3s
2026-07-08 12:27:34 -07:00
Gitea CI 2254693a7a build(wasm): honor CARGO_TARGET_DIR in build_wasm.sh [skip ci] 2026-07-08 12:25:09 -07:00
Gitea CI 4ba646738c chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m24s
Web E2E / web-e2e (push) Successful in 5m33s
2026-07-08 03:08:06 +00:00
funman300 251d35bc28 Merge pull request 'feat: Show solution — winning_line core API + auto-playback (card_game bucket 4)' (#159) from feat/solution-line into master
Build and Deploy / build-and-push (push) Successful in 6m20s
Test / test (push) Successful in 11m24s
Web E2E / web-e2e (push) Successful in 4m46s
Web WASM Rebuild / rebuild (push) Successful in 8m10s
2026-07-08 01:08:58 +00:00
funman300 1d2b6dc5de refactor(core): align Spider with upstream card_game idioms
Test / test (pull_request) Successful in 37m4s
Addresses upstream author review of the Spider core (PR #157):

- RNG: drop the hand-rolled SplitMix64 + Fisher-Yates; deals now use
  rand::rngs::StdRng seeded via seed_from_u64 + SliceRandom::shuffle,
  exactly like klondike::with_seed. solitaire_core gains the same
  pinned rand dep klondike uses (0.10.1, std_rng only — already in the
  lock, no new transitive deps). Deals for a given seed change; Spider
  has no persisted games yet, so nothing breaks.
- Enums over integers: pile indices and card counts in the instruction
  type are now SpiderTableau (Tableau1..Tableau10, with an ALL const)
  and RunLength (Run1..Run13); out-of-range piles and zero-card moves
  are unrepresentable. Rank comparisons use Rank::checked_add instead
  of u8 arithmetic.
- Fixed-size containers: build_deck returns Stack<104> instead of
  Vec<Card>; possible_instructions is a const-iterated SpiderIter +
  validity filter (klondike's KlondikeIter pattern) instead of
  collecting into a Vec.
- Upstream naming: SpiderGame -> Spider (matches Klondike),
  tableau_face_up/_down -> tableau_face_up_cards/_down_cards,
  stock_len -> stock() accessor, with_rng added alongside with_seed.
- Solver access: SpiderGameState now exposes session(), the same
  escape hatch GameState has — the wrapper no longer pretends to hide
  solve(), which SessionConfig budgets already gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:58:57 -07:00
funman300 fe3c3aed31 fix(ci): cap cargo to two parallel jobs so concurrent test-binary links fit runner memory
Test / test (push) Successful in 36m31s
Runs 514/516/519 (incl. master itself) died on 'ld terminated with
signal 9' — the workspace now links enough large test binaries that
per-core parallel linking OOMs the runner even at line-tables-only
debuginfo. CARGO_BUILD_JOBS=2 keeps at most two links in flight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:33:36 -07:00
Gitea CI d0e4ce796b chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m24s
Web E2E / web-e2e (push) Successful in 4m12s
2026-07-08 00:24:36 +00:00
funman300 9f038250d9 feat(engine): You hub — Profile/Stats/Achievements/Replays in one tabbed modal
Test / test (pull_request) Failing after 8m41s
Phase E of docs/ui-redesign-2026-07.md. New you_hub_plugin owns the
modal shell (header, shared tab chips, single Done); each tab's content
is a body builder extracted from its original plugin with every marker
component unchanged, so per-row update/scroll/selector systems keep
working. The replay selector gets its own Replays tab (Watch/Copy
buttons move into the tab body).

- Toggle*RequestEvents + P/S/A accelerators open the hub on the right
  tab, switch tabs in place, or toggle closed on a same-tab request;
  Esc/Done/scrim-click close
- Legacy ProfileScreen/StatsScreen/AchievementsScreen markers ride the
  hub scrim for the active tab — external queries and tests keep their
  meaning
- Standalone toggle/close systems and per-screen Done buttons removed
  (ProfileCloseButton, StatsCloseButton, AchievementsCloseButton)
- Tests: 2 new hub lifecycle tests; profile/stats/achievements modal
  tests adapted (fixtures add YouHubPlugin; selector tests target the
  Replays tab). Engine suite 916 green, clippy -D warnings, fmt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:10:56 -07:00
funman300 0c69d6859d refactor(engine): extract shared spawn_tab_chip widget into ui_modal
Settings' tab_chip becomes a thin wrapper; the You hub (Phase E) will
reuse the same widget so tabbed modals stay visually identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:52:31 -07:00
funman300 50d6d41d85 feat(engine): tabbed Settings panel — Phase A of the menu redesign
Test / test (pull_request) Failing after 7m15s
Replaces the 37-row single-scroll settings modal with five tabs
(Audio, Gameplay, Appearance, Accessibility, Account); only the active
tab's rows spawn, so every tab fits without scroll-hunting.

- SettingsTab + ActiveSettingsTab (session-only) + SettingsTabButton
  chips under the modal header; rebuild-on-switch mirrors the
  leaderboard despawn/respawn pattern via a shared build_panel helper
- Accessibility tab gathers color-blind, high-contrast, reduce-motion,
  touch-input, and tooltip-delay from the old Gameplay/Cosmetic mix;
  Account = Sync + Privacy
- SettingsButton enum, input handlers, and persistence untouched
- Tests: per-tab Focusable/Tooltip sweeps, tab-switch rebuild test,
  picker/thumbnail tests pinned to the Appearance tab

Plan: docs/ui-redesign-2026-07.md (also added, phases A–M)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:47:06 -07:00
funman300 9e4d4a6716 Merge pull request 'feat(core): Spider rules as a second card_game::Game implementation' (#157) from feat/spider-core into master
Build and Deploy / build-and-push (push) Successful in 6m3s
Web E2E / web-e2e (push) Successful in 5m7s
Web WASM Rebuild / rebuild (push) Successful in 7m10s
Test / test (push) Successful in 9m31s
2026-07-07 23:40:43 +00:00
funman300 ea1014285d feat(engine): Show solution — auto-play the winning line from the pause menu
Test / test (pull_request) Successful in 15m21s
New SolutionPlaybackPlugin: the pause modal's 'Show solution' button
resumes the game and requests a solve of the live position via
GameState::winning_line on AsyncComputeTaskPool (never blocks the main
thread; stale results discarded via a move_count snapshot). The line
then plays back one instruction per 0.45 s through the normal
MoveRequestEvent / DrawRequestEvent pipeline — animations, scoring,
undo history and win detection behave as if the player made the moves.
Tableau run counts are decoded against the live state at step time.

Playback cancels on Esc, pause, undo / new-game requests, and any
rejected move (the signature of the player diverging the board).
Toasts cover search start, line found, unwinnable, and budget-
exhausted outcomes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:33:49 -07:00
Gitea CI 4f849a23b8 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m20s
Web E2E / web-e2e (push) Successful in 4m55s
2026-07-07 23:31:55 +00:00
funman300 299f6bfea7 feat(core): winning_line — full solver line via Solution::clean_solution
New GameState::winning_line(moves_budget, states_budget) returns the
complete instruction sequence to a win (Ok(None) when unwinnable or
already won, Err on budget exhaustion), compacting the raw DFS trace
with the previously unused card_game Solution::clean_solution and
stripping foundation→foundation no-ops when the stripped line still
replays to a win — an internal replay check guarantees the returned
sequence always applies cleanly via apply_instruction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:19:12 -07:00
funman300 8c0eb3dfab Merge pull request 'fix(engine): force full board repaint when the decor-view poller fires (#130)' (#155) from fix/130-forced-repaint into master
Build and Deploy / build-and-push (push) Successful in 2m13s
Test / test (push) Successful in 8m7s
Web WASM Rebuild / rebuild (push) Successful in 7m29s
2026-07-07 23:14:11 +00:00
funman300 a39e04329e fix(engine): force full board repaint when the decor-view poller fires (#130)
Test / test (pull_request) Successful in 12m14s
The #152 poller fixes resolution + layout on a missed fold/unfold, but
a transient clip could survive if card sprites held stale visuals after
geometry converged. Emit StateChangedEvent alongside the synthetic
WindowResized so card_plugin re-renders every sprite from scratch —
silent self-heal, no player-facing prompt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:01:25 -07:00
82 changed files with 7821 additions and 6529 deletions
+5 -6
View File
@@ -10,9 +10,11 @@ on:
- 'solitaire_web/**'
- 'solitaire_sync/**'
- 'solitaire_core/**'
- 'solitaire_data/**'
- 'solitaire_engine/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'build_wasm.sh'
- 'solitaire_server/Dockerfile'
- '.gitea/workflows/docker-build.yml'
@@ -36,12 +38,9 @@ jobs:
id: meta
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
# WASM artifact freshness is owned by the `web-wasm-rebuild` workflow,
# which rebuilds pkg/ in CI on every master change to a wasm-feeding crate
# and commits it back (CI is the single source of truth — the artifacts
# aren't byte-reproducible on contributor machines). That pkg/ commit then
# triggers this workflow, so the deployed image always ships fresh wasm.
# No drift check is needed here.
# The wasm bundles (solitaire_server/web/pkg/) are not in the repo —
# the Dockerfile's wasm-builder stage builds them from source inside
# this image build, so the deployed image always ships fresh wasm.
- name: Log in to Gitea registry
uses: docker/login-action@v3
+64 -8
View File
@@ -1,7 +1,20 @@
# Workspace gate: the same clippy + test commands CLAUDE.md §6 requires
# 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 (or the web-wasm-rebuild bot commit) was entirely unguarded.
# 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,15 +41,50 @@ 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: ubuntu-latest
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.
# line-tables-only keeps file:line in panic backtraces while cutting the
# link's memory footprint enough to fit the runner.
#
# CARGO_BUILD_JOBS=2: with one job per core, cargo links several large
# 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 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
@@ -46,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.
@@ -60,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)
+25
View File
@@ -8,9 +8,13 @@ on:
- 'solitaire_server/src/**'
- 'solitaire_server/e2e/**'
- 'solitaire_wasm/**'
- 'solitaire_web/**'
- 'solitaire_engine/**'
- 'solitaire_data/**'
- 'solitaire_core/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'build_wasm.sh'
- '.gitea/workflows/web-e2e.yml'
workflow_dispatch:
@@ -24,10 +28,31 @@ jobs:
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- name: Cache cargo build
uses: Swatinem/rust-cache@v2
# The wasm bundles (solitaire_server/web/pkg/) are not in the repo —
# build them here so the served pages have real wasm to load. Tool
# versions are pinned; keep in sync with solitaire_server/Dockerfile.
- name: Install wasm-bindgen-cli + wasm-pack (pinned)
uses: taiki-e/install-action@v2
with:
tool: wasm-bindgen-cli@0.2.120,wasm-pack@0.14.0
- name: Install binaryen 130 (wasm-opt, pinned)
run: |
set -euo pipefail
curl -sSL \
https://github.com/WebAssembly/binaryen/releases/download/version_130/binaryen-version_130-x86_64-linux.tar.gz \
| tar xz
echo "$PWD/binaryen-version_130/bin" >> "$GITHUB_PATH"
- name: Build WASM artifacts
run: ./build_wasm.sh
# Prebuild the server so Playwright's `webServer` (which runs
# `cargo run -p solitaire_server`) starts from a compiled binary instead
# of cold-compiling the whole dependency graph (axum/sqlx/reqwest) inside
-89
View File
@@ -1,89 +0,0 @@
name: Web WASM Rebuild
# CI is the single source of truth for solitaire_server/web/pkg/.
#
# The wasm artifacts cannot be reproduced byte-for-byte on an arbitrary
# contributor machine: even with identical rustc 1.95.0 / LLVM 22.1.2, the same
# flags, the same Cargo.lock and remapped source paths, the output still differs
# by host environment. So rather than police freshness with a rebuild-and-diff
# gate (which false-failed for exactly that reason), CI rebuilds the artifacts
# itself on every master change to a wasm-feeding crate and commits them back.
#
# Result: the deployed pkg/ can't silently rot, and contributors never need to
# run build_wasm.sh by hand. The commit touches only pkg/, which is not in this
# workflow's trigger paths (so it does not re-trigger here) but does match
# docker-build's, so the refreshed wasm deploys.
on:
push:
branches: [master]
paths:
- 'solitaire_core/**'
- 'solitaire_engine/**'
- 'solitaire_data/**'
- 'solitaire_sync/**'
- 'solitaire_wasm/**'
- 'solitaire_web/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'build_wasm.sh'
- '.gitea/workflows/web-wasm-rebuild.yml'
workflow_dispatch:
concurrency:
group: web-wasm-rebuild
cancel-in-progress: false
jobs:
rebuild:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.CI_TOKEN }}
- name: Install Rust 1.95.0
uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.95.0
targets: wasm32-unknown-unknown
- name: Cache cargo build
uses: Swatinem/rust-cache@v2
- name: Install wasm-bindgen-cli + wasm-pack (pinned)
uses: taiki-e/install-action@v2
with:
tool: wasm-bindgen-cli@0.2.120,wasm-pack@0.14.0
- name: Install binaryen 130 (wasm-opt, pinned)
run: |
set -euo pipefail
curl -sSL \
https://github.com/WebAssembly/binaryen/releases/download/version_130/binaryen-version_130-x86_64-linux.tar.gz \
| tar xz
echo "$PWD/binaryen-version_130/bin" >> "$GITHUB_PATH"
- name: Rebuild WASM artifacts
run: ./build_wasm.sh
- name: Commit refreshed artifacts if changed
run: |
set -euo pipefail
if git diff --quiet -- solitaire_server/web/pkg/; then
echo "pkg/ already up to date — nothing to commit."
exit 0
fi
git config user.email "ci@gitea.local"
git config user.name "Gitea CI"
git add solitaire_server/web/pkg/
git commit -m "chore(web): regenerate wasm artifacts"
# master is unprotected; retry once if the tip moved under us.
git push origin HEAD:master || {
git fetch origin master
git rebase origin/master
git push origin HEAD:master
}
+4
View File
@@ -41,3 +41,7 @@ deploy/*-auth-secret.yaml
# Local token-saving helper scripts (peek/cargoclip/testfail/diffclip/etc.) —
# inspection-only Go tools, not committed. Tracked scripts/*.sh and *.md stay.
scripts/*.go
# WASM bundles — built by build_wasm.sh locally and by the Docker wasm-builder
# stage / web-e2e workflow in CI; never committed (issue #156)
solitaire_server/web/pkg/
+3 -5
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`
@@ -193,7 +192,7 @@ Owns:
### `solitaire_wasm`
**Dependencies:** `solitaire_core`, `serde`, `serde_json`, `chrono`, `wasm-bindgen`, `serde-wasm-bindgen`.
WebAssembly bindings for browser-side replay playback. Compiled to `cdylib` via `wasm-pack build`; the output lives in `solitaire_server/web/pkg/` and is served statically by the server.
WebAssembly bindings for browser-side replay playback. Compiled to `cdylib` via `wasm-pack build` (`build_wasm.sh`); the output lands in `solitaire_server/web/pkg/` — gitignored, built in CI (Docker `wasm-builder` stage, web-e2e workflow) — and is served statically by the server.
Intentionally **does not** depend on `solitaire_data` (which pulls in `dirs`, `keyring`, `reqwest`, and other non-WASM crates). Instead it defines a minimal `Replay` mirror with the same serde shape as `solitaire_data::Replay` — the JSON wire format is the compatibility contract.
@@ -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
@@ -745,7 +743,7 @@ All endpoints are under the base URL configured by the user (e.g., `https://soli
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | `/replays/:id` | None | Serves `web/index.html`; JS fetches `/api/replays/:id` and steps through via the `solitaire_wasm` WASM module |
| GET | `/web/*` | None | Static assets served via `ServeDir` from `solitaire_server/web/` (includes `web/pkg/` with wasm-bindgen output) |
| GET | `/web/*` | None | Static assets served via `ServeDir` from `solitaire_server/web/` (includes `web/pkg/` with wasm-bindgen output — gitignored, produced by `build_wasm.sh` / CI) |
### Account Management
+92
View File
@@ -6,6 +6,98 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [0.46.0] — 2026-07-13
### Added
- **Theme-store previews.** The store modal now shows each theme's
preview image next to its name — the server has advertised them since
the store launched; the client finally fetches them. Thumbnails load
in the background, pop in as they arrive, and are remembered for the
session. (#179)
- **Hint ghost preview.** Asking for a hint now also plays a translucent
copy of the suggested card gliding to its destination (twice, then it
fades) alongside the usual highlights — you see the move, not just the
pieces. Automatically disabled when reduce-motion is on. (#179)
### Changed
- **One toast style, one place.** Queued info banners and instant
celebration/warning/error toasts now share a single bottom-anchored
stack that clears the touch action bar; simultaneous toasts stack
upward instead of overlapping. (#178)
### Fixed
- **Touch onboarding copy.** The how-to-play slide no longer tells touch
players to left/right-click; it now describes drag, double-tap, and
the bottom-bar Hint button. (#178)
- **First launch shows one screen at a time.** On a fresh install the
Home screen no longer spawns underneath the welcome tour; the order is
now onboarding → Home → table. (#178)
## [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
+2
View File
@@ -7331,7 +7331,9 @@ dependencies = [
"card_game",
"klondike",
"proptest",
"rand 0.10.1",
"serde",
"serde_json",
"thiserror 2.0.18",
]
+5 -4
View File
@@ -13,9 +13,10 @@
# Run from the repo root:
# ./build_wasm.sh
#
# The generated pkg/ files are committed to git so self-hosters who don't
# touch the WASM crates can skip this step. Regenerate after any change to
# solitaire_wasm/, solitaire_web/, solitaire_engine/, or solitaire_core/.
# The generated pkg/ files are NOT committed to git (issue #156). CI builds
# them where needed: the Docker image's wasm-builder stage for deployment,
# and the web-e2e workflow for browser tests. Run this script locally before
# serving /web or /play from a source checkout.
set -euo pipefail
@@ -77,7 +78,7 @@ wasm-bindgen \
--out-name canvas \
--target web \
--no-typescript \
"$REPO_ROOT/target/wasm32-unknown-unknown/wasm-release/solitaire_web.wasm"
"${CARGO_TARGET_DIR:-$REPO_ROOT/target}/wasm32-unknown-unknown/wasm-release/solitaire_web.wasm"
# Optional size optimisation — Bevy bundles are large (~5-15 MB uncompressed).
# wasm-opt passes are skipped silently when the tool is not installed.
+211
View File
@@ -0,0 +1,211 @@
# Menu UX Redesign — July 2026
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)
| Surface | Today | Pain |
| --- | --- | --- |
| Settings | One scrolling modal, 5 section labels, ~37 control rows, single Done | Scroll-hunting for any toggle; endless on the folded (cover) screen; every new feature (theme store, import) stretches it further |
| Home | Modal launcher: stats strip, draw-mode row, difficulty rows, 6 equal mode cards, Cancel | No "Continue" primacy; deal options (draw/difficulty/winnable) crowd the mode choice; six cards weighted identically though Classic dominates play |
| HUD menu | Popover with 7 destinations (Help, Modes, Stats, Achievements, Profile, Settings, Leaderboard) | "Modes" duplicates Home's job; flat list, no grouping; each item opens another modal — modal-on-modal navigation |
| Global | Everything is a modal over the felt | No hierarchy; near-square unfolded Fold renders one narrow wasted column |
## Phase A — Settings tabs (highest pain, lowest risk)
- Replace the single scroll with **tabs**: `Audio · Gameplay · Appearance ·
Accessibility · Account`.
- *Appearance* = today's Cosmetic (card back, background, card theme,
theme store, import).
- *Accessibility* = extracted from Gameplay/Cosmetic: color-blind,
high-contrast, reduce-motion, touch input mode, tooltip delay.
- *Account* = Sync + Privacy merged.
- Narrow screens: tab chips in a row under the header. Wide (aspect > ~1.2 —
unfolded Fold, desktop): left rail, rows in two columns.
- Only the active tab's rows are spawned → each tab is ≤ 8 rows and fits the
cover screen without scrolling.
- **Implementation constraint:** pure `ui.rs` re-layout + one `SettingsTab`
resource. The `SettingsButton` enum, input handlers, and persistence are
untouched, so the 25 settings tests and the ambiguity gate stay green.
## Phase B — Home becomes a real home
- **Hierarchy, top to bottom:**
1. `Continue` card (only when a game is in progress) — mode, elapsed, score.
2. Hero `New Game` — one tap, reuses last mode + deal options.
3. Compact 2×3 mode grid (Classic, Daily, Zen, Challenge, Time Attack,
Seed) — smaller cards, glyph + name + one-line description on wide only.
4. Stats strip moves to the bottom (or right pane when unfolded).
- **Deal options** (draw 1/3, difficulty, winnable-only) move off the top
level into a disclosure on the Classic card / New Game hero — they are
Classic-mode concerns, not global ones.
- Cancel becomes `Back to table` and only renders when a game exists.
- Unfolded Fold / desktop: two panes — modes left, Continue + stats +
daily/weekly right.
## Phase C — HUD menu consolidation
- Drop `Modes` from the popover (Home owns mode selection; HUD's New-Game
path opens Home).
- Group the remaining six: **Play** (Home) · **You** (Profile, Stats,
Achievements) · **Community** (Leaderboard) · **System** (Settings, Help) —
section dividers in the existing popover, not a new widget.
- Dismissal audit: Esc / scrim-tap / Done must behave identically on every
modal (most already do via `ScrimDismissible`; sweep the stragglers).
## Phase D — Validation gates
- Optional Stitch mockups (Terminal design-system asset
`assets/0f1652274fea460585d0b331a9ddbb06`, project 593811793268308763) for
Home + Settings before implementing Phase B. Phase A is safe to build
straight in-engine.
- Per-phase gates: full test suite, clippy `-D warnings`, ambiguity gate at 0,
Android checklist (§15.3), on-device pass on the Fold 7 in BOTH postures.
## Sequencing & risk
| Phase | Size | Risk | Depends on |
| --- | --- | --- | --- |
| A — Settings tabs | ~1 session | Low (layout-only) | — |
| B — Home hierarchy | 12 sessions | Medium (touches launch flow) | D mockups if wanted |
| C — Menu grouping | small | Low | B (Modes removal) |
## Phase E — "You" hub (reuses the Phase A tab component)
Profile, Stats, Achievements, and the replay browser are four separate
modals today, reached through the HUD popover one at a time. Fold them
into ONE tabbed hub — `Profile · Stats · Achievements · Replays` — using
the same tab widget Phase A builds for Settings. Cuts the HUD popover to
four destinations and makes the tab component pay for itself twice.
Existing per-screen systems keep their markers/handlers; only the outer
shell changes (same trick as Phase A).
## Phase F — Mobile ergonomics (Fold-first)
- **Bottom action bar** on touch: Undo, Hint, Draw within thumb reach at
the bottom safe-area edge (via the existing `SafeAreaAnchoredBottom`),
score-only band stays on top. Desktop keeps the current top band.
Biggest one-handed-play win available; folded posture is tall and
top-heavy today.
- **Hold-to-repeat Undo** — press-and-hold steps back repeatedly
(respecting the existing undo scoring penalty), instead of tap-tap-tap.
- Both are additive; game stays fully playable with the top band alone
(UI-first rule §3.3 satisfied).
## Phase G — Post-win flow
Win summary today is a stats dump with a close. Give it an action
hierarchy: **Play again** (same mode/options, primary) · **Share replay**
(the upload already returns a share URL; surface it here with copy
feedback) · **Watch replay** · quiet stats below. Rematch loop drops from
4 taps to 1.
## Phase H — Feedback polish (small, bundling candidates)
- Unify queued vs. immediate toast styling (two subtly different styles
exist today) and anchor both to one position; consider a 5-item toast
history on the stats screen.
- Theme-store modal: render the preview PNGs the server already serves
(v1 is text-only rows).
- Hint: optional ghost-motion preview of the suggested move instead of a
static highlight (auto-disabled under reduce-motion).
## Phase I — Onboarding & discoverability
The first-run onboarding exists, but everything learned after it is
invisible: the radial menu (long-press/right-click), hint cycling,
tap-to-toggle HUD chrome, hotkeys. Add **contextual one-time tips** —
fired by the situation, not a tour (first stall → hint tip; first
long-press-able stack → radial tip; each shows once, stored in
Settings like `shown_achievement_onboarding` already is). Plus a
one-shot **"What's new"** card on first launch after a version bump —
ObtainX updates are silent today, so shipped features go unnoticed
(nobody will find the theme store on their own).
## Phase J — Keyboard & focus completeness (desktop + web)
`ui_focus` already gives deterministic Esc order and tab-walk
(`FocusRow`/`Focusable`), and `KeyboardDragState` exists — but full
keyboard-only play has never been audited end to end. Deliverables: every
modal reachable/dismissable without a mouse, a visible focus ring styled
to the Terminal system (current focus state is subtle), and a hotkey
cheat-sheet overlay (hold `?`) generated from the actual bindings instead
of the static Help text. Web build (Rhys) benefits most.
## Phase K — UI scale & touch-target accessibility
All type sizes flow through `TYPE_*` tokens and layout through
`compute_layout` — which makes a **UI scale setting** (90/100/115/130 %)
cheap to wire and genuinely useful on the Fold's dense cover screen.
Pair with: respect Android system font scale on first run (seed the
setting from it), and a one-time audit that every interactive element
meets the 44 px logical minimum in BOTH Fold postures (the pill buttons
and picker swatches are the suspects). Lands in the Accessibility tab
that Phase A creates.
## Phase L — Empty & loading states, standardized
Async/empty surfaces each improvise today: leaderboard with no entries,
stats with no games, replay browser with no replays, theme store
loading/error (just added), sync status text. Define ONE pattern —
glyph + one-line explanation + a single next-step action — as a
`spawn_empty_state` helper in `ui_modal`, and sweep all five surfaces
onto it. Small, mechanical, big perceived-quality win.
## Phase M — Sync transparency & data stewardship
The sync layer returns `ConflictReport`s ("data is never silently
discarded") — but no UI ever shows them; players can't tell what a merge
did. Add: a post-sync summary line ("merged, 2 conflicts kept newer
values" → tap for detail in the Account tab), a visible last-synced
timestamp (exists as `SyncStatus::LastSynced`, barely surfaced), and
local **data export/import** (zip of the JSON saves) for device
migrations without a server. Closes the trust loop the server work
opened.
## Roadmap tie-ins (already in flight elsewhere)
- **Spider mode** (feat/spider-core, PR #157): Home's mode grid gains a
7th card once engine work lands — the 2×3 grid in Phase B should be
designed as N-card flow from day one. Spider UI also needs positional
card→entity keys (documented in spider.rs module docs).
- **Show solution** (feat/solution-line, in flight): lands in the pause
menu; Phase C's grouping should leave room for it under a "Game"
cluster if the pause menu grows.
## Sequencing (updated)
| Phase | Size | Risk | Depends on |
| --- | --- | --- | --- |
| A — Settings tabs | ~1 session | Low | — (DECIDED: tabs) |
| E — "You" hub | ~1 session | Low | A's tab component |
| C — Menu grouping | small | Low | E (popover shrinks) |
| B — Home hierarchy | 12 sessions | Medium | D mockups if wanted |
| F — Bottom action bar | ~1 session | Medium (input paths) | — |
| G — Post-win flow | small | Low | — |
| H — Feedback polish | small each | Low | — |
| I — Onboarding & discoverability | ~1 session | Low | ships best after B/F land |
| J — Keyboard & focus completeness | ~1 session | Low | A/E (fewer modals to audit) |
| K — UI scale & touch targets | ~1 session | Medium (layout-wide) | A (Accessibility tab) |
| L — Empty-state standardization | small | Low | — |
| M — Sync transparency & export | ~1 session | LowMedium | A (Account tab) |
Suggested order: **A → E → C** (one arc: the tab component and the menu
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 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.
+5 -6
View File
@@ -2,8 +2,8 @@
# Live-watch the Gitea Actions deploy pipeline for Ferrous Solitaire.
#
# Polls recent workflow runs and prints a compact status block each cycle.
# Stops when the newest docker-build (the deploy) has completed and no
# web-wasm-rebuild is still pending — i.e. the full fix is live.
# Stops when the newest docker-build (the deploy) has completed — the wasm
# is built inside that image build, so no other workflow gates the deploy.
#
# Usage: ./scripts/watch_deploy.sh [interval_seconds]
# token is read from ~/.config/tea/config.yml (never printed).
@@ -49,16 +49,15 @@ for r in runs:
r.get("id"), str(r.get("head_sha"))[:7], wf[:18],
r.get("status"), r.get("conclusion")))
db = [r for r in runs if "docker-build" in str(r.get("path"))]
wr_pending = any(r.get("status") != "completed" for r in runs if "web-wasm-rebuild" in str(r.get("path")))
top = db[0] if db else None
live = bool(top and top.get("status")=="completed" and top.get("conclusion")=="success" and not wr_pending)
print("STATE=%s" % ("LIVE" if live else ("WAIT_WASM" if wr_pending else "DEPLOYING")))
live = bool(top and top.get("status")=="completed" and top.get("conclusion")=="success")
print("STATE=%s" % ("LIVE" if live else "DEPLOYING"))
PY
)"
echo "$out" | grep -v '^STATE='
if echo "$out" | grep -q '^STATE=LIVE'; then
echo ""
echo "DEPLOY LIVE — newest docker-build succeeded, no wasm rebuild pending."
echo "DEPLOY LIVE — newest docker-build succeeded."
echo " Test: https://klondike.aleshym.co/play?v=${RANDOM}"
break
fi
-5
View File
@@ -37,11 +37,6 @@ fn load_settings() -> Settings {
.unwrap_or_default()
}
/// Build the Bevy app without entering the event loop.
pub fn build_app(sync_provider: Box<dyn SyncProvider + Send + Sync>) -> App {
build_app_with_settings(load_settings(), sync_provider)
}
/// App entry point — configures runtime services, builds, and runs the app.
///
/// Called from both the desktop `bin` target's `main` shim and (on
+7 -1
View File
@@ -9,13 +9,19 @@ default = []
test-support = []
[dev-dependencies]
proptest = "1"
proptest = "1"
serde_json = { workspace = true }
[dependencies]
serde = { workspace = true }
thiserror = { workspace = true }
klondike = { workspace = true }
card_game = { workspace = true }
# Deliberately NOT the workspace rand (0.9): this pins the exact dep the
# upstream `klondike` crate uses, so `SeedableRng`/`SliceRandom` resolve
# against the same crate version as `klondike::Rng` and Spider deals go
# through the identical shuffle stack as Klondike deals.
rand = { version = "0.10.1", default-features = false, features = ["std_rng"] }
[lints]
workspace = true
+482 -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.
///
@@ -1109,6 +1250,66 @@ impl GameState {
})
}
/// Full winning line from the current position:
///
/// * `Ok(Some(line))` — winnable; applying every instruction in order via
/// [`GameState::apply_instruction`] reaches a won game.
/// * `Ok(None)` — provably unwinnable, or already won (no moves to show).
/// * `Err(SolveError)` — inconclusive; budget exhausted before a verdict.
///
/// Delegates to upstream [`card_game::Session::solve`] on a solve-budgeted
/// copy of the board like [`GameState::solve_first_move`], but returns the
/// whole path instead of the first move, compacted with
/// [`card_game::Solution::clean_solution`] (drops move ranges that loop
/// back to an already-seen state — the raw DFS trace is full of them).
///
/// Foundation→foundation shuffles ([`KlondikeInstruction::is_useless`])
/// are additionally stripped when the remaining sequence still replays to
/// a win; if stripping one would break a later move's preconditions the
/// unstripped cleaned line is returned instead, so the replay contract
/// above always holds. `clean_solution` is quadratic-ish in line length —
/// callers should run this off the UI thread with modest budgets.
pub fn winning_line(
&self,
moves_budget: u64,
states_budget: u64,
) -> Result<Option<Vec<KlondikeInstruction>>, SolveError> {
if self.is_won() {
return Ok(None);
}
let inner = KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation);
let config = SessionConfig {
inner: inner.clone(),
undo_penalty: 0,
solve_moves_budget: moves_budget,
solve_states_budget: states_budget,
};
let start = self.session.state().state().clone();
let session = Session::new(start.clone(), config);
let Some(solution) = session.solve()? else {
return Ok(None);
};
let cleaned: Vec<KlondikeInstruction> = solution
.clean_solution()
.iter()
.map(|snapshot| *snapshot.instruction())
.collect();
let filtered: Vec<KlondikeInstruction> = cleaned
.iter()
.copied()
.filter(|instruction| !instruction.is_useless())
.collect();
if line_replays_to_win(start, &inner, &filtered) {
Ok(Some(filtered))
} else {
Ok(Some(cleaned))
}
}
/// Solvability of a fresh Classic-mode deal from `seed` + `draw_mode`.
///
/// Fresh-deal solving models standard Klondike rules, so the non-standard
@@ -1126,6 +1327,25 @@ impl GameState {
}
}
/// `true` when applying `line` in order from `start` is legal at every step
/// and ends in a won game. Pure replay check backing
/// [`GameState::winning_line`]'s "the returned sequence always replays to a
/// win" contract.
fn line_replays_to_win(
mut state: Klondike,
config: &KlondikeConfig,
line: &[KlondikeInstruction],
) -> bool {
let mut stats = <Klondike as card_game::Game>::Stats::default();
for &instruction in line {
if !state.is_instruction_valid(config, instruction) {
return false;
}
state.process_instruction(&mut stats, config, instruction);
}
state.is_win()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1351,6 +1571,69 @@ mod tests {
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
}
// ── Full winning line (winning_line) ──────────────────────────────────
#[test]
fn winning_line_replays_to_a_won_game() {
// Seed 0xD1FF_0000_0000_0012 / DrawOne is proven Winnable at 5k
// budgets by `budget_is_passed_through_not_clamped`. Standard rules
// (take-from-foundation off) keep the search space identical to
// that baseline. The returned line must apply cleanly through the
// normal instruction pipeline and end in a win — the whole point
// of the API contract.
let mut game = GameState::new(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne);
game.take_from_foundation = false;
let line = game
.winning_line(5_000, 5_000)
.expect("this seed must not exhaust a 5k budget")
.expect("this seed must be winnable");
assert!(!line.is_empty(), "a winnable unfinished game needs moves");
for (i, instruction) in line.iter().enumerate() {
game.apply_instruction(*instruction)
.unwrap_or_else(|e| panic!("move {i} of the line must be legal: {e}"));
}
assert!(game.is_won(), "line must end in a won game");
}
#[test]
fn winning_line_contains_no_useless_moves() {
// The foundation→foundation strip must survive the replay check on
// this seed; a line shown to the player should never shuffle
// between foundations. Same proven-winnable seed and standard
// rules as `winning_line_replays_to_a_won_game`.
let mut game = GameState::new(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne);
game.take_from_foundation = false;
let line = game
.winning_line(5_000, 5_000)
.expect("budget")
.expect("winnable");
assert!(
!line.iter().any(KlondikeInstruction::is_useless),
"filtered line must not contain foundation→foundation moves"
);
}
#[test]
fn winning_line_is_inconclusive_when_budget_exhausted() {
let game = GameState::new(7, DrawStockConfig::DrawOne);
let outcome = game.winning_line(5_000, 0);
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
}
#[test]
fn winning_line_matches_first_move_verdict() {
// The two solver entry points must agree on winnability for the
// same position and budgets (both are deterministic DFS).
let game = GameState::new(42, DrawStockConfig::DrawOne);
let first = game.solve_first_move(5_000, 5_000);
let line = game.winning_line(5_000, 5_000);
assert_eq!(
matches!(first, Ok(Some(_))),
matches!(line, Ok(Some(_))),
"solve_first_move and winning_line disagree on winnability"
);
}
#[test]
fn budget_is_passed_through_not_clamped() {
// This seed is Inconclusive at 1k states but Winnable at 5k — proving the
@@ -1370,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);
}
}
+6 -4
View File
@@ -13,20 +13,22 @@ pub mod spider;
// re-exported — they are only used internally (in `klondike_adapter.rs` and
// when decoding instructions to piles in `instruction_to_piles`) and do not
// appear in any public method signature.
pub use card_game::{Card, Deck, Rank, Session, SolveError, Suit};
pub use card_game::{Card, Deck, Rank, SolveError, Suit};
pub use klondike::{
DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau,
};
// 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).
pub use spider::{
SpiderConfig, SpiderGame, SpiderGameState, SpiderInstruction, SpiderScoring, SpiderStats,
SpiderSuits,
RunLength, Spider, SpiderConfig, SpiderGameState, SpiderInstruction, SpiderIter, SpiderMove,
SpiderScoring, SpiderStats, SpiderSuits, SpiderTableau,
};
/// All four foundation slots, in slot order.
+397 -187
View File
@@ -22,11 +22,10 @@
//!
//! ## Determinism
//!
//! Deals are seeded with an inline SplitMix64 + FisherYates shuffle:
//! `solitaire_core` has no `rand` dependency (and adding one needs
//! explicit approval), so Spider's seed space is deliberately
//! self-contained rather than shared with Klondike's `StdRng` seeds.
//! The same seed + suit count always produces the same deal.
//! Deals are seeded exactly like upstream Klondike: [`Rng`] is the same
//! `rand::rngs::StdRng` alias `klondike` exports, seeded through
//! `SeedableRng::seed_from_u64` and applied with a `SliceRandom`
//! shuffle. The same seed + suit count always produces the same deal.
//!
//! ## Card identity caveat (engine integration, later phase)
//!
@@ -43,6 +42,10 @@ use serde::{Deserialize, Serialize};
use crate::error::MoveError;
/// The RNG Spider deals with — the same alias upstream `klondike`
/// exports, so both games share one shuffle stack.
pub type Rng = rand::rngs::StdRng;
/// Number of tableau piles.
pub const SPIDER_TABLEAUS: usize = 10;
/// Total cards in play (two decks).
@@ -146,24 +149,212 @@ impl SpiderStats {
}
// ---------------------------------------------------------------------------
// Instruction
// Piles, run lengths, and instructions
// ---------------------------------------------------------------------------
/// One of the ten Spider tableau piles, in layout order.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SpiderTableau {
/// Leftmost pile (opens with 6 cards).
Tableau1,
/// Second pile (opens with 6 cards).
Tableau2,
/// Third pile (opens with 6 cards).
Tableau3,
/// Fourth pile (opens with 6 cards).
Tableau4,
/// Fifth pile (opens with 5 cards).
Tableau5,
/// Sixth pile (opens with 5 cards).
Tableau6,
/// Seventh pile (opens with 5 cards).
Tableau7,
/// Eighth pile (opens with 5 cards).
Tableau8,
/// Ninth pile (opens with 5 cards).
Tableau9,
/// Rightmost pile (opens with 5 cards).
Tableau10,
}
impl SpiderTableau {
/// All ten piles, in layout order — the canonical iteration source,
/// following `Suit::SUITS` / `Rank::RANKS` upstream.
pub const ALL: [Self; SPIDER_TABLEAUS] = [
Self::Tableau1,
Self::Tableau2,
Self::Tableau3,
Self::Tableau4,
Self::Tableau5,
Self::Tableau6,
Self::Tableau7,
Self::Tableau8,
Self::Tableau9,
Self::Tableau10,
];
const ITER_BEGIN: Self = Self::Tableau1;
const fn next(self) -> Option<Self> {
use SpiderTableau::*;
Some(match self {
Tableau1 => Tableau2,
Tableau2 => Tableau3,
Tableau3 => Tableau4,
Tableau4 => Tableau5,
Tableau5 => Tableau6,
Tableau6 => Tableau7,
Tableau7 => Tableau8,
Tableau8 => Tableau9,
Tableau9 => Tableau10,
Tableau10 => return None,
})
}
/// Zero-based position in layout order.
const fn index(self) -> usize {
self as usize
}
}
/// How many cards a [`SpiderMove`] picks up — at most 13, since a
/// movable run is same-suit strictly-descending.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum RunLength {
/// A single card.
Run1 = 1,
/// The top 2 cards.
Run2 = 2,
/// The top 3 cards.
Run3 = 3,
/// The top 4 cards.
Run4 = 4,
/// The top 5 cards.
Run5 = 5,
/// The top 6 cards.
Run6 = 6,
/// The top 7 cards.
Run7 = 7,
/// The top 8 cards.
Run8 = 8,
/// The top 9 cards.
Run9 = 9,
/// The top 10 cards.
Run10 = 10,
/// The top 11 cards.
Run11 = 11,
/// The top 12 cards.
Run12 = 12,
/// A full K→A run.
Run13 = 13,
}
impl RunLength {
const ITER_BEGIN: Self = Self::Run1;
const fn next(self) -> Option<Self> {
use RunLength::*;
Some(match self {
Run1 => Run2,
Run2 => Run3,
Run3 => Run4,
Run4 => Run5,
Run5 => Run6,
Run6 => Run7,
Run7 => Run8,
Run8 => Run9,
Run9 => Run10,
Run10 => Run11,
Run11 => Run12,
Run12 => Run13,
Run13 => return None,
})
}
/// Number of cards in the run.
pub const fn len(self) -> usize {
self as usize
}
/// True only for [`Self::Run1`]; provided because clippy expects an
/// `is_empty` alongside `len`, and a run is never actually empty.
pub const fn is_empty(self) -> bool {
false
}
}
/// Move the top [`RunLength`] cards (a same-suit descending run) from
/// one pile to another.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SpiderMove {
/// Source pile.
pub from: SpiderTableau,
/// Number of cards picked up from the top of `from`.
pub run: RunLength,
/// Destination pile.
pub to: SpiderTableau,
}
impl SpiderMove {
const ITER_BEGIN: Self = Self {
from: SpiderTableau::ITER_BEGIN,
run: RunLength::ITER_BEGIN,
to: SpiderTableau::ITER_BEGIN,
};
const fn next(self) -> Option<Self> {
let Self { from, run, to } = self;
if let Some(to) = to.next() {
return Some(Self { from, run, to });
}
let to = SpiderTableau::ITER_BEGIN;
if let Some(run) = run.next() {
return Some(Self { from, run, to });
}
let run = RunLength::ITER_BEGIN;
if let Some(from) = from.next() {
return Some(Self { from, run, to });
}
None
}
}
/// One atomic Spider action (the `Game::Instruction` type).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SpiderInstruction {
/// Move a run between tableau piles.
Move(SpiderMove),
/// Deal one card from the stock onto every tableau pile.
Deal,
/// Move the top `count` face-up cards (a same-suit descending run)
/// from pile `from` to pile `to`. Pile indices are `0..10`.
Move {
/// Source pile index.
from: u8,
/// Destination pile index.
to: u8,
/// Number of cards in the moved run (≥ 1).
count: u8,
},
}
impl SpiderInstruction {
const ITER_BEGIN: Self = Self::Move(SpiderMove::ITER_BEGIN);
const fn next(self) -> Option<Self> {
Some(match self {
Self::Move(spider_move) => match spider_move.next() {
Some(spider_move) => Self::Move(spider_move),
None => Self::Deal,
},
Self::Deal => return None,
})
}
}
/// Exhaustive walk of the Spider instruction space, mirroring
/// `klondike::KlondikeIter`.
pub struct SpiderIter {
instruction: Option<SpiderInstruction>,
}
impl SpiderIter {
const fn new() -> Self {
Self {
instruction: Some(SpiderInstruction::ITER_BEGIN),
}
}
}
impl Iterator for SpiderIter {
type Item = SpiderInstruction;
fn next(&mut self) -> Option<Self::Item> {
let instruction = self.instruction;
self.instruction = instruction?.next();
instruction
}
}
// ---------------------------------------------------------------------------
@@ -174,45 +365,22 @@ pub enum SpiderInstruction {
/// of completed runs. Everything else (undo, score bookkeeping) lives
/// in the wrapping [`Session`].
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SpiderGame {
pub struct Spider {
tableaus: [Pile<MAX_FACE_DOWN, SPIDER_DECK_SIZE>; SPIDER_TABLEAUS],
stock: Stack<STOCK_SIZE>,
completed_runs: u8,
}
/// SplitMix64 step — small, well-known, and dependency-free. Spider
/// only needs a deterministic shuffle, not cryptographic quality.
const fn splitmix64(state: u64) -> (u64, u64) {
let state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
(state, z ^ (z >> 31))
}
/// In-place FisherYates driven by SplitMix64. The modulo bias is
/// astronomically small for n ≤ 104 and irrelevant for gameplay.
fn shuffle(cards: &mut [Card], seed: u64) {
let mut state = seed;
for i in (1..cards.len()).rev() {
let (next_state, r) = splitmix64(state);
state = next_state;
#[allow(clippy::cast_possible_truncation)]
let j = (r % (i as u64 + 1)) as usize;
cards.swap(i, j);
}
}
/// Builds the 104-card Spider deck for a suit-count difficulty.
///
/// Deck ids spread copies apart where possible (`Deck1..Deck4`), but
/// 1- and 2-suit games necessarily contain identical `Card` values —
/// see the module docs.
fn build_deck(suits: SpiderSuits) -> Vec<Card> {
fn build_deck(suits: SpiderSuits) -> Stack<SPIDER_DECK_SIZE> {
let suit_set = suits.suits();
let copies = SPIDER_DECK_SIZE / (suit_set.len() * RUN_LEN);
let decks = [Deck::Deck1, Deck::Deck2, Deck::Deck3, Deck::Deck4];
let mut cards = Vec::with_capacity(SPIDER_DECK_SIZE);
let mut cards = Stack::new();
for copy in 0..copies {
for &suit in suit_set {
for rank in Rank::RANKS {
@@ -223,15 +391,24 @@ fn build_deck(suits: SpiderSuits) -> Vec<Card> {
cards
}
impl SpiderGame {
impl Spider {
/// Deals a new seeded game at the given suit difficulty.
pub fn with_seed(seed: u64, suits: SpiderSuits) -> Self {
use rand::SeedableRng;
let mut rng = Rng::seed_from_u64(seed);
Self::with_rng(&mut rng, suits)
}
/// Deals a new game from a caller-supplied RNG.
pub fn with_rng(rng: &mut Rng, suits: SpiderSuits) -> Self {
// shuffle a new two-deck spread
let mut deck = build_deck(suits);
shuffle(&mut deck, seed);
use rand::seq::SliceRandom;
deck.shuffle(rng);
let mut cards = deck.into_iter();
let tableaus = core::array::from_fn(|index| {
// Piles 03 open with 5 face-down cards, piles 49 with 4;
// Piles 14 open with 5 face-down cards, piles 510 with 4;
// one face-up card lands on each afterwards.
let down_count = if index < 4 { 5 } else { 4 };
let stack: Stack<MAX_FACE_DOWN> = cards.by_ref().take(down_count).collect();
@@ -250,22 +427,24 @@ impl SpiderGame {
}
}
/// Face-up cards of pile `index` (bottom → top). Empty slice for
/// out-of-range indices.
pub fn tableau_face_up(&self, index: usize) -> &[Card] {
self.tableaus.get(index).map_or(&[], |pile| pile.face_up())
/// Face-up cards of a pile (bottom → top).
pub fn tableau_face_up_cards(&self, tableau: SpiderTableau) -> &[Card] {
self.tableaus[tableau.index()].face_up()
}
/// Face-down cards of pile `index` (bottom → top).
pub fn tableau_face_down(&self, index: usize) -> &[Card] {
self.tableaus
.get(index)
.map_or(&[], |pile| pile.face_down())
/// Face-down cards of a pile (bottom → top).
pub fn tableau_face_down_cards(&self, tableau: SpiderTableau) -> &[Card] {
self.tableaus[tableau.index()].face_down()
}
/// Cards remaining in the stock.
pub fn stock_len(&self) -> usize {
self.stock.len()
/// Topmost face-up card of a pile.
pub fn tableau_top_card(&self, tableau: SpiderTableau) -> Option<&Card> {
self.tableaus[tableau.index()].face_up().last()
}
/// The stock (deals come off the top).
pub const fn stock(&self) -> &Stack<STOCK_SIZE> {
&self.stock
}
/// Completed K→A runs removed from play so far.
@@ -273,19 +452,16 @@ impl SpiderGame {
self.completed_runs
}
/// Length of the longest movable run on top of pile `index`: the
/// maximal same-suit, strictly-descending face-up suffix.
fn movable_run_len(&self, index: usize) -> usize {
let Some(pile) = self.tableaus.get(index) else {
return 0;
};
let up = pile.face_up();
/// Length of the longest movable run on top of a pile: the maximal
/// same-suit, strictly-descending face-up suffix.
fn movable_run_len(&self, tableau: SpiderTableau) -> usize {
let up = self.tableau_face_up_cards(tableau);
let mut len = usize::from(!up.is_empty());
while len < up.len() {
let above = &up[up.len() - len];
let below = &up[up.len() - len - 1];
let descends =
below.suit() == above.suit() && below.rank() as u8 == above.rank() as u8 + 1;
below.suit() == above.suit() && above.rank().checked_add(1) == Some(below.rank());
if !descends {
break;
}
@@ -294,27 +470,27 @@ impl SpiderGame {
len
}
/// Whether `Move { from, to, count }` is legal in this position.
fn is_move_valid(&self, from: u8, to: u8, count: u8) -> bool {
let (from, to, count) = (from as usize, to as usize, count as usize);
if from == to || from >= SPIDER_TABLEAUS || to >= SPIDER_TABLEAUS || count == 0 {
/// Whether a run move is legal in this position.
fn is_move_valid(&self, spider_move: SpiderMove) -> bool {
let SpiderMove { from, run, to } = spider_move;
if from == to {
return false;
}
if count > self.movable_run_len(from) {
if run.len() > self.movable_run_len(from) {
return false;
}
let src_up = self.tableaus[from].face_up();
// Bottom card of the moved run; `count <= movable_run_len <=
// src_up.len()` guarantees the index is in range.
let Some(moved_bottom) = src_up.get(src_up.len() - count) else {
let src_up = self.tableau_face_up_cards(from);
// Bottom card of the moved run; `run.len() <= movable_run_len
// <= src_up.len()` guarantees the index is in range.
let Some(moved_bottom) = src_up.get(src_up.len() - run.len()) else {
return false;
};
match self.tableaus[to].face_up().last() {
match self.tableau_top_card(to) {
// Build down regardless of suit.
Some(dest_top) => dest_top.rank() as u8 == moved_bottom.rank() as u8 + 1,
Some(dest_top) => moved_bottom.rank().checked_add(1) == Some(dest_top.rank()),
// Empty pile accepts anything (face-down remnant can't
// exist without a face-up card — Pile flips eagerly).
None => self.tableaus[to].is_empty(),
None => self.tableaus[to.index()].is_empty(),
}
}
@@ -323,22 +499,24 @@ impl SpiderGame {
!self.stock.is_empty() && self.tableaus.iter().all(|pile| !pile.is_empty())
}
/// Removes a completed K→A same-suit run from the top of pile
/// `index`, if one is present. Returns `true` when a run was
/// removed (and the next face-down card, if any, was flipped).
fn sweep_completed_run(&mut self, index: usize) -> bool {
let Some(pile) = self.tableaus.get_mut(index) else {
return false;
};
/// Removes a completed K→A same-suit run from the top of a pile,
/// if one is present. Returns `true` when a run was removed (and
/// the next face-down card, if any, was flipped).
fn sweep_completed_run(&mut self, tableau: SpiderTableau) -> bool {
let pile = &mut self.tableaus[tableau.index()];
let up = pile.face_up();
if up.len() < RUN_LEN {
return false;
}
let run = &up[up.len() - RUN_LEN..];
let suit = run[0].suit();
let is_complete = run.iter().enumerate().all(|(offset, card)| {
card.suit() == suit && card.rank() as u8 == (RUN_LEN - offset) as u8
});
// The run sits bottom-first (K → A); reversed, it must read
// exactly Ace → King in one suit.
let is_complete = run
.iter()
.rev()
.zip(Rank::RANKS)
.all(|(card, rank)| card.suit() == suit && card.rank() == rank);
if !is_complete {
return false;
}
@@ -348,7 +526,7 @@ impl SpiderGame {
}
}
impl Game for SpiderGame {
impl Game for Spider {
type Score = i32;
type Stats = SpiderStats;
type Config = SpiderConfig;
@@ -365,34 +543,16 @@ impl Game for SpiderGame {
&self,
config: &Self::Config,
) -> impl Iterator<Item = Self::Instruction> + use<> {
let mut out = Vec::new();
if self.is_deal_valid() {
out.push(SpiderInstruction::Deal);
}
for from in 0..SPIDER_TABLEAUS {
let max_run = self.movable_run_len(from);
for count in 1..=max_run {
for to in 0..SPIDER_TABLEAUS {
#[allow(clippy::cast_possible_truncation)]
let instruction = SpiderInstruction::Move {
from: from as u8,
to: to as u8,
count: count as u8,
};
if self.is_move_valid(from as u8, to as u8, count as u8) {
out.push(instruction);
}
}
}
}
let _ = config;
out.into_iter()
let state = self.clone();
let config = config.clone();
SpiderIter::new()
.filter(move |&instruction| state.is_instruction_valid(&config, instruction))
}
fn is_instruction_valid(&self, _config: &Self::Config, instruction: Self::Instruction) -> bool {
match instruction {
SpiderInstruction::Deal => self.is_deal_valid(),
SpiderInstruction::Move { from, to, count } => self.is_move_valid(from, to, count),
SpiderInstruction::Move(spider_move) => self.is_move_valid(spider_move),
}
}
@@ -413,24 +573,24 @@ impl Game for SpiderGame {
match instruction {
SpiderInstruction::Deal => {
stats.deals += 1;
for index in 0..SPIDER_TABLEAUS {
for tableau in SpiderTableau::ALL {
match self.stock.pop() {
Some(card) => self.tableaus[index].push(card),
Some(card) => self.tableaus[tableau.index()].push(card),
None => break,
}
}
for index in 0..SPIDER_TABLEAUS {
if self.sweep_completed_run(index) {
for tableau in SpiderTableau::ALL {
if self.sweep_completed_run(tableau) {
self.completed_runs += 1;
stats.runs_completed += 1;
}
}
}
SpiderInstruction::Move { from, to, count } => {
let (from, to, count) = (from as usize, to as usize, count as usize);
let src_len = self.tableaus[from].face_up().len();
let (cards, _flipped) = self.tableaus[from].take_range_flip_up(src_len - count..);
self.tableaus[to].extend(cards);
SpiderInstruction::Move(SpiderMove { from, run, to }) => {
let src_len = self.tableaus[from.index()].face_up().len();
let (cards, _flipped) =
self.tableaus[from.index()].take_range_flip_up(src_len - run.len()..);
self.tableaus[to.index()].extend(cards);
if self.sweep_completed_run(to) {
self.completed_runs += 1;
stats.runs_completed += 1;
@@ -455,7 +615,7 @@ impl Game for SpiderGame {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpiderGameState {
seed: u64,
session: Session<SpiderGame>,
session: Session<Spider>,
}
impl SpiderGameState {
@@ -478,7 +638,7 @@ impl SpiderGameState {
};
Self {
seed,
session: Session::new(SpiderGame::with_seed(seed, suits), config),
session: Session::new(Spider::with_seed(seed, suits), config),
}
}
@@ -493,10 +653,18 @@ impl SpiderGameState {
}
/// Current position (read-only).
pub fn game(&self) -> &SpiderGame {
pub fn game(&self) -> &Spider {
self.session.state().state()
}
/// The underlying upstream session, for solver access and replay —
/// the same escape hatch [`crate::game_state::GameState::session`]
/// provides (`session().solve()` honours the budgets configured in
/// [`SessionConfig`]).
pub const fn session(&self) -> &Session<Spider> {
&self.session
}
/// In-play score, clamped at 0 like the Klondike wrapper.
pub fn score(&self) -> i32 {
self.session
@@ -563,7 +731,7 @@ impl SpiderGameState {
// ---------------------------------------------------------------------------
#[cfg(any(test, feature = "test-support"))]
impl SpiderGame {
impl Spider {
/// Builds an arbitrary position for tests: per-pile
/// `(face_down, face_up)` card lists plus stock and completed-run
/// count. No card-count invariants are enforced — stacked
@@ -592,7 +760,7 @@ impl SpiderGame {
#[cfg(any(test, feature = "test-support"))]
impl SpiderGameState {
/// Wraps an arbitrary position in a fresh session (empty history).
pub fn from_test_game(game: SpiderGame, suits: SpiderSuits) -> Self {
pub fn from_test_game(game: Spider, suits: SpiderSuits) -> Self {
let config = SessionConfig {
inner: SpiderConfig {
suits,
@@ -614,6 +782,8 @@ impl SpiderGameState {
#[cfg(test)]
mod tests {
use super::RunLength::*;
use super::SpiderTableau::*;
use super::*;
/// `Card::new` shorthand for stacked positions.
@@ -621,6 +791,11 @@ mod tests {
Card::new(Deck::Deck1, suit, rank)
}
/// `SpiderMove` shorthand for rule tests.
const fn mv(from: SpiderTableau, run: RunLength, to: SpiderTableau) -> SpiderMove {
SpiderMove { from, run, to }
}
/// K→A same-suit run, bottom (King) first — the face-up order a
/// completed run occupies on a pile.
fn full_run(suit: Suit) -> Vec<Card> {
@@ -643,21 +818,25 @@ mod tests {
#[test]
fn opening_deal_shape_is_4x6_6x5_with_50_in_stock() {
let game = SpiderGame::with_seed(42, SpiderSuits::Four);
for index in 0..SPIDER_TABLEAUS {
let game = Spider::with_seed(42, SpiderSuits::Four);
for (index, tableau) in SpiderTableau::ALL.into_iter().enumerate() {
let expected_down = if index < 4 { 5 } else { 4 };
assert_eq!(game.tableau_face_down(index).len(), expected_down);
assert_eq!(game.tableau_face_up(index).len(), 1, "one card face-up");
assert_eq!(game.tableau_face_down_cards(tableau).len(), expected_down);
assert_eq!(
game.tableau_face_up_cards(tableau).len(),
1,
"one card face-up"
);
}
assert_eq!(game.stock_len(), STOCK_SIZE);
assert_eq!(game.stock().len(), STOCK_SIZE);
assert_eq!(game.completed_runs(), 0);
}
#[test]
fn same_seed_same_deal_different_seed_different_deal() {
let a = SpiderGame::with_seed(7, SpiderSuits::Four);
let b = SpiderGame::with_seed(7, SpiderSuits::Four);
let c = SpiderGame::with_seed(8, SpiderSuits::Four);
let a = Spider::with_seed(7, SpiderSuits::Four);
let b = Spider::with_seed(7, SpiderSuits::Four);
let c = Spider::with_seed(8, SpiderSuits::Four);
assert_eq!(a, b);
assert_ne!(a, c);
}
@@ -691,15 +870,21 @@ mod tests {
let mut layout = empty_layout();
layout[0].1 = vec![card(Suit::Hearts, Rank::Five)];
layout[1].1 = vec![card(Suit::Spades, Rank::Six)];
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
assert!(game.is_move_valid(0, 1, 1), "5♥ onto 6♠ is legal");
assert!(!game.is_move_valid(1, 0, 1), "6♠ onto 5♥ is not");
let game = Spider::from_test_layout(layout, Vec::new(), 0);
assert!(
game.is_move_valid(mv(Tableau1, Run1, Tableau2)),
"5♥ onto 6♠ is legal"
);
assert!(
!game.is_move_valid(mv(Tableau2, Run1, Tableau1)),
"6♠ onto 5♥ is not"
);
}
#[test]
fn only_same_suit_runs_are_movable_as_a_group() {
let mut layout = empty_layout();
// Pile 0: 7♠ 6♠ (movable run of 2). Pile 1: 7♥ 6♠ (mixed).
// Pile 1: 7♠ 6♠ (movable run of 2). Pile 2: 7♥ 6♠ (mixed).
layout[0].1 = vec![
card(Suit::Spades, Rank::Seven),
card(Suit::Spades, Rank::Six),
@@ -711,10 +896,19 @@ mod tests {
// Destinations: 8♣ (for the pair), 7♦ (for a lone six).
layout[2].1 = vec![card(Suit::Clubs, Rank::Eight)];
layout[3].1 = vec![card(Suit::Diamonds, Rank::Seven)];
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
assert!(game.is_move_valid(0, 2, 2), "same-suit pair moves");
assert!(!game.is_move_valid(1, 2, 2), "mixed-suit pair does not");
assert!(game.is_move_valid(1, 3, 1), "its top card alone does");
let game = Spider::from_test_layout(layout, Vec::new(), 0);
assert!(
game.is_move_valid(mv(Tableau1, Run2, Tableau3)),
"same-suit pair moves"
);
assert!(
!game.is_move_valid(mv(Tableau2, Run2, Tableau3)),
"mixed-suit pair does not"
);
assert!(
game.is_move_valid(mv(Tableau2, Run1, Tableau4)),
"its top card alone does"
);
}
#[test]
@@ -724,27 +918,40 @@ mod tests {
card(Suit::Spades, Rank::Nine),
card(Suit::Spades, Rank::Eight),
];
// Pile 1 deliberately left empty.
// Pile 2 deliberately left empty.
layout[2].1 = vec![card(Suit::Hearts, Rank::Nine)];
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
assert!(game.is_move_valid(0, 1, 2), "run onto empty pile");
assert!(game.is_move_valid(2, 1, 1), "single onto empty pile");
let game = Spider::from_test_layout(layout, Vec::new(), 0);
assert!(
!game.is_move_valid(0, 2, 2),
game.is_move_valid(mv(Tableau1, Run2, Tableau2)),
"run onto empty pile"
);
assert!(
game.is_move_valid(mv(Tableau3, Run1, Tableau2)),
"single onto empty pile"
);
assert!(
!game.is_move_valid(mv(Tableau1, Run2, Tableau3)),
"9♠8♠ cannot land on 9♥ (needs a 10)"
);
}
#[test]
fn invalid_moves_rejected_out_of_range_zero_count_self_move() {
fn invalid_moves_rejected_self_move_and_overlong_run() {
// Out-of-range piles and zero-card runs are unrepresentable in
// `SpiderMove` — the enums only span legal values — so the only
// structurally invalid shapes left are self-moves and runs
// longer than the movable suffix.
let mut layout = empty_layout();
layout[0].1 = vec![card(Suit::Spades, Rank::Five)];
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
assert!(!game.is_move_valid(0, 0, 1), "self-move");
assert!(!game.is_move_valid(0, 1, 0), "zero count");
assert!(!game.is_move_valid(0, 10, 1), "destination out of range");
assert!(!game.is_move_valid(10, 0, 1), "source out of range");
assert!(!game.is_move_valid(0, 1, 2), "count exceeds run");
let game = Spider::from_test_layout(layout, Vec::new(), 0);
assert!(
!game.is_move_valid(mv(Tableau1, Run1, Tableau1)),
"self-move"
);
assert!(
!game.is_move_valid(mv(Tableau1, Run2, Tableau2)),
"count exceeds run"
);
}
// -- dealing from stock -------------------------------------------------
@@ -752,31 +959,35 @@ mod tests {
#[test]
fn deal_requires_stock_and_no_empty_pile() {
let stock = vec![card(Suit::Spades, Rank::Ace); 10];
let game = SpiderGame::from_test_layout(junk_layout(), stock.clone(), 0);
let game = Spider::from_test_layout(junk_layout(), stock.clone(), 0);
assert!(game.is_deal_valid());
let mut with_gap = junk_layout();
with_gap[3].1.clear();
let game = SpiderGame::from_test_layout(with_gap, stock, 0);
let game = Spider::from_test_layout(with_gap, stock, 0);
assert!(!game.is_deal_valid(), "empty pile blocks the deal");
let game = SpiderGame::from_test_layout(junk_layout(), Vec::new(), 0);
let game = Spider::from_test_layout(junk_layout(), Vec::new(), 0);
assert!(!game.is_deal_valid(), "empty stock blocks the deal");
}
#[test]
fn deal_puts_one_card_on_every_pile() {
let mut state = SpiderGameState::new_with_suits(3, SpiderSuits::Two);
let before: Vec<usize> = (0..SPIDER_TABLEAUS)
.map(|i| state.game().tableau_face_up(i).len())
let before: Vec<usize> = SpiderTableau::ALL
.into_iter()
.map(|tableau| state.game().tableau_face_up_cards(tableau).len())
.collect();
state
.apply_instruction(SpiderInstruction::Deal)
.expect("deal is legal on a fresh game");
for (index, previous) in before.iter().enumerate() {
assert_eq!(state.game().tableau_face_up(index).len(), previous + 1);
for (tableau, previous) in SpiderTableau::ALL.into_iter().zip(before) {
assert_eq!(
state.game().tableau_face_up_cards(tableau).len(),
previous + 1
);
}
assert_eq!(state.game().stock_len(), STOCK_SIZE - SPIDER_TABLEAUS);
assert_eq!(state.game().stock().len(), STOCK_SIZE - SPIDER_TABLEAUS);
}
#[test]
@@ -787,7 +998,7 @@ mod tests {
.apply_instruction(SpiderInstruction::Deal)
.expect("five deals must all be legal on untouched piles");
}
assert_eq!(state.game().stock_len(), 0);
assert_eq!(state.game().stock().len(), 0);
assert!(matches!(
state.apply_instruction(SpiderInstruction::Deal),
Err(MoveError::RuleViolation(_))
@@ -799,30 +1010,26 @@ mod tests {
#[test]
fn completing_a_run_removes_it_and_flips_the_card_beneath() {
let mut layout = empty_layout();
// Pile 0: one face-down card under K..2 of spades; the ace
// arrives from pile 1.
// Pile 1: one face-down card under K..2 of spades; the ace
// arrives from pile 2.
let mut run = full_run(Suit::Spades);
let ace = run.pop().unwrap_or(card(Suit::Spades, Rank::Ace));
layout[0] = (vec![card(Suit::Hearts, Rank::Nine)], run);
layout[1].1 = vec![ace];
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
let game = Spider::from_test_layout(layout, Vec::new(), 0);
let mut state = SpiderGameState::from_test_game(game, SpiderSuits::One);
state
.apply_instruction(SpiderInstruction::Move {
from: 1,
to: 0,
count: 1,
})
.apply_instruction(SpiderInstruction::Move(mv(Tableau2, Run1, Tableau1)))
.expect("ace onto two completes the run");
assert_eq!(state.game().completed_runs(), 1);
assert_eq!(
state.game().tableau_face_up(0),
state.game().tableau_face_up_cards(Tableau1),
&[card(Suit::Hearts, Rank::Nine)],
"run removed and the buried card flipped face-up"
);
assert!(state.game().tableau_face_up(1).is_empty());
assert!(state.game().tableau_face_up_cards(Tableau2).is_empty());
}
#[test]
@@ -832,16 +1039,12 @@ mod tests {
let ace = run.pop().unwrap_or(card(Suit::Spades, Rank::Ace));
layout[0].1 = run;
layout[1].1 = vec![ace];
let game = SpiderGame::from_test_layout(layout, Vec::new(), TOTAL_RUNS - 1);
let game = Spider::from_test_layout(layout, Vec::new(), TOTAL_RUNS - 1);
let mut state = SpiderGameState::from_test_game(game, SpiderSuits::One);
assert!(!state.is_won());
state
.apply_instruction(SpiderInstruction::Move {
from: 1,
to: 0,
count: 1,
})
.apply_instruction(SpiderInstruction::Move(mv(Tableau2, Run1, Tableau1)))
.expect("winning move is legal");
assert!(state.is_won());
assert!(matches!(
@@ -882,11 +1085,7 @@ mod tests {
#[test]
fn rule_violation_surfaces_move_error() {
let mut state = SpiderGameState::new_with_suits(9, SpiderSuits::One);
let result = state.apply_instruction(SpiderInstruction::Move {
from: 0,
to: 0,
count: 1,
});
let result = state.apply_instruction(SpiderInstruction::Move(mv(Tableau1, Run1, Tableau1)));
assert!(matches!(result, Err(MoveError::RuleViolation(_))));
}
@@ -905,6 +1104,13 @@ mod tests {
assert!(state.game().is_instruction_valid(&config, instruction));
}
}
#[test]
fn instruction_iter_walks_the_full_space_once() {
// 10 sources × 13 run lengths × 10 destinations, plus Deal.
let count = SpiderIter::new().count();
assert_eq!(count, SPIDER_TABLEAUS * RUN_LEN * SPIDER_TABLEAUS + 1);
}
}
#[cfg(test)]
@@ -915,11 +1121,15 @@ mod proptests {
/// Total cards across tableaus + stock + removed runs must always
/// equal 104, and every generated instruction must validate — for
/// any seed, difficulty, and random walk through legal moves.
fn card_conservation(game: &SpiderGame) -> usize {
let on_piles: usize = (0..SPIDER_TABLEAUS)
.map(|i| game.tableau_face_up(i).len() + game.tableau_face_down(i).len())
fn card_conservation(game: &Spider) -> usize {
let on_piles: usize = SpiderTableau::ALL
.into_iter()
.map(|tableau| {
game.tableau_face_up_cards(tableau).len()
+ game.tableau_face_down_cards(tableau).len()
})
.sum();
on_piles + game.stock_len() + usize::from(game.completed_runs()) * RUN_LEN
on_piles + game.stock().len() + usize::from(game.completed_runs()) * RUN_LEN
}
proptest! {
+7 -12
View File
@@ -105,10 +105,9 @@ pub use stats::{StatsExt, StatsSnapshot};
pub mod storage;
pub use storage::{
TimeAttackSession, cleanup_orphaned_tmp_files, delete_game_state_at,
delete_time_attack_session_at, game_state_file_path, load_game_state_from, load_stats,
load_stats_from, load_time_attack_session_from, load_time_attack_session_from_at,
save_game_state_to, save_stats, save_stats_to, save_time_attack_session_to, stats_file_path,
time_attack_session_path, time_attack_session_with_now,
delete_time_attack_session_at, game_state_file_path, load_game_state_from, load_stats_from,
load_time_attack_session_from, save_game_state_to, save_stats_to, save_time_attack_session_to,
stats_file_path, time_attack_session_path,
};
pub mod achievements;
@@ -136,11 +135,9 @@ pub use difficulty_seeds::{DifficultySeeds, seeds_for};
pub mod settings;
pub use settings::{
AnimSpeed, REPLAY_MOVE_INTERVAL_MAX_SECS, REPLAY_MOVE_INTERVAL_MIN_SECS,
REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend,
TIME_BONUS_MULTIPLIER_MAX, TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP,
TOOLTIP_DELAY_MAX_SECS, TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS, Theme, WindowGeometry,
load_settings_from, save_settings_to, settings_file_path,
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend,
TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_STEP_SECS, Theme, WindowGeometry, load_settings_from,
save_settings_to, settings_file_path,
};
#[cfg(target_os = "android")]
@@ -152,9 +149,7 @@ mod android_keystore;
#[cfg(not(target_arch = "wasm32"))]
pub mod auth_tokens;
#[cfg(not(target_arch = "wasm32"))]
pub use auth_tokens::{
TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens,
};
pub use auth_tokens::{TokenError, delete_tokens, store_tokens};
pub mod sync_client;
pub use sync_client::LocalOnlyProvider;
+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);
+32 -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,30 @@ 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,
/// The release version whose "What's new" card the player has already
/// seen (e.g. `"0.46.0"`). Empty on installs that predate the card,
/// which correctly reads as "there is news to show" after an upgrade;
/// fresh installs stamp it silently when onboarding completes. Older
/// `settings.json` files deserialize cleanly to `""` via
/// `#[serde(default)]`.
#[serde(default)]
pub last_seen_whats_new: String,
/// `true` once the one-shot "Stuck? Try a hint" contextual tip has
/// fired (or been suppressed as unnecessary). Phase I teach: fired
/// by the situation, shown once, like `shown_achievement_onboarding`.
#[serde(default)]
pub shown_stall_hint_tip: bool,
/// `true` once the one-shot radial-menu contextual tip has fired —
/// or the player has already opened the radial menu on their own,
/// which marks the tip as unnecessary without showing it.
#[serde(default)]
pub shown_radial_menu_tip: bool,
/// 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 +442,10 @@ 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,
last_seen_whats_new: String::new(),
shown_stall_hint_tip: false,
shown_radial_menu_tip: false,
leaderboard_display_name: None,
leaderboard_opted_in: false,
take_from_foundation: true,
+10 -33
View File
@@ -46,22 +46,6 @@ pub fn save_stats_to(path: &Path, stats: &StatsSnapshot) -> io::Result<()> {
Ok(())
}
/// Load stats from the platform default path. Returns default if the path
/// is unavailable or the file is missing/corrupt.
pub fn load_stats() -> StatsSnapshot {
stats_file_path()
.map(|p| load_stats_from(&p))
.unwrap_or_default()
}
/// Save stats to the platform default path. Returns an error if the platform
/// data dir is unavailable or the write fails.
pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> {
let path = stats_file_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "platform data dir unavailable"))?;
save_stats_to(&path, stats)
}
// ---------------------------------------------------------------------------
// In-progress game state
// ---------------------------------------------------------------------------
@@ -245,18 +229,6 @@ pub fn delete_time_attack_session_at(path: &Path) -> io::Result<()> {
}
}
/// Convenience helper for callers that want to stamp a session with the
/// current wall-clock time. Equivalent to constructing the struct
/// manually and setting `saved_at_unix_secs` to `SystemTime::now()`.
pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttackSession {
let now = Utc::now().timestamp().max(0) as u64;
TimeAttackSession {
remaining_secs,
wins,
saved_at_unix_secs: now,
}
}
/// Inner helper: delete `*.tmp` entries inside `dir`.
///
/// Per-file errors (already deleted, permission denied) are silently ignored.
@@ -505,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;
@@ -546,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 =
@@ -558,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!(
+40
View File
@@ -19,6 +19,10 @@ use thiserror::Error;
/// so downloading it is pure waste.
pub const MAX_THEME_DOWNLOAD_BYTES: u64 = 20 * 1024 * 1024;
/// Hard cap on a preview PNG download — previews are small decorative
/// thumbnails; anything past this is a misconfigured server.
pub const MAX_PREVIEW_BYTES: u64 = 512 * 1024;
/// Errors surfaced by [`ThemeStoreClient`].
#[derive(Debug, Error)]
pub enum ThemeStoreError {
@@ -110,6 +114,42 @@ impl ThemeStoreClient {
verify_archive(&bytes, entry)?;
Ok(bytes.to_vec())
}
/// Fetch the preview PNG the catalog advertises for `entry`.
///
/// Returns `Http(404)` when the entry carries no `preview_url` —
/// the same shape the server answers with when the file is absent,
/// so callers only handle one "no preview" case. Previews are
/// decorative; unlike archives they carry no checksum, only the
/// [`MAX_PREVIEW_BYTES`] size cap.
pub async fn fetch_preview(
&self,
entry: &ThemeCatalogEntry,
) -> Result<Vec<u8>, ThemeStoreError> {
let Some(path) = entry.preview_url.as_deref() else {
return Err(ThemeStoreError::Http(404));
};
let resp = self
.client
.get(format!("{}{}", self.base_url, path))
.send()
.await
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
if !resp.status().is_success() {
return Err(ThemeStoreError::Http(resp.status().as_u16()));
}
let bytes = resp
.bytes()
.await
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
if bytes.len() as u64 > MAX_PREVIEW_BYTES {
return Err(ThemeStoreError::Oversized {
expected: MAX_PREVIEW_BYTES,
got: bytes.len() as u64,
});
}
Ok(bytes.to_vec())
}
}
/// Checks downloaded `bytes` against the catalog `entry`'s declared
+38 -101
View File
@@ -30,13 +30,9 @@ use crate::replay_playback::ReplayPlaybackState;
use crate::resources::GameStateResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::stats_plugin::{StatsResource, StatsUpdate};
use crate::ui_modal::{
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, Z_MODAL_PANEL,
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1,
};
use crate::ui_tooltip::Tooltip;
@@ -137,8 +133,8 @@ impl Plugin for AchievementPlugin {
.after(GameMutation)
.after(StatsUpdate),
)
.add_systems(Update, toggle_achievements_screen)
.add_systems(Update, handle_achievements_close_button)
// Open/close/tab handling moved to `you_hub_plugin`
// (Phase E) — this plugin now owns body content + scroll.
.add_systems(Update, scroll_achievements_panel)
.add_systems(
Update,
@@ -385,47 +381,6 @@ pub fn display_name_for(id: &str) -> String {
achievement_by_id(id).map_or_else(|| id.to_string(), |d| d.name.to_string())
}
/// Marker on the "Done" button inside the Achievements modal.
#[derive(Component, Debug)]
pub struct AchievementsCloseButton;
/// Toggle the achievements overlay — `A` keyboard accelerator or
/// `ToggleAchievementsRequestEvent` from the HUD Menu popover.
fn toggle_achievements_screen(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleAchievementsRequestEvent>,
achievements: Res<AchievementsResource>,
font_res: Option<Res<FontResource>>,
screens: Query<Entity, With<AchievementsScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<AchievementsScreen>)>,
) {
let button_clicked = requests.read().count() > 0;
if !keys.just_pressed(KeyCode::KeyA) && !button_clicked {
return;
}
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else if other_modal_scrims.is_empty() {
spawn_achievements_screen(&mut commands, &achievements.0, font_res.as_deref());
}
}
/// Click handler for the modal's "Done" button — despawns the overlay
/// the same way the `A` accelerator does.
fn handle_achievements_close_button(
mut commands: Commands,
close_buttons: Query<&Interaction, (With<AchievementsCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<AchievementsScreen>>,
) {
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
/// Routes mouse-wheel events into the Achievements modal's scrollable body
/// while the panel is open.
///
@@ -458,14 +413,18 @@ fn scroll_achievements_panel(
}
}
fn spawn_achievements_screen(
commands: &mut Commands,
/// Builds the Achievements tab body inside the You hub's card. The
/// unlock-count line that used to live in the standalone modal's
/// header renders as the first body line instead (the hub owns the
/// header). All markers (`AchievementRow`, `AchievementsScrollable`)
/// are unchanged.
pub(crate) fn spawn_achievements_body(
card: &mut ChildSpawnerCommands,
records: &[AchievementRecord],
font_res: Option<&FontResource>,
) {
let unlocked: Vec<_> = records.iter().filter(|r| r.unlocked).collect();
let total = ALL_ACHIEVEMENTS.len();
let header = format!("Achievements ({}/{})", unlocked.len(), total);
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_name = TextFont {
@@ -486,8 +445,13 @@ fn spawn_achievements_screen(
let any_unlocked = records.iter().any(|r| r.unlocked);
let scrim = spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, header, font_res);
{
// Unlock progress — formerly the standalone modal's header.
card.spawn((
Text::new(format!("Unlocked {} / {}", unlocked.len(), total)),
font_name.clone(),
TextColor(TEXT_SECONDARY),
));
// First-time hint — shown until the player has unlocked anything.
// The list itself describes individual rewards, but a top-level
@@ -594,21 +558,7 @@ fn spawn_achievements_screen(
));
}
});
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
AchievementsCloseButton,
"Done",
Some("A"),
ButtonVariant::Primary,
font_res,
);
});
});
// Achievements is a read-only list — clicking the scrim outside
// the card dismisses alongside the existing A / Done paths.
commands.entity(scrim).insert(ScrimDismissible);
}
}
fn format_reward(reward: Reward) -> String {
@@ -668,7 +618,8 @@ mod tests {
.add_plugins(TablePlugin)
.add_plugins(StatsPlugin::headless())
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
.add_plugins(AchievementPlugin::headless());
.add_plugins(AchievementPlugin::headless())
.add_plugins(crate::you_hub_plugin::YouHubPlugin);
// StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under
// MinimalPlugins it isn't auto-registered.
app.init_resource::<ButtonInput<KeyCode>>();
@@ -1392,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`
@@ -1413,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],
),
)
}
@@ -1457,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),
@@ -1491,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
@@ -1522,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();
@@ -1545,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();
@@ -1569,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();
+175 -40
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),
);
@@ -252,10 +317,17 @@ fn advance_card_anims(
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut anims: Query<(Entity, &mut Transform, &mut CardAnim)>,
mut redraw: MessageWriter<RequestRedraw>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Keep the winit loop awake at full frame rate while slides (including
// staggered deals still in their delay phase) are in flight — required
// for Android's reactive_low_power focused_mode.
if !anims.is_empty() {
redraw.write(RequestRedraw);
}
let dt = time.delta_secs();
for (entity, mut transform, mut anim) in &mut anims {
if anim.delay > 0.0 {
@@ -558,10 +630,17 @@ fn drive_toast_display(
paused: Option<Res<PausedResource>>,
mut queue: ResMut<ToastQueue>,
mut active: ResMut<ActiveToast>,
mut redraw: MessageWriter<RequestRedraw>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Keep the loop ticking while a toast is displayed or queued so the
// countdown advances and the despawn frame isn't held hostage by
// Android's reactive_low_power wake ceiling.
if active.entity.is_some() || !queue.0.is_empty() {
redraw.write(RequestRedraw);
}
let dt = time.delta_secs();
// Tick down the active toast timer.
@@ -593,9 +672,9 @@ pub enum ToastVariant {
/// Neutral system message — teal border. Default for `InfoToastEvent`,
/// settings volume notifications, and the auto-complete announcement.
Info,
/// Caution / penalty — gold border. Currently unused by an in-engine
/// event; kept so future warning-flavoured toasts have a slot.
#[allow(dead_code)]
/// Caution / penalty — gold border. Used by [`handle_warning_toast`]
/// for `WarningToastEvent` messages (daily-challenge expiry, sync,
/// theme-store, and leaderboard warnings).
Warning,
/// Failure / rejected action — pink border. Used by
/// [`handle_move_rejected_toast`] for illegal-placement
@@ -623,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>) {
@@ -730,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),
);
}
@@ -752,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)),
@@ -785,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((
@@ -1301,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"
);
}
}
+5 -4
View File
@@ -1,7 +1,7 @@
//! Sound-effect playback via `kira`.
//!
//! Loads five embedded WAVs (`include_bytes!`) at startup and plays them in
//! response to gameplay events:
//! Loads seven embedded WAVs (`include_bytes!`) at startup — six SFX plus
//! the ambient loop — and plays them in response to gameplay events:
//!
//! | Event | Sound |
//! |---|---|
@@ -10,6 +10,7 @@
//! | `MoveRejectedEvent` | `card_invalid.wav` |
//! | `NewGameRequestEvent` | `card_deal.wav` |
//! | `GameWonEvent` | `win_fanfare.wav` |
//! | `FoundationCompletedEvent` | `foundation_complete.wav` |
//!
//! An ambient loop (`ambient_loop.wav`) is started at plugin startup at very
//! low volume (0.05 amplitude) routed through `music_track`.
@@ -38,7 +39,7 @@ use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
/// Volume amplitude for the stock-recycle draw sound (half of normal 1.0).
const RECYCLE_VOLUME: f64 = 0.5;
/// Volume amplitude for the ambient music loop placeholder.
/// Volume amplitude for the ambient music loop.
const AMBIENT_VOLUME: f64 = 0.05;
/// Converts a linear amplitude (0.01.0+) to the `Decibels` type used by
@@ -101,7 +102,7 @@ pub struct MuteState {
pub music_muted: bool,
}
/// Plays sound effects and background music via `bevy_kira_audio`. Responds to game events (card place, flip, invalid move, win fanfare) and respects volume settings from `SettingsResource`.
/// Plays sound effects and background music via `kira`. Responds to game events (card place, flip, invalid move, win fanfare) and respects volume settings from `SettingsResource`.
pub struct AudioPlugin;
impl Plugin for AudioPlugin {
@@ -147,6 +147,7 @@ fn drive_auto_complete(
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut moves: MessageWriter<MoveRequestEvent>,
mut redraw: MessageWriter<RequestRedraw>,
) {
if !state.active {
return;
@@ -154,6 +155,10 @@ fn drive_auto_complete(
if paused.is_some_and(|p| p.0) {
return;
}
// Keepalive: the step-interval cooldown only advances on frames that
// actually run, so keep the winit loop awake for the whole burst under
// Android's reactive_low_power focused_mode.
redraw.write(RequestRedraw);
state.cooldown -= time.delta_secs();
if state.cooldown > 0.0 {
@@ -18,11 +18,6 @@
//! The sine term is 0 at `t = 0` and `t = 1` and peaks at `t = 0.5`, so the
//! card "floats up" in the middle of its travel and lands at its correct rest z.
//!
//! # Retargeting
//!
//! When a card is redirected mid-flight, call [`retarget_animation`]. It reads
//! the current interpolated position so the card never snaps.
//!
//! # Coexistence with `CardAnim`
//!
//! `CardAnimation` and the legacy `CardAnim` can coexist in the same world but
@@ -33,6 +28,7 @@
use std::f32::consts::PI;
use bevy::prelude::*;
use bevy::window::RequestRedraw;
use super::curves::{MotionCurve, sample_curve};
use super::timing::compute_duration;
@@ -122,8 +118,6 @@ impl CardAnimation {
}
/// Returns the current interpolated XY position without advancing time.
///
/// Used by [`retarget_animation`] to read mid-flight position cleanly.
pub fn current_xy(&self) -> Vec2 {
if self.duration <= 0.0 {
return self.end;
@@ -134,90 +128,6 @@ impl CardAnimation {
}
}
// ---------------------------------------------------------------------------
// Retarget helper
// ---------------------------------------------------------------------------
/// Redirects a card to a new destination without snapping or interrupting motion.
///
/// Reads the card's current interpolated position (from a live [`CardAnimation`]
/// if present, or from `Transform` if stationary) and starts a fresh
/// [`CardAnimation`] from that position. Duration is recalculated from the
/// remaining distance so short paths stay quick.
///
/// # Velocity continuity
///
/// When a card is mid-flight, the new animation starts with a small positive
/// `elapsed` offset (`carry`) derived from how far through the current animation
/// the card is. This preserves a sense of forward momentum: the new curve does
/// not restart from zero velocity, avoiding a visible "lurch" when the target
/// changes rapidly.
///
/// The carry is deliberately small (≤ 10 % of the new duration) so that it
/// never causes a visible position jump — the card's start position is still
/// read from the current transform.
///
/// # Example
///
/// ```ignore
/// // Inside a system that decides to move a card to a new target:
/// let (entity, transform, anim) = cards.get(card_entity)?;
/// retarget_animation(
/// &mut commands,
/// entity,
/// anim, // Option<&CardAnimation>
/// transform,
/// Vec2::new(400.0, 200.0),
/// resting_z,
/// MotionCurve::SmoothSnap,
/// );
/// ```
pub fn retarget_animation(
commands: &mut Commands,
entity: Entity,
current_anim: Option<&CardAnimation>,
transform: &Transform,
new_end: Vec2,
new_end_z: f32,
curve: MotionCurve,
) {
let (current_xy, current_z, momentum_carry) = match current_anim {
Some(anim) if anim.duration > 0.0 => {
// Estimate how far into the current animation we are and carry
// a small fraction of that progress into the new animation.
// This avoids restarting from zero velocity and makes the motion
// feel continuous when the target changes mid-flight.
let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0);
// Cap at 10 % of the new animation so there's no visible jump.
let carry = (t * 0.12).min(0.10);
(anim.current_xy(), transform.translation.z, carry)
}
_ => (
transform.translation.truncate(),
transform.translation.z,
0.0,
),
};
let distance = current_xy.distance(new_end);
let duration = compute_duration(distance);
commands.entity(entity).insert(CardAnimation {
start: current_xy,
end: new_end,
// Start slightly into the new animation to carry forward momentum.
elapsed: momentum_carry * duration,
duration,
curve,
delay: 0.0,
start_z: current_z,
end_z: new_end_z,
z_lift: 8.0,
scale_start: 1.0,
scale_end: 1.0,
});
}
// ---------------------------------------------------------------------------
// System
// ---------------------------------------------------------------------------
@@ -232,10 +142,18 @@ pub(crate) fn advance_card_animations(
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut q: Query<(Entity, &mut Transform, &mut CardAnimation)>,
mut redraw: MessageWriter<RequestRedraw>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Keep the winit event loop awake while any animation (including one
// still in its delay phase) needs per-frame ticks. Without this,
// Android's reactive_low_power focused_mode only wakes at its 100 ms
// ceiling and card slides render at ~10 fps.
if !q.is_empty() {
redraw.write(RequestRedraw);
}
let dt = time.delta_secs();
for (entity, mut transform, mut anim) in &mut q {
@@ -283,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
// ---------------------------------------------------------------------------
@@ -386,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:?}"
);
}
}
}
+45 -109
View File
@@ -31,38 +31,6 @@
//! ));
//! ```
//!
//! Retarget a card mid-flight:
//!
//! ```ignore
//! use solitaire_engine::card_animation::retarget_animation;
//!
//! fn handle_drop(
//! mut commands: Commands,
//! q: Query<(Entity, &Transform, Option<&CardAnimation>), With<CardEntity>>,
//! ) {
//! let (entity, transform, anim) = q.get(card_entity).unwrap();
//! retarget_animation(
//! &mut commands,
//! entity,
//! anim,
//! transform,
//! new_target_xy,
//! new_target_z,
//! MotionCurve::SmoothSnap,
//! );
//! }
//! ```
//!
//! # 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? |
@@ -80,24 +48,21 @@ pub mod interaction;
pub mod timing;
pub mod tuning;
pub use animation::{CardAnimation, retarget_animation, 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;
@@ -166,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
// ---------------------------------------------------------------------------
@@ -307,6 +215,49 @@ mod tests {
);
}
/// Regression test for the v0.40.0 Android animation-lag bug: commit
/// 38e4c03 switched Android to `reactive_low_power` focused_mode on the
/// premise that animation systems write `RequestRedraw` while active,
/// but the writers were never added — card slides rendered at the 100 ms
/// wake ceiling (~10 fps). Active animations MUST emit `RequestRedraw`
/// every frame; an idle board must not.
#[test]
fn active_card_animation_requests_redraw() {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(CardAnimationPlugin);
// Idle board: no redraw requests.
app.update();
assert!(
app.world().resource::<Messages<RequestRedraw>>().is_empty(),
"no RequestRedraw expected while no animation is active"
);
app.world_mut().spawn((
Transform::from_translation(Vec3::ZERO),
CardAnimation {
start: Vec2::ZERO,
end: Vec2::new(100.0, 0.0),
elapsed: 0.0,
duration: 1.0,
curve: MotionCurve::Responsive,
delay: 0.0,
start_z: 0.0,
end_z: 0.0,
z_lift: 0.0,
scale_start: 1.0,
scale_end: 1.0,
},
));
app.update();
assert!(
!app.world().resource::<Messages<RequestRedraw>>().is_empty(),
"an active CardAnimation must write RequestRedraw each frame to \
sustain the reactive render loop"
);
}
#[test]
fn card_animation_instant_snaps_on_zero_duration() {
let mut app = App::new();
@@ -412,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}"
);
}
}
}
@@ -0,0 +1,309 @@
//! Contextual one-time tips (Phase I of the 2026-07 UI redesign).
//!
//! Everything the first-run onboarding doesn't cover is invisible until
//! stumbled upon: hint cycling, the radial quick-action menu. These
//! tips fire **from the situation, not a tour** — each exactly once,
//! recorded in `Settings` like `shown_achievement_onboarding`:
//!
//! - **Stall tip** — after [`STALL_TIP_SECS`] with no board change in an
//! active game, an info toast points at Hint. A player staring at a
//! stuck board is the one moment the tip is welcome.
//! - **Radial tip** — once a game reaches [`RADIAL_TIP_MIN_MOVES`] moves
//! (an engaged player) and the radial menu has never been opened, an
//! info toast teaches the long-press / right-click gesture. Opening
//! the radial organically marks the tip as unnecessary — it is never
//! shown to someone who already knows.
//!
//! Tips render through the queued [`InfoToastEvent`] path, so they share
//! the unified toast stack and never interrupt play.
use bevy::prelude::*;
use solitaire_data::save_settings_to;
use crate::events::{InfoToastEvent, StateChangedEvent};
use crate::game_plugin::GameMutation;
use crate::pause_plugin::PausedResource;
use crate::platform::SHOW_KEYBOARD_ACCELERATORS;
use crate::radial_menu::RightClickRadialState;
use crate::resources::GameStateResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::ui_modal::ModalScrim;
/// Seconds without any board change before the stall tip fires.
/// Long enough that normal thinking never triggers it; short enough to
/// reach a genuinely stuck player before they quit.
const STALL_TIP_SECS: f32 = 45.0;
/// Move count at which an engaged player earns the radial-menu teach.
const RADIAL_TIP_MIN_MOVES: u32 = 15;
/// Stall-tip copy per platform input vocabulary.
const STALL_TIP: &str = if SHOW_KEYBOARD_ACCELERATORS {
"Stuck? Press H for a hint."
} else {
"Stuck? Tap Hint in the bottom bar for a suggested move."
};
/// Radial-tip copy per platform input vocabulary.
const RADIAL_TIP: &str = if SHOW_KEYBOARD_ACCELERATORS {
"Tip: right-click a card for quick actions."
} else {
"Tip: long-press a card for quick actions."
};
/// Seconds of board inactivity, frozen while paused / a modal is open,
/// reset by every [`StateChangedEvent`].
#[derive(Resource, Debug, Default)]
pub struct StallClock(pub f32);
/// Registers the stall clock and the two tip triggers.
pub struct ContextualTipsPlugin;
impl Plugin for ContextualTipsPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<StallClock>()
.add_message::<InfoToastEvent>()
.add_message::<StateChangedEvent>()
.add_systems(
Update,
(
tick_stall_clock,
fire_stall_hint_tip
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
observe_radial_menu_use,
fire_radial_menu_tip
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
)
.chain()
.after(GameMutation),
);
}
}
/// Advances the stall clock; resets on any board change; freezes while
/// paused, while a modal owns the screen, or when the game is over.
fn tick_stall_clock(
time: Res<Time>,
mut state_events: MessageReader<StateChangedEvent>,
paused: Option<Res<PausedResource>>,
scrims: Query<(), With<ModalScrim>>,
game: Option<Res<GameStateResource>>,
mut clock: ResMut<StallClock>,
) {
if state_events.read().next().is_some() {
clock.0 = 0.0;
return;
}
if paused.is_some_and(|p| p.0)
|| !scrims.is_empty()
|| game.as_ref().is_none_or(|g| g.0.is_won())
{
// Frozen, not reset: backgrounding into a menu mid-stall
// shouldn't restart the wait.
return;
}
clock.0 += time.delta_secs();
}
/// Fires the one-shot stall tip once the clock passes the threshold in
/// a game the player has actually started (at least one move).
fn fire_stall_hint_tip(
clock: Res<StallClock>,
game: Option<Res<GameStateResource>>,
mut settings: Option<ResMut<SettingsResource>>,
storage_path: Option<Res<SettingsStoragePath>>,
mut toast: MessageWriter<InfoToastEvent>,
) {
let Some(s) = settings.as_mut() else { return };
if s.0.shown_stall_hint_tip || clock.0 < STALL_TIP_SECS {
return;
}
if game
.as_ref()
.is_none_or(|g| g.0.move_count() == 0 || g.0.is_won())
{
return;
}
toast.write(InfoToastEvent(STALL_TIP.to_string()));
s.0.shown_stall_hint_tip = true;
persist(storage_path.as_deref(), &s.0);
}
/// A player who opens the radial menu on their own doesn't need the
/// teach — mark the tip done silently.
fn observe_radial_menu_use(
radial: Option<Res<RightClickRadialState>>,
mut settings: Option<ResMut<SettingsResource>>,
storage_path: Option<Res<SettingsStoragePath>>,
) {
let Some(radial) = radial else { return };
if !radial.is_active() {
return;
}
let Some(s) = settings.as_mut() else { return };
if s.0.shown_radial_menu_tip {
return;
}
s.0.shown_radial_menu_tip = true;
persist(storage_path.as_deref(), &s.0);
}
/// Fires the one-shot radial teach for an engaged player who has never
/// opened the menu themselves.
fn fire_radial_menu_tip(
game: Option<Res<GameStateResource>>,
mut settings: Option<ResMut<SettingsResource>>,
storage_path: Option<Res<SettingsStoragePath>>,
mut toast: MessageWriter<InfoToastEvent>,
) {
let Some(s) = settings.as_mut() else { return };
if s.0.shown_radial_menu_tip {
return;
}
if game
.as_ref()
.is_none_or(|g| g.0.move_count() < RADIAL_TIP_MIN_MOVES || g.0.is_won())
{
return;
}
toast.write(InfoToastEvent(RADIAL_TIP.to_string()));
s.0.shown_radial_menu_tip = true;
persist(storage_path.as_deref(), &s.0);
}
fn persist(storage_path: Option<&SettingsStoragePath>, settings: &solitaire_data::Settings) {
if let Some(p) = storage_path
&& let Some(path) = p.0.as_deref()
&& let Err(e) = save_settings_to(path, settings)
{
warn!("contextual tips: failed to persist tip flag: {e}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::ecs::message::Messages;
use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_data::Settings;
fn app_with(settings: Settings, game: GameState) -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(ContextualTipsPlugin);
app.insert_resource(SettingsResource(settings));
app.insert_resource(GameStateResource(game));
app.update();
app
}
fn info_toast_count(app: &App) -> usize {
let events = app.world().resource::<Messages<InfoToastEvent>>();
let mut cursor = events.get_cursor();
cursor.read(events).count()
}
fn started_game() -> GameState {
let mut game = GameState::new(7, DrawStockConfig::DrawOne);
game.draw().expect("draw from fresh deal");
game
}
#[test]
fn stall_tip_fires_once_past_threshold() {
let mut app = app_with(Settings::default(), started_game());
app.world_mut().resource_mut::<StallClock>().0 = STALL_TIP_SECS + 1.0;
app.update();
assert_eq!(info_toast_count(&app), 1, "stall tip must fire");
assert!(
app.world()
.resource::<SettingsResource>()
.0
.shown_stall_hint_tip,
"the tip flag must set so it never repeats"
);
app.world_mut().resource_mut::<StallClock>().0 = STALL_TIP_SECS + 30.0;
app.world_mut()
.resource_mut::<Messages<InfoToastEvent>>()
.clear();
app.update();
assert_eq!(info_toast_count(&app), 0, "the tip is one-shot");
}
#[test]
fn stall_tip_needs_a_started_unwon_game() {
// Untouched deal: staring at the launch screen is not a stall.
let mut app = app_with(
Settings::default(),
GameState::new(7, DrawStockConfig::DrawOne),
);
app.world_mut().resource_mut::<StallClock>().0 = STALL_TIP_SECS + 1.0;
app.update();
assert_eq!(info_toast_count(&app), 0, "no tip on an untouched deal");
// Won game: nothing to hint at.
let mut won = started_game();
won.set_test_won(true);
let mut app = app_with(Settings::default(), won);
app.world_mut().resource_mut::<StallClock>().0 = STALL_TIP_SECS + 1.0;
app.update();
assert_eq!(info_toast_count(&app), 0, "no tip on a won game");
}
#[test]
fn stall_clock_resets_on_state_change() {
let mut app = app_with(Settings::default(), started_game());
app.world_mut().resource_mut::<StallClock>().0 = 30.0;
app.world_mut().write_message(StateChangedEvent);
app.update();
assert_eq!(
app.world().resource::<StallClock>().0,
0.0,
"any board change must reset the stall clock"
);
}
#[test]
fn radial_tip_fires_for_engaged_player() {
let mut game = started_game();
game.set_test_move_count(RADIAL_TIP_MIN_MOVES);
let mut app = app_with(Settings::default(), game);
app.update();
assert_eq!(
info_toast_count(&app),
1,
"radial tip must fire at the move threshold"
);
assert!(
app.world()
.resource::<SettingsResource>()
.0
.shown_radial_menu_tip
);
}
#[test]
fn radial_tip_suppressed_after_organic_use() {
let mut game = started_game();
game.set_test_move_count(RADIAL_TIP_MIN_MOVES);
let mut app = app_with(
Settings {
shown_radial_menu_tip: true,
..Settings::default()
},
game,
);
app.update();
assert_eq!(
info_toast_count(&app),
0,
"a player who used the radial menu never sees the teach"
);
}
}
+12 -7
View File
@@ -14,13 +14,14 @@ use crate::platform::{
};
use crate::{
AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin,
CardAnimationPlugin, CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin,
DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin,
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncProvider,
TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin,
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
CardAnimationPlugin, CardPlugin, ChallengePlugin, ContextualTipsPlugin, CursorPlugin,
DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin,
GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin,
PlayBySeedPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin,
ReplayPlaybackPlugin, SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin,
SolutionPlaybackPlugin, SplashPlugin, StatsPlugin, SyncProvider, TablePlugin, ThemePlugin,
ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin,
UiTooltipPlugin, WeeklyGoalsPlugin, WhatsNewPlugin, WinSummaryPlugin,
};
#[cfg(not(target_arch = "wasm32"))]
use crate::{
@@ -93,6 +94,7 @@ impl Plugin for CoreGamePlugin {
.add_plugins(FeedbackAnimPlugin)
.add_plugins(CardAnimationPlugin)
.add_plugins(AutoCompletePlugin)
.add_plugins(SolutionPlaybackPlugin)
.add_plugins(ReplayPlaybackPlugin)
.add_plugins(ReplayOverlayPlugin)
.add_plugins(StatsPlugin::default())
@@ -109,9 +111,12 @@ impl Plugin for CoreGamePlugin {
.add_plugins(HelpPlugin)
.add_plugins(HomePlugin::default())
.add_plugins(ProfilePlugin)
.add_plugins(crate::you_hub_plugin::YouHubPlugin)
.add_plugins(PausePlugin)
.add_plugins(SettingsPlugin::default())
.add_plugins(OnboardingPlugin)
.add_plugins(WhatsNewPlugin)
.add_plugins(ContextualTipsPlugin)
.add_plugins(WinSummaryPlugin)
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin)
+10 -19
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`.
@@ -159,6 +158,12 @@ pub struct DeleteAccountRequestEvent;
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct PauseRequestEvent;
/// Request to solve the current deal and auto-play the winning line.
/// Fired by the pause menu's "Show solution" button; consumed by
/// `solution_playback_plugin`.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ShowSolutionRequestEvent;
/// Request to toggle the help / controls overlay. Fired by the HUD "Help"
/// button alongside the existing `F1` accelerator so the overlay is
/// reachable without a keyboard. Consumed by `help_plugin::toggle_help_screen`.
@@ -237,15 +242,10 @@ pub struct ToggleSettingsRequestEvent;
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ToggleLeaderboardRequestEvent;
/// 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>);
/// Request to toggle the Home mode launcher. Fired by the HUD
/// Menu-popover "Home" row alongside the existing `M` accelerator.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ToggleHomeRequestEvent;
/// Generic informational toast message. Any system can fire this to display
/// a short string to the player, e.g. "Locked — reach level 5".
@@ -289,15 +289,6 @@ pub struct ForfeitEvent;
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ForfeitRequestEvent;
/// Fired when the player clicks "Scan for new themes" in Settings.
///
/// Consumed by `handle_scan_themes` in `SettingsPlugin`, which scans
/// `user_theme_dir()` for `.zip` files, calls `import_theme()` on each
/// unrecognised archive, refreshes [`crate::theme::ThemeRegistry`], and
/// fires [`InfoToastEvent`] messages to report results.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ScanThemesRequestEvent;
/// Fired when the player requests a hint (H key). Carries the source card ID
/// and destination pile for visual highlighting.
///
+322 -3
View File
@@ -49,10 +49,11 @@ use solitaire_core::klondike_adapter::foundation_from_slot;
use solitaire_data::AnimSpeed;
use crate::animation_plugin::CardAnim;
use crate::card_plugin::CardEntity;
use crate::card_animation::{MotionCurve, sample_curve};
use crate::card_plugin::{CardEntity, CardEntityIndex};
use crate::events::{
DrawRequestEvent, FoundationCompletedEvent, MoveRejectedEvent, MoveRequestEvent,
NewGameRequestEvent,
DrawRequestEvent, FoundationCompletedEvent, HintVisualEvent, MoveRejectedEvent,
MoveRequestEvent, NewGameRequestEvent, StateChangedEvent,
};
use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource;
@@ -207,6 +208,8 @@ impl Plugin for FeedbackAnimPlugin {
.add_message::<MoveRejectedEvent>()
.add_message::<NewGameRequestEvent>()
.add_message::<FoundationCompletedEvent>()
.add_message::<HintVisualEvent>()
.add_message::<StateChangedEvent>()
.add_message::<RequestRedraw>()
.add_systems(
Update,
@@ -224,6 +227,20 @@ impl Plugin for FeedbackAnimPlugin {
start_deal_anim.after(GameMutation),
start_foundation_flourish.after(GameMutation),
),
)
// Hint ghost (Phase H): the spawn reads card Transform/Sprite,
// so it orders after the board painters; the tick only touches
// ghost entities (Without<CardEntity>) and stays conflict-free.
.add_systems(
Update,
(
spawn_hint_ghost
.after(GameMutation)
.after(crate::card_plugin::BoardVisuals),
tick_hint_ghosts,
despawn_hint_ghosts_on_state_change.after(GameMutation),
)
.chain(),
);
}
}
@@ -276,10 +293,16 @@ fn tick_shake_anim(
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut anims: Query<(Entity, &mut Transform, &mut ShakeAnim)>,
mut redraw: MessageWriter<RequestRedraw>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Sustain full-rate frames for the shake under Android's
// reactive_low_power focused_mode.
if !anims.is_empty() {
redraw.write(RequestRedraw);
}
let dt = time.delta_secs();
for (entity, mut transform, mut anim) in &mut anims {
anim.elapsed += dt;
@@ -356,10 +379,16 @@ fn tick_settle_anim(
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut anims: Query<(Entity, &mut Transform, &mut SettleAnim)>,
mut redraw: MessageWriter<RequestRedraw>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Sustain full-rate frames for the settle bounce under Android's
// reactive_low_power focused_mode.
if !anims.is_empty() {
redraw.write(RequestRedraw);
}
let dt = time.delta_secs();
for (entity, mut transform, mut anim) in &mut anims {
anim.elapsed += dt;
@@ -581,10 +610,16 @@ fn tick_foundation_flourish(
(Entity, &mut Sprite, &mut FoundationMarkerFlourish),
Without<FoundationFlourish>,
>,
mut redraw: MessageWriter<RequestRedraw>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Sustain full-rate frames for the flourish under Android's
// reactive_low_power focused_mode.
if !card_anims.is_empty() || !marker_anims.is_empty() {
redraw.write(RequestRedraw);
}
let dt = time.delta_secs();
// Advance the King's scale pulse.
@@ -646,6 +681,147 @@ fn pile_cards(
}
}
// ---------------------------------------------------------------------------
// Phase H — hint ghost-motion preview
// ---------------------------------------------------------------------------
/// Duration of one ghost glide from the hinted card to its destination.
const HINT_GHOST_PASS_SECS: f32 = 0.7;
/// How many glides one hint plays before the ghost despawns. Two reads
/// as "this move, over there" without outstaying the 2 s static
/// highlight it accompanies.
const HINT_GHOST_PASSES: f32 = 2.0;
/// Ghost translucency — clearly a projection, never mistakable for the
/// real card.
const HINT_GHOST_ALPHA: f32 = 0.45;
/// Ghost render depth: above every settled pile (~1.04 max) and the
/// in-flight `CardAnim` lift (50), below a dragged card (500).
const HINT_GHOST_Z: f32 = 400.0;
/// A translucent copy of the hinted card gliding to the suggested
/// destination (Phase H). Purely decorative — despawned by timer, by a
/// newer hint, or by any state change.
#[derive(Component, Debug)]
pub struct HintGhost {
start: Vec3,
target: Vec3,
elapsed: f32,
}
/// Normalised progress of the current glide pass, restarting from the
/// source each pass. Pure for unit testing.
fn hint_ghost_pass_t(elapsed: f32) -> f32 {
(elapsed % HINT_GHOST_PASS_SECS) / HINT_GHOST_PASS_SECS
}
/// Ghost alpha at `pass_t` — full strength for most of the glide, then
/// fading over the last 20 % so the loop restart reads as a repeat
/// rather than a teleport. Pure for unit testing.
fn hint_ghost_alpha(pass_t: f32) -> f32 {
let fade_in_tail = ((pass_t - 0.8) / 0.2).clamp(0.0, 1.0);
HINT_GHOST_ALPHA * (1.0 - fade_in_tail)
}
/// Spawns the ghost when a hint fires. The static highlights (source
/// card + gold destination pile) still spawn regardless; under
/// reduce-motion they are the whole story and no ghost appears
/// (`design-system.md` §Accessibility).
#[allow(clippy::too_many_arguments)]
fn spawn_hint_ghost(
mut events: MessageReader<HintVisualEvent>,
settings: Option<Res<SettingsResource>>,
index: Option<Res<CardEntityIndex>>,
layout: Option<Res<LayoutResource>>,
cards: Query<(&Transform, &Sprite), With<CardEntity>>,
existing: Query<Entity, With<HintGhost>>,
mut commands: Commands,
) {
if events.is_empty() {
return;
}
if settings.is_some_and(|s| s.0.reduce_motion_mode) {
events.clear();
return;
}
let (Some(index), Some(layout)) = (index, layout) else {
events.clear();
return;
};
for ev in events.read() {
// A fresh hint replaces any ghost still in flight.
for entity in &existing {
commands.entity(entity).despawn();
}
let Some(card_entity) = index.get(&ev.source_card) else {
continue;
};
let Ok((transform, sprite)) = cards.get(card_entity) else {
continue;
};
let Some(&dest) = layout.0.pile_positions.get(&ev.dest_pile) else {
continue;
};
let start = transform.translation.truncate().extend(HINT_GHOST_Z);
let mut ghost_sprite = sprite.clone();
ghost_sprite.color = ghost_sprite.color.with_alpha(HINT_GHOST_ALPHA);
commands.spawn((
HintGhost {
start,
target: dest.extend(HINT_GHOST_Z),
elapsed: 0.0,
},
ghost_sprite,
Transform::from_translation(start),
));
}
}
/// Advances every ghost: eased glide per pass, tail fade, despawn after
/// [`HINT_GHOST_PASSES`]. Frozen while paused, like every other
/// decorative animation.
#[allow(clippy::type_complexity)]
fn tick_hint_ghosts(
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut ghosts: Query<
(Entity, &mut HintGhost, &mut Transform, &mut Sprite),
(Without<CardEntity>, Without<PileMarker>),
>,
mut commands: Commands,
) {
if paused.is_some_and(|p| p.0) {
return;
}
let dt = time.delta_secs();
for (entity, mut ghost, mut transform, mut sprite) in ghosts.iter_mut() {
ghost.elapsed += dt;
if ghost.elapsed >= HINT_GHOST_PASS_SECS * HINT_GHOST_PASSES {
commands.entity(entity).despawn();
continue;
}
let pass_t = hint_ghost_pass_t(ghost.elapsed);
let eased = sample_curve(MotionCurve::SmoothSnap, pass_t);
transform.translation = ghost.start.lerp(ghost.target, eased);
sprite.color = sprite.color.with_alpha(hint_ghost_alpha(pass_t));
}
}
/// A ghost previews a move against the *current* board; the moment the
/// board changes (move, undo, draw, new game) it is stale and vanishes.
fn despawn_hint_ghosts_on_state_change(
mut events: MessageReader<StateChangedEvent>,
ghosts: Query<Entity, With<HintGhost>>,
mut commands: Commands,
) {
if events.is_empty() {
return;
}
events.clear();
for entity in &ghosts {
commands.entity(entity).despawn();
}
}
// ---------------------------------------------------------------------------
// Unit tests (pure functions only — no Bevy world required)
// ---------------------------------------------------------------------------
@@ -937,4 +1113,147 @@ mod tests {
"FoundationFlourish must not be inserted under reduce-motion"
);
}
// -----------------------------------------------------------------------
// Phase H — hint ghost-motion preview
// -----------------------------------------------------------------------
#[test]
fn hint_ghost_pass_t_restarts_each_pass() {
assert_eq!(hint_ghost_pass_t(0.0), 0.0);
// Just past one full pass, progress wraps back near zero.
assert!(hint_ghost_pass_t(HINT_GHOST_PASS_SECS + 0.01) < 0.1);
}
#[test]
fn hint_ghost_alpha_fades_only_in_the_tail() {
assert_eq!(hint_ghost_alpha(0.0), HINT_GHOST_ALPHA);
assert_eq!(hint_ghost_alpha(0.79), HINT_GHOST_ALPHA);
assert!(hint_ghost_alpha(0.9) < HINT_GHOST_ALPHA);
assert!(hint_ghost_alpha(1.0).abs() < 1e-6);
}
/// App with one indexed card entity and a real layout, ready to
/// receive `HintVisualEvent`s.
fn ghost_app(reduce_motion: bool) -> (App, Card) {
use solitaire_core::{Deck, Rank, Suit};
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(FeedbackAnimPlugin);
// Sibling systems in the plugin take GameStateResource
// non-optionally; give them a fresh deal.
app.insert_resource(GameStateResource(
solitaire_core::game_state::GameState::new(1, solitaire_core::DrawStockConfig::DrawOne),
));
app.insert_resource(SettingsResource(solitaire_data::Settings {
reduce_motion_mode: reduce_motion,
..Default::default()
}));
let card = Card::new(Deck::Deck1, Suit::Spades, Rank::Ace);
let entity = app
.world_mut()
.spawn((
CardEntity { card: card.clone() },
Sprite::default(),
Transform::from_xyz(-100.0, 40.0, 1.0),
))
.id();
let mut index = CardEntityIndex::default();
index.0.insert(card.clone(), entity);
app.insert_resource(index);
app.insert_resource(LayoutResource(crate::layout::compute_layout(
Vec2::new(1280.0, 720.0),
0.0,
0.0,
true,
)));
app.update();
(app, card)
}
fn fire_hint(app: &mut App, card: Card) {
use solitaire_core::Tableau;
app.world_mut().write_message(HintVisualEvent {
source_card: card,
dest_pile: KlondikePile::Tableau(Tableau::Tableau2),
});
app.update();
}
fn ghost_count(app: &mut App) -> usize {
app.world_mut()
.query::<&HintGhost>()
.iter(app.world())
.count()
}
#[test]
fn hint_event_spawns_one_ghost_at_the_source_card() {
let (mut app, card) = ghost_app(false);
fire_hint(&mut app, card);
assert_eq!(ghost_count(&mut app), 1, "hint must spawn one ghost");
let transform = app
.world_mut()
.query_filtered::<&Transform, With<HintGhost>>()
.single(app.world())
.expect("ghost transform");
// The tick system may have advanced the ghost by one real-time
// frame already; assert it is still essentially at the source.
assert!(
transform
.translation
.truncate()
.distance(Vec2::new(-100.0, 40.0))
< 5.0,
"ghost must start at the hinted card, got {:?}",
transform.translation
);
assert_eq!(transform.translation.z, HINT_GHOST_Z);
}
#[test]
fn hint_ghost_suppressed_under_reduce_motion() {
let (mut app, card) = ghost_app(true);
fire_hint(&mut app, card);
assert_eq!(
ghost_count(&mut app),
0,
"no ghost may spawn under reduce-motion; the static highlight is the whole story"
);
}
#[test]
fn state_change_despawns_the_ghost() {
let (mut app, card) = ghost_app(false);
fire_hint(&mut app, card);
assert_eq!(ghost_count(&mut app), 1);
app.world_mut().write_message(StateChangedEvent);
app.update();
app.update();
assert_eq!(
ghost_count(&mut app),
0,
"a board change makes the previewed move stale — the ghost must vanish"
);
}
#[test]
fn second_hint_replaces_the_first_ghost() {
let (mut app, card) = ghost_app(false);
fire_hint(&mut app, card.clone());
fire_hint(&mut app, card);
app.update();
assert_eq!(
ghost_count(&mut app),
1,
"re-hinting must replace the in-flight ghost, not stack a second"
);
}
}
+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);
+36 -2
View File
@@ -86,13 +86,18 @@ fn toggle_help_screen(
}
/// Click handler for the modal's "Done" button. F1 toggles the overlay
/// the same way; this just exposes the close action to mouse / touch.
/// the same way; Esc closes too, so dismissal matches every other
/// modal (Phase C dismissal audit). Nothing ever stacks above Help,
/// so Esc needs no topmost gate.
fn handle_help_close_button(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
close_buttons: Query<&Interaction, (With<HelpCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<HelpScreen>>,
) {
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
let clicked = close_buttons.iter().any(|i| *i == Interaction::Pressed);
let esc = keys.just_pressed(KeyCode::Escape) && !screens.is_empty();
if !clicked && !esc {
return;
}
for entity in &screens {
@@ -583,4 +588,33 @@ mod tests {
0
);
}
/// Esc must dismiss the Help modal like Done and F1 do (Phase C
/// dismissal audit).
#[test]
fn escape_closes_help_screen() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::F1);
app.update();
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(KeyCode::F1);
input.clear();
input.press(KeyCode::Escape);
}
app.update();
app.update();
assert_eq!(
app.world_mut()
.query::<&HelpScreen>()
.iter(app.world())
.count(),
0,
"Esc must close the Help modal"
);
}
}
File diff suppressed because it is too large Load Diff
+175 -64
View File
@@ -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>,
@@ -311,44 +391,65 @@ pub(super) fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&Font
..default()
};
// Each row carries a tooltip alongside its label so hover reveals
// a one-line description of what each overlay shows — mirroring
// the tooltips on the action-bar buttons that opened this popover.
let rows: [(MenuOption, &'static str, &'static str); 7] = [
// One popover row: destination, label, hover tooltip.
type MenuRow = (MenuOption, &'static str, &'static str);
// Destinations grouped into labelled sections (Phase C of the menu
// redesign): Play · You · Community · System. Each row carries a
// tooltip alongside its label so hover reveals a one-line
// description of what each overlay shows — mirroring the tooltips
// on the action-bar buttons that opened this popover. Mode
// selection lives on Home now, so there is no Modes row.
let sections: [(&'static str, &'static [MenuRow]); 4] = [
(
MenuOption::Help,
"Help",
"Show controls, rules, and keyboard shortcuts.",
"Play",
&[(
MenuOption::Home,
"Home",
"Pick a mode, continue, or start a new game.",
)],
),
(
MenuOption::Modes,
"Game Modes",
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
"You",
&[
(
MenuOption::Profile,
"Profile",
"Your level, XP progress, and sync status.",
),
(
MenuOption::Stats,
"Stats",
"Lifetime totals: wins, streaks, fastest time, best score.",
),
(
MenuOption::Achievements,
"Achievements",
"Browse unlocked achievements and the rewards still ahead.",
),
],
),
(
MenuOption::Stats,
"Stats",
"Lifetime totals: wins, streaks, fastest time, best score.",
"Community",
&[(
MenuOption::Leaderboard,
"Leaderboard",
"Top players from your sync server. Opt in from Profile.",
)],
),
(
MenuOption::Achievements,
"Achievements",
"Browse unlocked achievements and the rewards still ahead.",
),
(
MenuOption::Profile,
"Profile",
"Your level, XP progress, and sync status.",
),
(
MenuOption::Settings,
"Settings",
"Audio, animations, theme, draw mode, and sync.",
),
(
MenuOption::Leaderboard,
"Leaderboard",
"Top players from your sync server. Opt in from Profile.",
"System",
&[
(
MenuOption::Settings,
"Settings",
"Audio, animations, theme, draw mode, and sync.",
),
(
MenuOption::Help,
"Help",
"Show controls, rules, and keyboard shortcuts.",
),
],
),
];
@@ -373,27 +474,48 @@ pub(super) fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&Font
ZIndex(Z_HUD_POPOVER),
))
.with_children(|panel| {
for (option, label, tooltip) in rows {
let section_font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_CAPTION,
..default()
};
for (section, rows) in sections {
// Non-interactive section header — a quiet divider
// inside the existing panel, not a new widget.
panel
.spawn((
option,
ActionButton,
PopoverRow,
Button,
Tooltip::new(tooltip),
Node {
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
justify_content: JustifyContent::FlexStart,
align_items: AlignItems::Center,
min_width: Val::Px(150.0),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(ACTION_BTN_IDLE),
))
.spawn(Node {
padding: UiRect::axes(VAL_SPACE_3, Val::Px(2.0)),
..default()
})
.with_children(|b| {
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
b.spawn((
Text::new(section),
section_font.clone(),
TextColor(TEXT_SECONDARY),
));
});
for &(option, label, tooltip) in rows {
panel
.spawn((
option,
ActionButton,
PopoverRow,
Button,
Tooltip::new(tooltip),
Node {
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
justify_content: JustifyContent::FlexStart,
align_items: AlignItems::Center,
min_width: Val::Px(150.0),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(ACTION_BTN_IDLE),
))
.with_children(|b| {
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
});
}
}
});
@@ -422,31 +544,28 @@ pub(super) fn handle_menu_option_click(
interaction_query: Query<(&Interaction, &MenuOption), Changed<Interaction>>,
popovers: Query<Entity, With<MenuPopover>>,
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
mut home: MessageWriter<ToggleHomeRequestEvent>,
mut stats: MessageWriter<ToggleStatsRequestEvent>,
mut achievements: MessageWriter<ToggleAchievementsRequestEvent>,
mut profile: MessageWriter<ToggleProfileRequestEvent>,
mut settings: MessageWriter<ToggleSettingsRequestEvent>,
mut leaderboard: MessageWriter<ToggleLeaderboardRequestEvent>,
mut help: MessageWriter<HelpRequestEvent>,
progress: Option<Res<ProgressResource>>,
daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>,
mut commands: Commands,
) {
let mut clicked_any = false;
let mut open_modes = false;
for (interaction, option) in &interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
clicked_any = true;
match option {
MenuOption::Home => {
home.write(ToggleHomeRequestEvent);
}
MenuOption::Help => {
help.write(HelpRequestEvent);
}
MenuOption::Modes => {
open_modes = true;
}
MenuOption::Stats => {
stats.write(ToggleStatsRequestEvent);
}
@@ -470,14 +589,6 @@ pub(super) fn handle_menu_option_click(
commands.entity(e).despawn();
}
}
if open_modes {
spawn_modes_popover(
&mut commands,
progress.as_deref(),
daily.as_deref(),
font_res.as_deref(),
);
}
}
/// Despawns the [`ModesPopover`] and its backdrop when Escape / Android back
+51 -29
View File
@@ -28,11 +28,11 @@ 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, ToggleLeaderboardRequestEvent,
ToggleProfileRequestEvent, ToggleSettingsRequestEvent, ToggleStatsRequestEvent,
UndoRequestEvent, WinStreakMilestoneEvent,
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleHomeRequestEvent,
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent,
};
use crate::font_plugin::FontResource;
use crate::game_plugin::{GameMutation, NewGameRequestWriters};
@@ -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"))]
@@ -386,8 +394,9 @@ pub enum ModeOption {
}
/// Marker on the "Menu" action button. Click toggles the [`MenuPopover`]
/// which exposes the Stats / Achievements / Profile / Settings /
/// Leaderboard overlays without needing the S/A/P/O/L hotkeys.
/// which exposes the Home / Profile / Stats / Achievements /
/// Leaderboard / Settings / Help overlays without needing the
/// M/P/S/A/L/O/F1 hotkeys.
#[derive(Component, Debug)]
pub struct MenuButton;
@@ -413,16 +422,18 @@ struct MenuPopoverBackdrop;
struct ModesPopoverBackdrop;
/// One row inside the [`MenuPopover`]. The variant selects which
/// `Toggle*RequestEvent` the click handler fires.
/// `Toggle*RequestEvent` the click handler fires. Rows render grouped
/// under section headers (Play · You · Community · System); mode
/// selection lives on Home, so there is no Modes row here.
#[derive(Component, Debug, Clone, Copy)]
pub enum MenuOption {
Help,
Modes,
Home,
Profile,
Stats,
Achievements,
Profile,
Settings,
Leaderboard,
Settings,
Help,
}
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
@@ -452,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>()
@@ -459,6 +471,7 @@ impl Plugin for HudPlugin {
.add_message::<StartTimeAttackRequestEvent>()
.add_message::<StartDailyChallengeRequestEvent>()
.add_message::<ToggleStatsRequestEvent>()
.add_message::<ToggleHomeRequestEvent>()
.add_message::<ToggleAchievementsRequestEvent>()
.add_message::<ToggleProfileRequestEvent>()
.add_message::<ToggleSettingsRequestEvent>()
@@ -467,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>>()
@@ -556,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,
+156 -2
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),
@@ -786,8 +786,8 @@ fn popover_rows_carry_tooltip_strings() {
menu_tooltips.len()
);
for expected in [
"Pick a mode, continue, or start a new game.",
"Show controls, rules, and keyboard shortcuts.",
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
"Lifetime totals: wins, streaks, fastest time, best score.",
"Browse unlocked achievements and the rewards still ahead.",
"Your level, XP progress, and sync status.",
@@ -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,
+66 -84
View File
@@ -22,7 +22,7 @@ use std::collections::HashMap;
use bevy::ecs::system::SystemParam;
use bevy::input::ButtonInput;
use bevy::input::touch::{TouchInput, TouchPhase, Touches};
use bevy::math::{Vec2, Vec3};
use bevy::math::Vec2;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
#[cfg(not(target_os = "android"))]
@@ -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,
@@ -1806,10 +1793,5 @@ pub fn find_hint(game: &GameState) -> Option<(KlondikePile, KlondikePile)> {
all_hints(game).into_iter().next()
}
// `Vec3` is referenced only via the `DRAG_Z` constant; keep the import silenced
// when the compiler can't see it used.
#[allow(dead_code)]
const _VEC3_REFERENCED: Option<Vec3> = None;
#[cfg(test)]
mod tests;
+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
// -----------------------------------------------------------------------
+16 -2
View File
@@ -343,13 +343,21 @@ fn scroll_leaderboard_panel(
}
}
/// Done click or Esc dismisses the leaderboard (Phase C dismissal
/// audit). Esc only fires when the leaderboard is the topmost modal —
/// with the display-name dialog stacked on top, that dialog owns Esc.
fn handle_leaderboard_close_button(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
close_buttons: Query<&Interaction, (With<LeaderboardCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<LeaderboardScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<LeaderboardScreen>)>,
mut closed_flag: ResMut<ClosedThisFrame>,
) {
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
let clicked = close_buttons.iter().any(|i| *i == Interaction::Pressed);
let esc =
keys.just_pressed(KeyCode::Escape) && !screens.is_empty() && other_modal_scrims.is_empty();
if !clicked && !esc {
return;
}
for entity in &screens {
@@ -888,12 +896,18 @@ fn handle_display_name_confirm(
}
/// Discards any typed text and closes the display-name editor modal.
/// Cancel click or Esc dismisses the display-name dialog without
/// saving (Phase C dismissal audit — same contract as the sync-setup
/// dialog's Cancel/Esc pair).
fn handle_display_name_cancel(
button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameCancelButton>)>,
keys: Res<ButtonInput<KeyCode>>,
screens: Query<Entity, With<DisplayNameModal>>,
mut commands: Commands,
) {
if !button_q.iter().any(|i| *i == Interaction::Pressed) {
let clicked = button_q.iter().any(|i| *i == Interaction::Pressed);
let esc = keys.just_pressed(KeyCode::Escape) && !screens.is_empty();
if !clicked && !esc {
return;
}
for entity in &screens {
+11 -4
View File
@@ -15,6 +15,7 @@ pub mod avatar_plugin;
pub mod card_animation;
pub mod card_plugin;
pub mod challenge_plugin;
pub mod contextual_tips_plugin;
pub mod core_game_plugin;
pub mod cursor_plugin;
pub mod daily_challenge_plugin;
@@ -46,6 +47,7 @@ pub mod safe_area;
mod schedule_checks;
pub mod selection_plugin;
pub mod settings_plugin;
pub mod solution_playback_plugin;
pub mod splash_plugin;
pub mod stats_plugin;
#[cfg(not(target_arch = "wasm32"))]
@@ -63,7 +65,9 @@ pub mod ui_modal;
pub mod ui_theme;
pub mod ui_tooltip;
pub mod weekly_goals_plugin;
pub mod whats_new_plugin;
pub mod win_summary_plugin;
pub mod you_hub_plugin;
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
#[cfg(not(target_arch = "wasm32"))]
@@ -81,9 +85,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, retarget_animation,
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,
@@ -92,6 +95,7 @@ pub use card_plugin::{
pub use challenge_plugin::{
CHALLENGE_UNLOCK_LEVEL, ChallengeAdvancedEvent, ChallengePlugin, challenge_progress_label,
};
pub use contextual_tips_plugin::ContextualTipsPlugin;
pub use core_game_plugin::CoreGamePlugin;
pub use cursor_plugin::CursorPlugin;
pub use daily_challenge_plugin::{
@@ -105,7 +109,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,
@@ -161,6 +165,7 @@ pub use settings_plugin::{
SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS,
};
pub use solitaire_data::SyncProvider;
pub use solution_playback_plugin::{SolutionPlayback, SolutionPlaybackPlugin, SolutionSolveTask};
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
pub use stats_plugin::{
LatestReplayPath, ReplayHistoryResource, ReplayNextButton, ReplayPrevButton,
@@ -192,6 +197,8 @@ pub use ui_modal::{
};
pub use ui_tooltip::{Tooltip, UiTooltipPlugin};
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
pub use whats_new_plugin::{WhatsNewPending, WhatsNewPlugin, WhatsNewScreen};
pub use win_summary_plugin::{
ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin, format_win_time,
};
pub use you_hub_plugin::{ActiveYouTab, YouHubPlugin, YouHubScreen, YouTab};
+21 -10
View File
@@ -313,6 +313,10 @@ fn complete_onboarding(
despawn_screen(commands, screens);
if let Some(s) = settings {
s.0.first_run_complete = true;
// A fresh install has nothing "new" to announce — stamp the
// running release so the What's-new card (Phase I) only ever
// fires after an actual upgrade.
s.0.last_seen_whats_new = crate::whats_new_plugin::current_release_version();
persist(path.map(|p| &p.0), &s.0);
}
}
@@ -382,20 +386,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,
+32
View File
@@ -71,6 +71,12 @@ struct PauseResumeButton;
#[derive(Component, Debug)]
struct PauseForfeitButton;
/// Marker on the "Show solution" secondary button on the pause modal.
/// A click resumes the game and fires `ShowSolutionRequestEvent`;
/// `solution_playback_plugin` takes it from there.
#[derive(Component, Debug)]
struct PauseSolutionButton;
/// Marker on the forfeit-confirm modal scrim.
#[derive(Component, Debug)]
pub struct ForfeitConfirmScreen;
@@ -107,6 +113,7 @@ impl Plugin for PausePlugin {
.add_message::<PauseRequestEvent>()
.add_message::<ForfeitRequestEvent>()
.add_message::<ForfeitEvent>()
.add_message::<crate::events::ShowSolutionRequestEvent>()
.add_message::<InfoToastEvent>()
.init_resource::<PausedResource>()
.add_systems(
@@ -125,6 +132,7 @@ impl Plugin for PausePlugin {
handle_pause_draw_buttons,
handle_pause_resume_button,
handle_pause_forfeit_button,
handle_pause_solution_button,
handle_forfeit_request,
handle_forfeit_confirm_buttons,
handle_forfeit_keyboard,
@@ -304,6 +312,22 @@ fn handle_pause_resume_button(
}
}
/// Translates a click on the pause modal's "Show solution" button into
/// a resume (`PauseRequestEvent` — playback can't run while paused)
/// plus a `ShowSolutionRequestEvent` for `solution_playback_plugin`.
fn handle_pause_solution_button(
interaction_query: Query<&Interaction, (Changed<Interaction>, With<PauseSolutionButton>)>,
mut pause: MessageWriter<PauseRequestEvent>,
mut solution: MessageWriter<crate::events::ShowSolutionRequestEvent>,
) {
for interaction in &interaction_query {
if *interaction == Interaction::Pressed {
pause.write(PauseRequestEvent);
solution.write(crate::events::ShowSolutionRequestEvent);
}
}
}
/// Translates a click on the pause modal's Forfeit button into a
/// `ForfeitRequestEvent` so `handle_forfeit_request` can spawn the
/// confirm modal — same code path as the `G` accelerator.
@@ -498,6 +522,14 @@ fn spawn_pause_screen(
ButtonVariant::Tertiary,
font_res,
);
spawn_modal_button(
actions,
PauseSolutionButton,
"Show solution",
None,
ButtonVariant::Secondary,
font_res,
);
spawn_modal_button(
actions,
PauseResumeButton,
+19 -102
View File
@@ -4,7 +4,6 @@
//! summary in a single scrollable panel. Spawned on the first `P` keypress and
//! despawned on the second.
use bevy::input::ButtonInput;
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
use chrono::{Duration, Local, NaiveDate};
@@ -13,23 +12,19 @@ use solitaire_data::SyncBackend;
use crate::achievement_plugin::AchievementsResource;
#[cfg(not(target_arch = "wasm32"))]
use crate::avatar_plugin::AvatarResource;
pub(crate) use crate::avatar_plugin::AvatarResource;
#[cfg(target_arch = "wasm32")]
#[derive(bevy::prelude::Resource)]
struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
pub(crate) struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
use crate::events::ToggleProfileRequestEvent;
use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource;
use crate::resources::{SyncStatus, SyncStatusResource};
use crate::settings_plugin::SettingsResource;
use crate::stats_plugin::{StatsResource, format_fastest_win, format_win_rate};
use crate::ui_modal::{
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, SPACE_1, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY,
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, Z_MODAL_PANEL,
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2,
};
/// Number of days surfaced in the daily-challenge calendar row.
@@ -60,13 +55,11 @@ pub struct DailyCalendarDot {
pub is_today: bool,
}
/// Registers the `P` key toggle for the profile overlay.
/// Registers the Profile body's scroll handling. Opening/closing moved
/// to the You hub (`you_hub_plugin`), which calls
/// [`spawn_profile_body`] for this tab's content.
pub struct ProfilePlugin;
/// Marker on the "Done" button inside the Profile modal.
#[derive(Component, Debug)]
pub struct ProfileCloseButton;
/// Marker on the scrollable body Node inside the Profile modal.
///
/// The Profile panel renders sync info, progression (incl. 14-day
@@ -87,14 +80,9 @@ impl Plugin for ProfilePlugin {
// profile-scroll system also runs cleanly under
// `MinimalPlugins` in tests.
.add_message::<MouseWheel>()
.add_systems(
Update,
(
toggle_profile_screen,
handle_profile_close_button,
scroll_profile_panel,
),
);
// Open/close/tab handling moved to `you_hub_plugin`
// (Phase E) — this plugin now owns body content + scroll.
.add_systems(Update, scroll_profile_panel);
}
}
@@ -124,70 +112,12 @@ fn scroll_profile_panel(
}
}
fn handle_profile_close_button(
mut commands: Commands,
close_buttons: Query<&Interaction, (With<ProfileCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<ProfileScreen>>,
) {
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
/// Builds the Profile tab body inside the You hub's card. All markers
/// (`ProfileScrollable`, `DailyCalendarDot`, …) are unchanged, so the
/// scroll and test queries keep working.
#[allow(clippy::too_many_arguments)]
fn toggle_profile_screen(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleProfileRequestEvent>,
settings: Option<Res<SettingsResource>>,
sync_status: Option<Res<SyncStatusResource>>,
progress: Option<Res<ProgressResource>>,
achievements: Option<Res<AchievementsResource>>,
stats: Option<Res<StatsResource>>,
font_res: Option<Res<FontResource>>,
avatar: Option<Res<AvatarResource>>,
screens: Query<Entity, With<ProfileScreen>>,
scrims: Query<(), With<ModalScrim>>,
) {
let button_clicked = requests.read().count() > 0;
let p_pressed = keys.just_pressed(KeyCode::KeyP);
let esc_pressed = keys.just_pressed(KeyCode::Escape);
let already_open = !screens.is_empty();
// P / button toggles open-or-close. Esc only ever closes — when
// Profile is layered over Home (clicking the new Home stats chip
// opens this on top), Esc must dismiss the *topmost* modal.
// Without this branch, Esc fell through to Home's cancel handler
// and closed the wrong modal.
let want_open = !already_open && (p_pressed || button_clicked);
let want_close = already_open && (p_pressed || button_clicked || esc_pressed);
if !want_open && !want_close {
return;
}
if want_open && !scrims.is_empty() {
return;
}
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else {
spawn_profile_screen(
&mut commands,
settings.as_deref(),
sync_status.as_deref(),
progress.as_deref(),
achievements.as_deref(),
stats.as_deref(),
font_res.as_deref(),
avatar.as_deref(),
);
}
}
#[allow(clippy::too_many_arguments)]
fn spawn_profile_screen(
commands: &mut Commands,
pub(crate) fn spawn_profile_body(
card: &mut ChildSpawnerCommands,
settings: Option<&SettingsResource>,
sync_status: Option<&SyncStatusResource>,
progress: Option<&ProgressResource>,
@@ -208,9 +138,7 @@ fn spawn_profile_screen(
..default()
};
let scrim = spawn_modal(commands, ProfileScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Profile", font_res);
{
// Scrollable body — the Profile panel renders sync info,
// progression (incl. a 14-day calendar), every unlocked
// achievement (up to ~18), and a stats summary, which can
@@ -472,20 +400,7 @@ fn spawn_profile_screen(
));
}
});
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
ProfileCloseButton,
"Done",
Some("P"),
ButtonVariant::Primary,
font_res,
);
});
});
// Profile is read-only — opt into click-outside-to-dismiss.
commands.entity(scrim).insert(ScrimDismissible);
}
}
/// Spawn a fixed-height vertical spacer node.
@@ -627,6 +542,7 @@ mod tests {
use crate::settings_plugin::SettingsPlugin;
use crate::stats_plugin::StatsPlugin;
use crate::table_plugin::TablePlugin;
use crate::ui_modal::ModalScrim;
fn headless_app() -> App {
let mut app = App::new();
@@ -637,7 +553,8 @@ mod tests {
.add_plugins(ProgressPlugin::headless())
.add_plugins(AchievementPlugin::headless())
.add_plugins(SettingsPlugin::headless())
.add_plugins(ProfilePlugin);
.add_plugins(ProfilePlugin)
.add_plugins(crate::you_hub_plugin::YouHubPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
+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;
@@ -1,5 +1,3 @@
#![allow(dead_code)]
use bevy::prelude::*;
use super::*;
@@ -27,960 +25,6 @@ pub(crate) struct ReplayScrubKeyHold {
pub(crate) right_held_secs: f32,
}
/// Marker on the keybind-hint footer row at the bottom edge of the
/// banner. Carries two `Text` children: a vim-style mode indicator
/// (`▌ NORMAL │ replay`) on the left and the keybind hint
/// (`[SPACE] pause/resume`) on the right. 1 px top border in
/// [`BORDER_SUBTLE`] separates it from the notch-label row above.
///
/// Surfaces the existing Space-key accelerator visually so the
/// UI-first contract from CLAUDE.md §3.3 (every player action has
/// a visible UI control) holds for keyboard accelerators too.
/// Future commits that wire ESC for stop or ← / → for scrub will
/// extend the right-hand text in lockstep — the footer always
/// reflects what's actually wired, never aspirational.
#[derive(Component, Debug)]
pub struct ReplayOverlayKeybindFooter;
/// Marker on the bottom-edge **Move Log** panel — a separate root
/// UI entity (not a child of the banner) that sits anchored to the
/// viewport's bottom edge. Carries a header (`▌ MOVE LOG · N/M`)
/// plus a row showing the most-recently-applied move.
///
/// Spawned by `spawn_overlay` alongside the banner and the
/// floating progress chip; despawned by `react_to_state_change`
/// on the same `Playing → Inactive` transition. Same lifecycle
/// pattern as `ReplayFloatingProgressChip` — a sibling root, not
/// a banner child, because it lives at a different screen anchor.
///
/// First slice of the move-log mockup at
/// `docs/ui-mockups/replay-overlay-mobile.html` § "Move Log Card".
/// Subsequent commits add prev/next rows and scrolling.
#[derive(Component, Debug)]
pub struct ReplayOverlayMoveLogPanel;
/// Marker on the move-log panel's header `Text`. Carries
/// `▌ MOVE LOG · N/M` while a replay is playing; the
/// `update_move_log_header` system repaints it as the cursor
/// advances.
#[derive(Component, Debug)]
pub struct ReplayOverlayMoveLogHeader;
/// Marker on the move-log panel's active-row `Text`. Carries the
/// most-recently-applied move's text (`47 │ waste → tableau 5`)
/// when `cursor > 0`; empty when no moves have been applied yet
/// (initial spawn) or in `Completed`/`Inactive` states. The
/// `update_move_log_active_row` system repaints it as the cursor
/// advances.
#[derive(Component, Debug)]
pub struct ReplayOverlayMoveLogActiveRow;
/// Marker on a "previous move" row above the active row.
/// `offset` is the 1-based distance backwards from the active
/// row: `offset = 1` is the move applied just before the active
/// one (e.g. cursor=47 → row reads "46 │ ..."), `offset = 2` is
/// the one before that, and so on. Up to [`MOVE_LOG_PREV_ROWS`]
/// rows render above the active row.
///
/// Empty text when there isn't enough history (`offset >= cursor`,
/// e.g. cursor=1 has no prev rows; cursor=2 has only the
/// `offset = 1` row populated).
#[derive(Component, Debug)]
pub struct ReplayOverlayMoveLogPrevRow {
/// Distance backwards from the active row (1-based).
pub offset: u8,
}
/// 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`),
/// `offset = 2` is the one after that, and so on. Up to
/// [`MOVE_LOG_NEXT_ROWS`] rows render below the active row.
///
/// Empty text when there isn't enough remaining replay
/// (`cursor + offset - 1 >= moves.len()`, e.g. cursor=99 of
/// a 100-move replay shows offset 1 but offset 2 stays empty).
#[derive(Component, Debug)]
pub struct ReplayOverlayMoveLogNextRow {
/// Distance forward from the active row (1-based).
pub offset: u8,
}
/// Marker added to every top-level entity spawned by [`spawn_overlay`].
/// `react_to_state_change` uses a single `Query<Entity, With<DespawnWithReplay>>`
/// to despawn all of them, rather than keeping a separate query per
/// entity type. Future sibling overlay surfaces just need this marker
/// at spawn time — no changes to the despawn logic required.
#[derive(Component, Debug)]
pub struct DespawnWithReplay;
/// Marker on the mini-tableau preview panel root. A right-edge-anchored
/// panel that shows a compact summary of the live game state during
/// replay: the four foundation tops and the stock / waste heads.
/// Spawned as a sibling root entity (same lifecycle pattern as
/// [`ReplayOverlayMoveLogPanel`]) at `right: 0`, `top: MINI_TABLEAU_TOP_OFFSET`.
#[derive(Component, Debug)]
pub struct ReplayMiniTableauPanel;
/// Marker on the foundations row `Text` inside the mini-tableau panel.
/// Carries `F: A♠ 7♥ 5♦ K♣` (or `--` for empty slots); repainted by
/// `update_mini_tableau` whenever [`GameStateResource`] changes.
#[derive(Component, Debug)]
pub struct ReplayMiniTableauFoundations;
/// Marker on the stock/waste row `Text` inside the mini-tableau panel.
/// Carries `STK:14 WST:7♥`; repainted by `update_mini_tableau` whenever
/// [`GameStateResource`] changes.
#[derive(Component, Debug)]
pub struct ReplayMiniTableauStockWaste;
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
/// Bevy plugin that registers every system needed to drive the replay
/// overlay's lifecycle.
///
/// The plugin is independent of [`crate::replay_playback::ReplayPlaybackPlugin`]
/// — it only reads the shared `ReplayPlaybackState` resource. Tests insert
/// the resource manually and exercise the overlay in isolation.
pub struct ReplayOverlayPlugin;
impl Plugin for ReplayOverlayPlugin {
fn build(&self, app: &mut App) {
// The systems are ordered so that, on a single frame:
// 1. The state-watcher spawns or despawns the overlay if the
// `ReplayPlaybackState` resource changed.
// 2. The completion-text update swaps the banner label when the
// state is `Completed`.
// 3. The progress-text update writes the latest "Move N of M".
// 4. The Stop-button click handler reads `Interaction::Pressed`
// and calls `stop_replay_playback` (which mutates the state).
// Putting Stop last means a click in frame N is observed by
// `react_to_state_change` in frame N+1, which then despawns the
// overlay in response — a clean state-driven loop.
// Step-button handler dispatches into the same canonical move
// / draw events that the tick loop fires. Register them
// defensively here so this plugin can run under
// `MinimalPlugins` without the playback plugin attached;
// `add_message` is idempotent so the duplicate registration
// in production (alongside `replay_playback`) is harmless.
app.init_resource::<ReplayScrubKeyHold>()
.add_message::<MoveRequestEvent>()
.add_message::<DrawRequestEvent>()
.add_message::<UndoRequestEvent>()
.add_message::<StateChangedEvent>()
.add_systems(
Update,
(
react_to_state_change,
update_banner_label,
update_progress_text,
update_floating_progress_chip,
update_scrub_fill,
update_move_log_header,
update_move_log_active_row,
update_move_log_prev_rows,
update_move_log_next_rows,
update_mini_tableau_foundations,
update_mini_tableau_stock_waste,
update_pause_button_label,
handle_pause_button,
handle_step_button,
handle_pause_keyboard,
handle_stop_keyboard,
handle_arrow_keyboard,
handle_stop_button,
)
.chain(),
);
}
}
// ---------------------------------------------------------------------------
// Spawning
// ---------------------------------------------------------------------------
/// Reads [`ReplayPlaybackState`] every time the resource changes and either
/// spawns or despawns the overlay accordingly. Treats the resource as the
/// single source of truth — the spawn / despawn decision is derived from
/// `is_playing() || is_completed()` rather than tracking previous-state
/// transitions explicitly, which keeps the system stateless.
pub(crate) fn react_to_state_change(
mut commands: Commands,
state: Res<ReplayPlaybackState>,
roots: Query<Entity, With<ReplayOverlayRoot>>,
despawnable: Query<Entity, With<DespawnWithReplay>>,
font_res: Option<Res<FontResource>>,
) {
if !state.is_changed() {
return;
}
let should_be_visible = state.is_playing() || state.is_completed();
let already_spawned = roots.iter().next().is_some();
if should_be_visible && !already_spawned {
spawn_overlay(&mut commands, font_res.as_deref(), &state);
} else if !should_be_visible && already_spawned {
// Despawn all sibling root entities in one loop — every entity
// spawned by `spawn_overlay` carries `DespawnWithReplay` for
// exactly this purpose.
for entity in &despawnable {
commands.entity(entity).despawn();
}
}
// The `should_be_visible && already_spawned` branch is a no-op here —
// the per-frame text update systems below repaint the banner label
// and progress readout in place without a respawn.
}
/// Spawns the banner — a flex-row Node anchored to the top edge of the
/// window with three children: the "▌ replay" / "▌ replay complete" label,
/// the centred progress text, and the right-aligned Stop button.
pub(crate) fn spawn_overlay(
commands: &mut Commands,
font_res: Option<&FontResource>,
state: &ReplayPlaybackState,
) {
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
// Clone for the floating chip spawn that runs *after* the
// banner's `.with_children(|banner| { ... })` closure consumes
// the original `font_handle`. Cheap — Bevy's `Handle<Font>` is
// `Arc`-backed, the clone bumps a refcount.
let font_handle_for_floating = font_handle.clone();
// Second clone for the scrub-bar label row and keybind footer
// inside the outer banner closure. The inner top-row closure
// consumes the original `font_handle` for the progress-chip
// text, so by the time the outer closure reaches the
// label-row / footer spawns the original is gone.
// `font_handle_for_labels` is `.clone()`'d (never moved) inside
// the labels closure, so it's still alive for the footer
// spawn afterwards — single shared clone covers both.
let font_handle_for_labels = font_handle.clone();
// Third clone for the move-log panel — a separate root
// entity spawned after the banner closure closes. Mirrors the
// floating-chip clone reasoning.
let font_handle_for_move_log = font_handle.clone();
// Fourth clone for the mini-tableau preview panel.
let font_handle_for_mini_tableau = font_handle.clone();
let banner_label = if state.is_completed() {
"\u{258C} replay complete" // ▌ — cursor-block prefix; matches the splash boot-screen convention.
} else {
"\u{258C} replay" // ▌
};
let progress_label = format_progress(state);
// Tableau dim layer — full-screen scrim at z = Z_REPLAY_DIM (= 54).
// Spawned first so it sits behind the banner (z=55) and move-log (z=55)
// in the UI stacking context. World-space sprites (cards, badges) are
// always below any UI node, so the dim layer darkens the entire
// gameplay scene without needing to touch card_plugin. No Interaction
// component — purely visual.
commands.spawn((
ReplayTableauDimLayer,
DespawnWithReplay,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
top: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, TABLEAU_DIM_ALPHA)),
ZIndex(Z_REPLAY_DIM),
GlobalZIndex(Z_REPLAY_DIM),
));
let banner_bg = Color::srgba(
BG_ELEVATED_HI.to_srgba().red,
BG_ELEVATED_HI.to_srgba().green,
BG_ELEVATED_HI.to_srgba().blue,
BANNER_ALPHA,
);
commands
.spawn((
ReplayOverlayRoot,
DespawnWithReplay,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
top: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Px(BANNER_HEIGHT),
// Column outer so the content row sits above the 1px
// scrub bar at the bottom edge.
flex_direction: FlexDirection::Column,
..default()
},
BackgroundColor(banner_bg),
// Pin the banner to its z layer in both the local and the
// global stacking context — `GlobalZIndex` matters because
// the overlay is a top-level Node (no parent), and Bevy 0.18
// has historically had subtle stacking-context drift here.
ZIndex(Z_REPLAY_OVERLAY),
GlobalZIndex(Z_REPLAY_OVERLAY),
))
.with_children(|banner| {
// Top row: the existing content (label / progress / Stop).
banner
.spawn(Node {
flex_grow: 1.0,
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
justify_content: JustifyContent::SpaceBetween,
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
column_gap: VAL_SPACE_4,
..default()
})
.with_children(|row| {
// Left: column with the accent "▌ replay" headline
// above and a small `GAME #YYYY-DDD` caption below.
// The caption mirrors the mockup's right-anchored
// game identifier but stays visually grouped with
// the headline so the two pieces of "this is a
// replay of game X" read as a single unit.
row.spawn(Node {
flex_direction: FlexDirection::Column,
align_items: AlignItems::FlexStart,
row_gap: Val::Px(2.0),
..default()
})
.with_children(|left| {
left.spawn((
ReplayOverlayBannerText,
Text::new(banner_label),
TextFont {
font: font_handle.clone(),
font_size: TYPE_HEADLINE,
..default()
},
TextColor(ACCENT_PRIMARY),
));
left.spawn((
ReplayOverlayGameCaption,
Text::new(format_game_caption(state).unwrap_or_default()),
TextFont {
font: font_handle.clone(),
font_size: TYPE_CAPTION,
..default()
},
TextColor(TEXT_SECONDARY),
));
});
// Centre: progress readout, wrapped in a 1 px
// ACCENT_PRIMARY-bordered chip so it reads as a
// discrete callout rather than free-floating
// text. No fill — the Terminal aesthetic gets
// depth from borders + tonal layering, not
// shadows. The marker stays on the inner Text so
// `update_progress_text` keeps working unchanged.
row.spawn((
Node {
border: UiRect::all(Val::Px(1.0)),
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
..default()
},
BorderColor::all(ACCENT_PRIMARY),
))
.with_children(|chip| {
chip.spawn((
ReplayOverlayProgressText,
Text::new(progress_label),
TextFont {
font: font_handle,
font_size: TYPE_BODY,
..default()
},
TextColor(TEXT_PRIMARY),
));
});
// Right: Stop button. Tertiary variant — the
// action is available but not the loudest element
// in the banner; the "Replay" primary accent owns
// that slot. `spawn_modal_button` gives us hover /
// press paint and focus rings for free via the
// existing `UiModalPlugin` paint system.
row.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_2,
..default()
})
.with_children(|wrap| {
// Pause / Resume label is set from the current
// state so a freshly-spawned overlay (which
// currently always starts unpaused) reads
// "Pause". `update_pause_button_label`
// repaints it whenever the state changes.
spawn_modal_button(
wrap,
ReplayPauseButton,
pause_button_label(state),
None,
ButtonVariant::Tertiary,
font_res,
);
spawn_modal_button(
wrap,
ReplayStepButton,
"Step",
None,
ButtonVariant::Tertiary,
font_res,
);
spawn_modal_button(
wrap,
ReplayStopButton,
"Stop",
None,
ButtonVariant::Tertiary,
font_res,
);
});
});
// Bottom edge: 1px-tall scrub bar. Track in `BORDER_SUBTLE`,
// fill in `ACCENT_PRIMARY`. The fill width is rewritten by
// [`update_scrub_fill`] every tick the cursor advances.
// Initial fill width matches the spawn-time progress so the
// first-frame paint already reflects state instead of
// popping from 0 → cursor on the first tick.
let initial_scrub_pct = scrub_pct(state);
let win_pct = win_move_marker_pct(state);
banner
.spawn((
Node {
width: Val::Percent(100.0),
height: Val::Px(1.0),
..default()
},
BackgroundColor(BORDER_SUBTLE),
// HC marker: bumps the 1 px track from #505050
// → #a0a0a0 under high-contrast mode. The track
// paints via BackgroundColor (it's a 1 px Node,
// not a border on a wider container) so the
// BorderColor-targeting HighContrastBorder marker
// doesn't apply — HighContrastBackground is the
// parallel primitive for this case.
HighContrastBackground::with_default(BORDER_SUBTLE),
))
.with_children(|track| {
track.spawn((
ReplayOverlayScrubFill,
Node {
width: Val::Percent(initial_scrub_pct),
height: Val::Percent(100.0),
..default()
},
BackgroundColor(ACCENT_PRIMARY),
));
// WIN MOVE marker — small green tick anchored at
// `win_move_index / total`. Spawned only when the
// active replay carries the field; older replays
// pre-dating `win_move_index` simply don't get a
// marker. Centered vertically on the 1px track via
// a 3px-tall node offset 1px above the track top so
// 1px sits above and 1px below the track line.
if let Some(pct) = win_pct {
track.spawn((
ReplayOverlayWinMoveMarker,
Node {
position_type: PositionType::Absolute,
left: Val::Percent(pct),
top: Val::Px(-1.0),
width: Val::Px(2.0),
height: Val::Px(3.0),
..default()
},
BackgroundColor(STATE_SUCCESS),
// HC bump: lime → brighter lime so the win
// marker reads clearly above the bumped
// notch ticks (BORDER_SUBTLE_HC gray) under
// high-contrast mode.
HighContrastBackground::with_hc(STATE_SUCCESS, STATE_SUCCESS_HC),
));
}
// Fixed quarter-mark notches: five 1px vertical
// ticks at 0 / 25 / 50 / 75 / 100 % that give the
// player visual anchor points without needing to
// mentally bisect the bar. Painted in
// BORDER_SUBTLE — same colour as the unfilled
// track — so visibility comes from extending past
// the 1px track height (5px tall, anchored 2px
// above the track top) rather than colour
// contrast. Spawned *after* the WIN MOVE marker
// so a notch and the marker landing on the same
// percentage paint the marker on top.
for pct in scrub_notch_positions() {
track.spawn((
ReplayOverlayScrubNotch,
Node {
position_type: PositionType::Absolute,
left: Val::Percent(pct),
top: Val::Px(-2.0),
width: Val::Px(1.0),
height: Val::Px(5.0),
..default()
},
BackgroundColor(BORDER_SUBTLE),
// Same HC-paint reasoning as the track
// above: 5 px tall × 1 px wide tick mark
// paints via BackgroundColor, so
// HighContrastBackground (not -Border) is
// the right marker.
HighContrastBackground::with_default(BORDER_SUBTLE),
));
}
});
// Third banner row: percentage labels (`0%` / `25%` /
// `50%` / `75%` / `100%`) under each scrub-bar notch.
// Sibling of (not child of) the 1px track because labels
// need their own vertical real estate (TYPE_CAPTION text
// doesn't fit inside a 1px container). Position math:
// track Node has `Val::Percent(p)` referencing the
// banner's full width; this label row also has the
// banner's full width, so labels at the same
// percentages line up vertically with their notches.
let labels = scrub_notch_labels();
let positions = scrub_notch_positions();
banner
.spawn(Node {
width: Val::Percent(100.0),
height: Val::Px(SCRUB_LABEL_ROW_HEIGHT),
position_type: PositionType::Relative,
..default()
})
.with_children(|row| {
for (i, (label, pct)) in labels.iter().zip(positions.iter()).enumerate() {
// Endpoints flush to the row's edges; middle
// three labels use the `translateX(-50%)`
// pattern for Bevy 0.18 UI: a fixed-width
// container is placed at `left: Percent(pct)`
// then shifted left by half its own width via
// `margin.left: Px(-SCRUB_LABEL_CENTER_WIDTH/2)`.
// `Justify::Center` renders the text centred
// within the container so the text's visual
// centre coincides with the notch line.
let (node, justify) = if i == 0 {
(
Node {
position_type: PositionType::Absolute,
top: Val::Px(2.0),
left: Val::Px(0.0),
..default()
},
Justify::Left,
)
} else if i == labels.len() - 1 {
(
Node {
position_type: PositionType::Absolute,
top: Val::Px(2.0),
right: Val::Px(0.0),
..default()
},
Justify::Right,
)
} else {
(
Node {
position_type: PositionType::Absolute,
top: Val::Px(2.0),
left: Val::Percent(*pct),
width: Val::Px(SCRUB_LABEL_CENTER_WIDTH),
margin: UiRect {
left: Val::Px(-SCRUB_LABEL_CENTER_WIDTH / 2.0),
..default()
},
..default()
},
Justify::Center,
)
};
row.spawn((
ReplayOverlayScrubNotchLabel,
node,
Text::new(*label),
TextLayout::new_with_justify(justify),
TextFont {
font: font_handle_for_labels.clone(),
font_size: TYPE_CAPTION,
..default()
},
// TEXT_SECONDARY keeps the subdued visual
// hierarchy (caption, not headline) while
// staying readable against BG_ELEVATED_HI.
TextColor(TEXT_SECONDARY),
));
}
});
// Fourth banner row: keybind-hint footer. Vim-style
// mode line on the left (`▌ NORMAL │ replay`), keybind
// hint on the right (`[SPACE] pause/resume`), 1px top
// border in BORDER_SUBTLE separating it from the
// labels row above. Surfaces the existing Space
// accelerator visually so CLAUDE.md §3.3's UI-first
// contract holds for keyboard accelerators too.
banner
.spawn((
ReplayOverlayKeybindFooter,
Node {
width: Val::Percent(100.0),
height: Val::Px(KEYBIND_FOOTER_HEIGHT),
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::SpaceBetween,
align_items: AlignItems::Center,
padding: UiRect::horizontal(VAL_SPACE_4),
border: UiRect::top(Val::Px(1.0)),
..default()
},
BorderColor::all(BORDER_SUBTLE),
// Marker for `apply_high_contrast_borders`: bumps
// the 1 px top border from BORDER_SUBTLE (#505050)
// to BORDER_SUBTLE_HC (#a0a0a0) when
// `Settings::high_contrast_mode` is on. Without
// this the footer reads as floating loose under
// HC because the border that visually anchors it
// to the labels row above is near-invisible.
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|footer| {
footer.spawn((
Text::new(keybind_footer_mode_text()),
TextFont {
font: font_handle_for_labels.clone(),
font_size: TYPE_CAPTION,
..default()
},
TextColor(TEXT_SECONDARY),
));
if SHOW_KEYBOARD_ACCELERATORS {
footer.spawn((
Text::new(keybind_footer_hint_text()),
TextFont {
font: font_handle_for_labels.clone(),
font_size: TYPE_CAPTION,
..default()
},
TextColor(TEXT_SECONDARY),
));
}
});
});
// Floating progress chip — a 2D world-space `Text2d` rendered
// above the destination pile of the most-recently-applied move.
// Sibling of (not child of) the banner overlay because it lives
// in world-space coordinates, not the UI tree. Spawned hidden;
// `update_floating_progress_chip` shows + positions it on the
// first frame the cursor advances past 0. Lifecycle matches
// the banner overlay — `react_to_state_change` despawns both
// when the replay state transitions back to `Inactive`.
commands.spawn((
ReplayFloatingProgressChip,
DespawnWithReplay,
Text2d::new(format_progress(state)),
TextFont {
font: font_handle_for_floating,
font_size: TYPE_BODY,
..default()
},
TextColor(TEXT_PRIMARY),
// High Z keeps the chip above every card stack
// (Z_DROP_OVERLAY = 50, Z_STOCK_BADGE = 30, regular cards
// stack to the low double digits at most).
Transform::from_xyz(0.0, 0.0, 100.0),
Visibility::Hidden,
));
// Move-log panel — a separate root UI entity anchored to the
// viewport's bottom edge. Carries a `▌ MOVE LOG · N/M` header
// plus a row showing the most-recently-applied move.
// Sibling-of-banner pattern (not a banner child) because the
// panel lives at a different screen anchor and has its own
// spawn/despawn lifecycle synced via `react_to_state_change`.
let banner_bg = Color::srgba(
BG_ELEVATED_HI.to_srgba().red,
BG_ELEVATED_HI.to_srgba().green,
BG_ELEVATED_HI.to_srgba().blue,
BANNER_ALPHA,
);
commands
.spawn((
ReplayOverlayMoveLogPanel,
DespawnWithReplay,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
bottom: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Px(MOVE_LOG_PANEL_HEIGHT),
flex_direction: FlexDirection::Column,
align_items: AlignItems::FlexStart,
justify_content: JustifyContent::Center,
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
row_gap: VAL_SPACE_1,
border: UiRect::top(Val::Px(1.0)),
..default()
},
BackgroundColor(banner_bg),
BorderColor::all(BORDER_SUBTLE),
// Same z-stack rationale as the banner — above gameplay,
// below modals.
ZIndex(Z_REPLAY_OVERLAY),
GlobalZIndex(Z_REPLAY_OVERLAY),
// HC marker so the top border bumps under HC mode.
// Without it the panel reads as floating loose because
// the border that anchors it to the gameplay area above
// is near-invisible at #505050.
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|panel| {
// Header row: `▌ MOVE LOG · N/M` in ACCENT_PRIMARY for
// the cursor-block prefix consistency with the banner
// headline.
panel.spawn((
ReplayOverlayMoveLogHeader,
Text::new(format_move_log_header(state)),
TextFont {
font: font_handle_for_move_log.clone(),
font_size: TYPE_CAPTION,
..default()
},
TextColor(ACCENT_PRIMARY),
));
// Prev rows — render above the active row in display
// order (oldest first), so the active row sits at the
// bottom of the visible window. Spawn from
// MOVE_LOG_PREV_ROWS down to 1 (offset 2, then 1) so
// the highest-offset (oldest) row is topmost in the
// panel's flex column. Each carries
// ReplayOverlayMoveLogPrevRow { offset } — the
// per-frame system reads `offset` and recomputes the
// text on cursor advance. Painted in TEXT_SECONDARY
// so the active row stands out from context rows.
for offset in (1..=MOVE_LOG_PREV_ROWS as u8).rev() {
panel.spawn((
ReplayOverlayMoveLogPrevRow { offset },
Text::new(format_kth_recent_row(state, offset as usize + 1)),
TextFont {
font: font_handle_for_move_log.clone(),
font_size: TYPE_BODY,
..default()
},
TextColor(TEXT_SECONDARY),
));
}
// Active move row. Wrapped in a Node with an
// ACCENT_PRIMARY background so the row reads as
// "current focus" — the player can scan vertically
// and the highlighted row is the move that just
// applied. Empty text at spawn time when cursor=0;
// the per-frame update system populates it as the
// cursor advances. Text colour is TEXT_PRIMARY_HC
// (near-white) for contrast against the brick-red
// background — same trick as the modal-button
// primary-variant paint.
panel
.spawn((
Node {
width: Val::Percent(100.0),
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
..default()
},
BackgroundColor(ACCENT_PRIMARY),
))
.with_children(|active| {
active.spawn((
ReplayOverlayMoveLogActiveRow,
Text::new(format_active_move_row(state)),
TextFont {
font: font_handle_for_move_log.clone(),
font_size: TYPE_BODY,
..default()
},
TextColor(TEXT_PRIMARY_HC),
));
});
// Next rows — render below the active row in display
// order (offset 1 directly below active, then offset
// 2). Same TEXT_SECONDARY de-emphasis as prev rows so
// the active row stays the focal point. Empty text
// late in the replay (when cursor + offset exceeds
// moves.len()) — the panel under-fills gracefully.
for offset in 1..=MOVE_LOG_NEXT_ROWS as u8 {
panel.spawn((
ReplayOverlayMoveLogNextRow { offset },
Text::new(format_kth_next_row(state, offset as usize)),
TextFont {
font: font_handle_for_move_log.clone(),
font_size: TYPE_BODY,
..default()
},
TextColor(TEXT_SECONDARY),
));
}
});
// Mini-tableau preview panel — right-edge anchor, just below the banner.
// Compact two-row readout: foundation tops then stock/waste head.
// Sibling-of-banner pattern (separate root entity, own spawn/despawn).
let banner_bg = Color::srgba(
BG_ELEVATED_HI.to_srgba().red,
BG_ELEVATED_HI.to_srgba().green,
BG_ELEVATED_HI.to_srgba().blue,
BANNER_ALPHA,
);
commands
.spawn((
ReplayMiniTableauPanel,
DespawnWithReplay,
Node {
position_type: PositionType::Absolute,
right: Val::Px(0.0),
top: Val::Px(MINI_TABLEAU_TOP_OFFSET),
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
flex_direction: FlexDirection::Column,
align_items: AlignItems::FlexStart,
row_gap: VAL_SPACE_1,
border: UiRect::left(Val::Px(1.0)),
..default()
},
BackgroundColor(banner_bg),
BorderColor::all(BORDER_SUBTLE),
ZIndex(Z_REPLAY_OVERLAY),
GlobalZIndex(Z_REPLAY_OVERLAY),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|panel| {
panel.spawn((
Text::new("\u{258C} BOARD"),
TextFont {
font: font_handle_for_mini_tableau.clone(),
font_size: TYPE_CAPTION,
..default()
},
TextColor(ACCENT_PRIMARY),
));
panel.spawn((
ReplayMiniTableauFoundations,
Text::new("F: -- -- -- --"),
TextFont {
font: font_handle_for_mini_tableau.clone(),
font_size: TYPE_CAPTION,
..default()
},
TextColor(TEXT_PRIMARY),
));
panel.spawn((
ReplayMiniTableauStockWaste,
Text::new("STK:-- WST:--"),
TextFont {
font: font_handle_for_mini_tableau,
font_size: TYPE_CAPTION,
..default()
},
TextColor(TEXT_SECONDARY),
));
});
}
/// Pure helper — returns the scrub-fill width as a percentage of the
/// track for the given playback state. `Completed` reads as 100 %;
/// `Inactive` and `Playing` with no progress read as 0 %.
pub(crate) fn scrub_pct(state: &ReplayPlaybackState) -> f32 {
if state.is_completed() {
return 100.0;
}
match state.progress() {
Some((_, 0)) | None => 0.0,
Some((cursor, total)) => {
let frac = (cursor as f32 / total as f32).clamp(0.0, 1.0);
frac * 100.0
}
}
}
/// Pure helper — returns the fixed scrub-bar notch positions as
/// percentages along the track. Five evenly-spaced notches at the
/// quarter-marks: `[0, 25, 50, 75, 100]`. Function (rather than
/// const) so the unit-test surface is obvious and a future
/// regression — e.g. someone simplifying to three notches — fails
/// at the helper test rather than at visual review.
pub(crate) fn scrub_notch_positions() -> [f32; 5] {
[0.0, 25.0, 50.0, 75.0, 100.0]
}
/// Pure helper — returns the percentage-label text for each notch,
/// in left-to-right order. Paired with [`scrub_notch_positions`] so
/// `labels[i]` belongs at `positions[i]`. Lifted to a function for
/// the same reason as the positions helper: a clean unit-test
/// surface that fails at a regression (e.g. someone simplifying
/// `100%` → `MAX`) rather than at visual review.
pub(crate) fn scrub_notch_labels() -> [&'static str; 5] {
["0%", "25%", "50%", "75%", "100%"]
}
/// Pure helper — returns the vim-style mode indicator text shown on
/// the left side of the keybind-hint footer row. `▌ NORMAL │ replay`
/// matches the `▌replay.tsx` motif from the splash boot-screen and
/// the screen-takeover mockup. The cursor block (`▌`) matches the
/// banner-label prefix; "NORMAL" is the vim mode (mockup parity);
/// "replay" identifies the surface.
pub(crate) fn keybind_footer_mode_text() -> &'static str {
"\u{258C} NORMAL \u{2502} replay" // ▌ NORMAL │ replay
}
/// Pure helper — returns the keybind-hint text shown on the right
/// side of the keybind-hint footer row. Lists only the keys that
/// are *actually wired* today: the Space accelerator for
/// pause/resume, the ESC accelerator for stop, and the ← / →
/// accelerators for paused single-move stepping. The footer never
/// lists unimplemented keybinds (would lie to users).
pub(crate) fn keybind_footer_hint_text() -> &'static str {
if SHOW_KEYBOARD_ACCELERATORS {
"[SPACE] pause/resume \u{00B7} [ESC] stop \u{00B7} [\u{2190}\u{2192}] step" // · separator
} else {
""
}
}
/// Pure helper — returns the WIN MOVE marker's left-edge position as
/// a percentage of the scrub track, or `None` when no marker should
/// be drawn.
///
/// `None` is returned in any of these cases:
/// - The state isn't `Playing` (no replay attached).
/// - The replay's `win_move_index` is `None` (older replay loaded
/// from disk pre-dating the field).
/// - The replay's move list is empty (shouldn't happen for real wins,
/// but guards the divide-by-zero).
///
/// The percentage clamps to `[0, 100]` so a malformed
/// `win_move_index >= total` (defensive — shouldn't happen) doesn't
/// position the marker outside the track.
pub(crate) fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option<f32> {
let ReplayPlaybackState::Playing { replay, .. } = state else {
return None;
};
let idx = replay.win_move_index?;
let total = replay.moves.len();
if total == 0 {
return None;
}
let frac = (idx as f32 / total as f32).clamp(0.0, 1.0);
Some(frac * 100.0)
}
// ---------------------------------------------------------------------------
// Playback-control button handlers
// ---------------------------------------------------------------------------
+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],
),
)
}
+9 -2
View File
@@ -82,7 +82,8 @@ impl Plugin for SafeAreaInsetsPlugin {
);
#[cfg(target_os = "android")]
app.init_resource::<android::SafeAreaPollTries>()
app.add_message::<crate::events::StateChangedEvent>()
.init_resource::<android::SafeAreaPollTries>()
.add_systems(Update, android::refresh_insets)
.add_systems(Update, android::rearm_on_resumed)
.add_systems(Update, android::refresh_surface_size);
@@ -330,11 +331,16 @@ mod android {
/// `on_window_resized` in `table_plugin` recomputes the board layout,
/// 3. re-arms the inset poller, because a screen change almost always
/// moves the system bars too — covering the "re-poll never fires"
/// hole left open in #116.
/// hole left open in #116,
/// 4. emits `StateChangedEvent` so `card_plugin`'s sync pipeline
/// re-renders every card sprite from scratch — belt-and-braces for
/// the transient tableau clip of #130, where geometry converged but
/// stale card visuals survived the relayout.
pub(super) fn refresh_surface_size(
mut frame: Local<u32>,
mut windows: Query<(Entity, &mut Window)>,
mut resize_events: MessageWriter<WindowResized>,
mut state_events: MessageWriter<crate::events::StateChangedEvent>,
mut poll: ResMut<SafeAreaPollTries>,
) {
const POLL_INTERVAL_FRAMES: u32 = 30; // ~0.5 s @ 60 fps
@@ -381,6 +387,7 @@ mod android {
width: decor_w as f32 / scale,
height: decor_h as f32 / scale,
});
state_events.write(crate::events::StateChangedEvent);
poll.0 = 0;
}
+26 -1
View File
@@ -50,15 +50,21 @@ pub(super) fn handle_volume_keys(
}
/// Opens or closes the Settings panel — `O` keyboard accelerator or
/// `ToggleSettingsRequestEvent` from the HUD Menu popover.
/// `ToggleSettingsRequestEvent` from the HUD Menu popover. Esc closes
/// too (Phase C dismissal audit), but only when Settings is the
/// topmost modal — with sync-setup or the theme store stacked on top,
/// the stacked dialog owns Esc.
pub(super) fn toggle_settings_screen(
keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleSettingsRequestEvent>,
mut screen: ResMut<SettingsScreen>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
) {
let button_clicked = requests.read().count() > 0;
if keys.just_pressed(KeyCode::KeyO) || button_clicked {
screen.0 = !screen.0;
} else if keys.just_pressed(KeyCode::Escape) && screen.0 && other_modal_scrims.is_empty() {
screen.0 = false;
}
}
@@ -418,6 +424,25 @@ pub(super) fn handle_settings_buttons(
}
}
/// Switches the active Settings tab when a chip is pressed. The
/// rebuild itself happens in `rebuild_panel_on_tab_change`, driven by
/// change detection on [`ActiveSettingsTab`].
pub(super) fn handle_tab_buttons(
interactions: Query<(&Interaction, &SettingsTabButton), Changed<Interaction>>,
mut active: ResMut<ActiveSettingsTab>,
) {
for (interaction, chip) in &interactions {
if *interaction != Interaction::Pressed {
continue;
}
// Compare before writing so re-clicking the active chip doesn't
// trip change detection and rebuild the panel for nothing.
if active.0 != chip.0 {
active.0 = chip.0;
}
}
}
/// Handles sync-related settings buttons: Sync Now, Connect, Disconnect,
/// and Delete Account. Split from `handle_settings_buttons` to stay within
/// Bevy's 16-parameter system limit.
+55 -1
View File
@@ -170,6 +170,51 @@ struct SmartDefaultSizeText;
#[derive(Component, Debug)]
struct AnalyticsEnabledText;
/// Which tab of the Settings panel is showing. Tabs replace the old
/// single-scroll five-section layout (Phase A of
/// `docs/ui-redesign-2026-07.md`) so each group fits on screen without
/// scroll-hunting.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(super) enum SettingsTab {
#[default]
Audio,
Gameplay,
Appearance,
Accessibility,
Account,
}
impl SettingsTab {
/// Every tab, in display order.
pub(super) const ALL: [Self; 5] = [
Self::Audio,
Self::Gameplay,
Self::Appearance,
Self::Accessibility,
Self::Account,
];
/// Chip label.
pub(super) fn label(self) -> &'static str {
match self {
Self::Audio => "Audio",
Self::Gameplay => "Gameplay",
Self::Appearance => "Appearance",
Self::Accessibility => "Access",
Self::Account => "Account",
}
}
}
/// The active Settings tab. Session-only — deliberately not persisted;
/// reopening Settings within a session returns to the last tab.
#[derive(Resource, Debug, Default)]
pub(super) struct ActiveSettingsTab(pub(super) SettingsTab);
/// Marker on each tab chip button in the Settings header row.
#[derive(Component, Debug)]
pub(super) struct SettingsTabButton(pub(super) SettingsTab);
/// Marks the scrollable inner card so the mouse-wheel system can target it.
#[derive(Component, Debug)]
struct SettingsPanelScrollable;
@@ -369,6 +414,7 @@ impl Plugin for SettingsPlugin {
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
.init_resource::<SettingsScreen>()
.init_resource::<SettingsScrollPos>()
.init_resource::<ActiveSettingsTab>()
.init_resource::<PendingWindowGeometry>()
.add_message::<SettingsChangedEvent>()
.add_message::<ManualSyncRequestEvent>()
@@ -423,7 +469,15 @@ impl Plugin for SettingsPlugin {
app.add_systems(
Update,
(
sync_settings_panel_visibility,
// Chained: a tab click must observe this frame's
// visibility state, and the rebuild must observe the
// click — total order avoids panel double-spawns.
(
sync_settings_panel_visibility,
handle_tab_buttons,
rebuild_panel_on_tab_change,
)
.chain(),
handle_settings_buttons,
handle_sync_buttons,
#[cfg(not(target_arch = "wasm32"))]
+153 -70
View File
@@ -98,6 +98,25 @@ fn volume_clamped_at_zero_does_not_emit_event() {
);
}
/// Opens the Settings panel showing `tab`, then waits out the
/// spawn / child-flush / marker-attach frames.
fn open_settings_on(app: &mut App, tab: SettingsTab) {
app.world_mut().resource_mut::<ActiveSettingsTab>().0 = tab;
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
app.update();
app.update();
app.update();
}
/// Switches the already-open panel to `tab` (drives the
/// `rebuild_panel_on_tab_change` path) and waits out the rebuild frames.
fn switch_settings_tab(app: &mut App, tab: SettingsTab) {
app.world_mut().resource_mut::<ActiveSettingsTab>().0 = tab;
app.update();
app.update();
app.update();
}
#[test]
fn pressing_o_toggles_settings_screen_flag() {
let mut app = headless_app();
@@ -121,6 +140,31 @@ fn pressing_o_toggles_settings_screen_flag() {
);
}
/// Esc closes the Settings panel like O / Done do (Phase C dismissal
/// audit). Esc while the panel is closed must NOT open it.
#[test]
fn escape_closes_settings_screen_flag() {
let mut app = headless_app();
press(&mut app, KeyCode::Escape);
app.update();
assert!(
!app.world().resource::<SettingsScreen>().0,
"Esc on a closed panel stays closed"
);
press(&mut app, KeyCode::KeyO);
app.update();
assert!(app.world().resource::<SettingsScreen>().0, "O opens");
press(&mut app, KeyCode::Escape);
app.update();
assert!(
!app.world().resource::<SettingsScreen>().0,
"Esc closes settings"
);
}
// cycle_unlocked pure-function tests
#[test]
fn cycle_unlocked_wraps_at_end() {
@@ -243,40 +287,40 @@ fn headless_app_with_focus() -> App {
fn settings_buttons_get_focusable_marker() {
let mut app = headless_app_with_focus();
// Open the panel.
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
app.update();
// Two more ticks: the first runs `sync_settings_panel_visibility`
// and queues the spawn commands; the second flushes them and
// runs `attach_focusable_to_settings_buttons`.
app.update();
app.update();
// Walk every tab: each rebuild must leave no bespoke
// `SettingsButton` (not `Done`, which is also a `ModalButton`)
// without a `Focusable`.
let mut total_tagged = 0usize;
for (i, tab) in SettingsTab::ALL.into_iter().enumerate() {
if i == 0 {
open_settings_on(&mut app, tab);
} else {
switch_settings_tab(&mut app, tab);
}
// Every bespoke `SettingsButton` (not `Done`, which is also a
// `ModalButton`) must carry a `Focusable`.
let untagged: Vec<&SettingsButton> = app
.world_mut()
.query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
.iter(app.world())
.collect();
let untagged: Vec<&SettingsButton> = app
.world_mut()
.query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
.iter(app.world())
.collect();
assert!(
untagged.is_empty(),
"every bespoke Settings button on tab {tab:?} must carry Focusable; missing: {untagged:?}"
);
total_tagged += app
.world_mut()
.query_filtered::<&SettingsButton, With<Focusable>>()
.iter(app.world())
.count();
}
// Floor across all five tabs so the assertion above isn't
// vacuously true (audio 4 + gameplay 8 + appearance ≥5 +
// accessibility 6 + account ≥1).
assert!(
untagged.is_empty(),
"every bespoke Settings button must carry Focusable; missing: {:?}",
untagged
);
// And there must be at least one tagged `SettingsButton` so the
// assertion above isn't vacuously true (the panel really did
// spawn).
let tagged_count = app
.world_mut()
.query_filtered::<&SettingsButton, With<Focusable>>()
.iter(app.world())
.count();
assert!(
tagged_count >= 6,
"expected the panel to spawn many bespoke buttons (volume up/down ×2, toggles ×4, sync, swatches…); got {tagged_count}"
total_tagged >= 20,
"expected the five tabs to spawn many bespoke buttons; got {total_tagged}"
);
}
@@ -290,41 +334,45 @@ fn settings_buttons_get_focusable_marker() {
fn settings_buttons_carry_tooltip() {
let mut app = headless_app_with_focus();
// Open the panel and let spawn + child-flush run.
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
app.update();
app.update();
app.update();
// Walk every tab: no bespoke `SettingsButton` (i.e. excluding
// `Done`, which is also a `ModalButton`) may be missing a
// `Tooltip` on any of them.
let mut total_tipped = 0usize;
for (i, tab) in SettingsTab::ALL.into_iter().enumerate() {
if i == 0 {
open_settings_on(&mut app, tab);
} else {
switch_settings_tab(&mut app, tab);
}
// No bespoke `SettingsButton` (i.e. excluding `Done`, which is
// also a `ModalButton`) may be missing a `Tooltip`.
let untipped: Vec<&SettingsButton> = app
.world_mut()
.query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>()
.iter(app.world())
.collect();
let untipped: Vec<&SettingsButton> = app
.world_mut()
.query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>()
.iter(app.world())
.collect();
assert!(
untipped.is_empty(),
"every bespoke Settings button on tab {tab:?} must carry Tooltip; missing: {untipped:?}"
);
total_tipped += app
.world_mut()
.query_filtered::<&SettingsButton, With<Tooltip>>()
.iter(app.world())
.count();
}
// Floor across all five tabs so the assertion above isn't
// vacuously true.
assert!(
untipped.is_empty(),
"every bespoke Settings button must carry Tooltip; missing: {:?}",
untipped
total_tipped >= 20,
"expected the five tabs to spawn many tooltipped buttons; got {total_tipped}"
);
// And there must be at least 6 tipped buttons so the assertion
// above isn't vacuously true: SFX +/, Music +/, Draw Mode,
// Anim Speed, Theme, Color-blind, Sync Now, plus at least one
// card-back and one background swatch — well over the floor.
let tipped_count = app
.world_mut()
.query_filtered::<&SettingsButton, With<Tooltip>>()
.iter(app.world())
.count();
assert!(
tipped_count >= 6,
"expected the panel to spawn many tooltipped buttons; got {tipped_count}"
);
// The loop ends on the Account tab, where with default (Local)
// settings the Connect button spawns.
// Spot-check: with default (Local) settings the Connect button
// spawns. We verify its tooltip carries the canonical microcopy.
// We verify its tooltip carries the canonical microcopy.
let connect_tip = app
.world_mut()
.query::<(&SettingsButton, &Tooltip)>()
@@ -342,10 +390,8 @@ fn settings_buttons_carry_tooltip() {
fn settings_picker_rows_get_focus_row_marker() {
let mut app = headless_app_with_focus();
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
app.update();
app.update();
app.update();
// Picker rows live on the Appearance tab.
open_settings_on(&mut app, SettingsTab::Appearance);
// Two picker rows are spawned (card-back + background); each
// must carry the FocusRow marker.
@@ -360,6 +406,45 @@ fn settings_picker_rows_get_focus_row_marker() {
);
}
/// Switching tabs despawns the old rows and spawns the new tab's —
/// exactly one panel exists afterwards (no double-spawn from the
/// visibility system racing the rebuild system).
#[test]
fn switching_tab_rebuilds_panel_with_new_rows() {
let mut app = headless_app_with_focus();
fn has_button(app: &mut App, wanted: fn(&SettingsButton) -> bool) -> bool {
app.world_mut()
.query::<&SettingsButton>()
.iter(app.world())
.any(wanted)
}
open_settings_on(&mut app, SettingsTab::Audio);
assert!(has_button(&mut app, |b| matches!(b, SettingsButton::SfxUp)));
assert!(
!has_button(&mut app, |b| matches!(b, SettingsButton::ToggleDrawMode)),
"Gameplay rows must not spawn while the Audio tab is active"
);
switch_settings_tab(&mut app, SettingsTab::Gameplay);
assert!(
!has_button(&mut app, |b| matches!(b, SettingsButton::SfxUp)),
"Audio rows must despawn when leaving the Audio tab"
);
assert!(has_button(&mut app, |b| matches!(
b,
SettingsButton::ToggleDrawMode
)));
let panel_count = app
.world_mut()
.query_filtered::<Entity, With<SettingsPanel>>()
.iter(app.world())
.count();
assert_eq!(panel_count, 1, "tab switch must not stack panels");
}
/// Test 3 of the thumbnail-picker spec: when [`ThemeRegistry`] has
/// at least one theme and the [`ThemeThumbnailCache`] holds a
/// fully-populated [`ThemeThumbnailPair`] for that theme's id, the
@@ -404,11 +489,9 @@ fn theme_picker_chip_includes_thumbnail_sprite_when_thumbnails_loaded() {
);
app.insert_resource(cache);
// Open the panel and let the spawn + child-flush systems run.
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
app.update();
app.update();
app.update();
// Open the panel on the Appearance tab (the theme picker's home)
// and let the spawn + child-flush systems run.
open_settings_on(&mut app, SettingsTab::Appearance);
// Find every ImageNode tagged with ThemeThumbnailMarker — the
// theme picker chip for "test_theme" must contribute exactly
+274 -194
View File
@@ -18,6 +18,7 @@ use crate::theme::{ImportError, import_theme, refresh_registry};
use crate::ui_focus::FocusRow;
use crate::ui_modal::{
ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header,
spawn_tab_chip,
};
use crate::ui_theme::{
BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM,
@@ -45,13 +46,34 @@ pub(super) fn spawn_settings_panel(
scroll_offset: f32,
font_res: Option<&FontResource>,
theme_overrides_back: bool,
active_tab: SettingsTab,
) {
spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Settings", font_res);
// Scrollable bodycontains every section so tall content stays
// reachable on short windows. The Done button below stays fixed
// outside the scroll so it's always one click away.
// Tab chips — one group of rows on screen at a time, no
// scroll-hunting (Phase A, docs/ui-redesign-2026-07.md).
card.spawn((
FocusRow,
Node {
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
column_gap: VAL_SPACE_2,
row_gap: VAL_SPACE_2,
margin: UiRect::bottom(VAL_SPACE_3),
..default()
},
))
.with_children(|row| {
for tab in SettingsTab::ALL {
tab_chip(row, tab, tab == active_tab, font_res);
}
});
// Scrollable body — most tabs fit without scrolling, but the
// Appearance pickers can still overflow short windows. The Done
// button below stays fixed outside the scroll so it's always one
// click away.
card.spawn((
SettingsPanelScrollable,
SettingsScrollNode,
@@ -65,200 +87,20 @@ pub(super) fn spawn_settings_panel(
..default()
},
))
.with_children(|body| {
// --- Audio ---
section_label(body, "Audio", font_res);
volume_row(
.with_children(|body| match active_tab {
SettingsTab::Audio => spawn_audio_tab(body, settings, font_res),
SettingsTab::Gameplay => spawn_gameplay_tab(body, settings, font_res),
SettingsTab::Appearance => spawn_appearance_tab(
body,
"SFX Volume",
settings.sfx_volume,
SfxVolumeText,
SettingsButton::SfxDown,
SettingsButton::SfxUp,
"Lower sound effects volume.",
"Raise sound effects volume.",
font_res,
);
volume_row(
body,
"Music Volume",
settings.music_volume,
MusicVolumeText,
SettingsButton::MusicDown,
SettingsButton::MusicUp,
"Lower music and ambience volume.",
"Raise music and ambience volume.",
font_res,
);
// --- Gameplay ---
section_label(body, "Gameplay", font_res);
toggle_row(
body,
"Draw Mode",
DrawModeText,
draw_mode_label(&settings.draw_mode),
SettingsButton::ToggleDrawMode,
"Switch between Draw 1 and Draw 3. Takes effect next deal.",
font_res,
);
toggle_row(
body,
"Winnable deals only",
WinnableDealsOnlyText,
winnable_deals_only_label(settings.winnable_deals_only),
SettingsButton::ToggleWinnableDealsOnly,
"When on, fresh Classic deals are filtered through a solver \
(may take a moment when on).",
font_res,
);
toggle_row(
body,
"Anim Speed",
AnimSpeedText,
anim_speed_label(&settings.animation_speed),
SettingsButton::CycleAnimSpeed,
"Cycle animation speed: Normal, Fast, Instant.",
font_res,
);
tooltip_delay_row(
body,
settings.tooltip_delay_secs,
font_res,
);
time_bonus_multiplier_row(
body,
settings.time_bonus_multiplier,
font_res,
);
replay_move_interval_row(
body,
settings.replay_move_interval_secs,
font_res,
);
toggle_row(
body,
"Smart window size",
SmartDefaultSizeText,
smart_default_size_label(!settings.disable_smart_default_size),
SettingsButton::ToggleSmartDefaultSize,
"When ON, fresh launches resize the window to ~70 % of the \
monitor. OFF pins the 1280\u{00D7}800 baseline. Saved \
window size always wins.",
font_res,
);
// --- Cosmetic ---
section_label(body, "Cosmetic", font_res);
toggle_row(
body,
"Theme",
ThemeText,
theme_label(&settings.theme),
SettingsButton::ToggleTheme,
"Cycle felt color: Green, Blue, Dark.",
font_res,
);
toggle_row(
body,
"Color-blind Mode",
ColorBlindText,
color_blind_label(settings.color_blind_mode),
SettingsButton::ToggleColorBlind,
"Show shape glyphs alongside suit colors. Suit-blind friendly.",
font_res,
);
toggle_row(
body,
"High Contrast",
HighContrastText,
on_off_label(settings.high_contrast_mode),
SettingsButton::ToggleHighContrast,
"Boosts foreground + suit-red glyphs to higher-luminance variants for low-vision readers and low-quality displays.",
font_res,
);
toggle_row(
body,
"Reduce Motion",
ReduceMotionText,
on_off_label(settings.reduce_motion_mode),
SettingsButton::ToggleReduceMotion,
"Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.",
font_res,
);
toggle_row(
body,
"Touch Input Mode",
TouchInputModeText,
touch_input_mode_label(&settings.touch_input_mode),
SettingsButton::ToggleTouchInputMode,
"One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.",
font_res,
);
if theme_overrides_back {
// The active theme provides its own back; the legacy
// picker has no visible effect, so we replace its
// swatch row with an informational caption. The
// player's `selected_card_back` value still
// round-trips through `settings.json` — the moment
// they switch to a theme without a back, the picker
// re-appears with their previous choice intact.
picker_row_overridden_by_theme(body, "Card Back", font_res);
} else {
picker_row(
body,
"Card Back",
unlocked_card_backs,
settings.selected_card_back,
SettingsButton::SelectCardBack,
"Choose your deck art. New backs unlock at higher levels.",
font_res,
);
}
picker_row(
body,
"Background",
settings,
unlocked_card_backs,
unlocked_backgrounds,
settings.selected_background,
SettingsButton::SelectBackground,
"Choose your felt art. New felts unlock at higher levels.",
themes,
theme_overrides_back,
font_res,
);
// Card-art theme picker — only renders when the registry has
// entries (production: always; tests: only when
// ThemeRegistryPlugin is installed).
if !themes.is_empty() {
theme_picker_row(
body,
"Card Theme",
themes,
&settings.selected_theme_id,
"Choose card-face artwork. Imported themes appear here.",
font_res,
);
}
#[cfg(not(target_arch = "wasm32"))]
import_themes_row(body, font_res);
#[cfg(not(target_arch = "wasm32"))]
theme_store_row(body, font_res);
// --- Privacy (only shown when a Matomo URL is configured) ---
if settings.matomo_url.is_some() {
section_label(body, "Privacy", font_res);
toggle_row(
body,
"Share usage data",
AnalyticsEnabledText,
on_off_label(settings.analytics_enabled),
SettingsButton::ToggleAnalytics,
"Sends anonymous game events to Matomo for aggregate analytics.",
font_res,
);
}
// --- Sync ---
section_label(body, "Sync", font_res);
sync_row(body, sync_status, &settings.sync_backend, font_res);
),
SettingsTab::Accessibility => spawn_accessibility_tab(body, settings, font_res),
SettingsTab::Account => spawn_account_tab(body, settings, sync_status, font_res),
});
// Done is the only action — primary so the player always knows
@@ -276,6 +118,244 @@ pub(super) fn spawn_settings_panel(
});
}
/// One Settings tab chip — thin wrapper over the shared
/// [`spawn_tab_chip`] widget so Settings and the You hub stay visually
/// identical.
fn tab_chip(
parent: &mut ChildSpawnerCommands,
tab: SettingsTab,
active: bool,
font_res: Option<&FontResource>,
) {
spawn_tab_chip(
parent,
SettingsTabButton(tab),
tab.label(),
active,
font_res,
);
}
/// Audio tab: the two volume rows.
fn spawn_audio_tab(
body: &mut ChildSpawnerCommands,
settings: &Settings,
font_res: Option<&FontResource>,
) {
volume_row(
body,
"SFX Volume",
settings.sfx_volume,
SfxVolumeText,
SettingsButton::SfxDown,
SettingsButton::SfxUp,
"Lower sound effects volume.",
"Raise sound effects volume.",
font_res,
);
volume_row(
body,
"Music Volume",
settings.music_volume,
MusicVolumeText,
SettingsButton::MusicDown,
SettingsButton::MusicUp,
"Lower music and ambience volume.",
"Raise music and ambience volume.",
font_res,
);
}
/// Gameplay tab: rules and pacing.
fn spawn_gameplay_tab(
body: &mut ChildSpawnerCommands,
settings: &Settings,
font_res: Option<&FontResource>,
) {
toggle_row(
body,
"Draw Mode",
DrawModeText,
draw_mode_label(&settings.draw_mode),
SettingsButton::ToggleDrawMode,
"Switch between Draw 1 and Draw 3. Takes effect next deal.",
font_res,
);
toggle_row(
body,
"Winnable deals only",
WinnableDealsOnlyText,
winnable_deals_only_label(settings.winnable_deals_only),
SettingsButton::ToggleWinnableDealsOnly,
"When on, fresh Classic deals are filtered through a solver \
(may take a moment when on).",
font_res,
);
toggle_row(
body,
"Anim Speed",
AnimSpeedText,
anim_speed_label(&settings.animation_speed),
SettingsButton::CycleAnimSpeed,
"Cycle animation speed: Normal, Fast, Instant.",
font_res,
);
time_bonus_multiplier_row(body, settings.time_bonus_multiplier, font_res);
replay_move_interval_row(body, settings.replay_move_interval_secs, font_res);
toggle_row(
body,
"Smart window size",
SmartDefaultSizeText,
smart_default_size_label(!settings.disable_smart_default_size),
SettingsButton::ToggleSmartDefaultSize,
"When ON, fresh launches resize the window to ~70 % of the \
monitor. OFF pins the 1280\u{00D7}800 baseline. Saved \
window size always wins.",
font_res,
);
}
/// Appearance tab: felt, deck art, backgrounds, and the theme system.
#[allow(clippy::too_many_arguments)]
fn spawn_appearance_tab(
body: &mut ChildSpawnerCommands,
settings: &Settings,
unlocked_card_backs: &[usize],
unlocked_backgrounds: &[usize],
themes: &[ThemePickerEntry],
theme_overrides_back: bool,
font_res: Option<&FontResource>,
) {
toggle_row(
body,
"Theme",
ThemeText,
theme_label(&settings.theme),
SettingsButton::ToggleTheme,
"Cycle felt color: Green, Blue, Dark.",
font_res,
);
if theme_overrides_back {
// The active theme provides its own back; the legacy
// picker has no visible effect, so we replace its
// swatch row with an informational caption. The
// player's `selected_card_back` value still
// round-trips through `settings.json` — the moment
// they switch to a theme without a back, the picker
// re-appears with their previous choice intact.
picker_row_overridden_by_theme(body, "Card Back", font_res);
} else {
picker_row(
body,
"Card Back",
unlocked_card_backs,
settings.selected_card_back,
SettingsButton::SelectCardBack,
"Choose your deck art. New backs unlock at higher levels.",
font_res,
);
}
picker_row(
body,
"Background",
unlocked_backgrounds,
settings.selected_background,
SettingsButton::SelectBackground,
"Choose your felt art. New felts unlock at higher levels.",
font_res,
);
// Card-art theme picker — only renders when the registry has
// entries (production: always; tests: only when
// ThemeRegistryPlugin is installed).
if !themes.is_empty() {
theme_picker_row(
body,
"Card Theme",
themes,
&settings.selected_theme_id,
"Choose card-face artwork. Imported themes appear here.",
font_res,
);
}
#[cfg(not(target_arch = "wasm32"))]
import_themes_row(body, font_res);
#[cfg(not(target_arch = "wasm32"))]
theme_store_row(body, font_res);
}
/// Accessibility tab: perception and input assists, gathered from the
/// old Gameplay/Cosmetic sections so they're findable in one place.
fn spawn_accessibility_tab(
body: &mut ChildSpawnerCommands,
settings: &Settings,
font_res: Option<&FontResource>,
) {
toggle_row(
body,
"Color-blind Mode",
ColorBlindText,
color_blind_label(settings.color_blind_mode),
SettingsButton::ToggleColorBlind,
"Show shape glyphs alongside suit colors. Suit-blind friendly.",
font_res,
);
toggle_row(
body,
"High Contrast",
HighContrastText,
on_off_label(settings.high_contrast_mode),
SettingsButton::ToggleHighContrast,
"Boosts foreground + suit-red glyphs to higher-luminance variants for low-vision readers and low-quality displays.",
font_res,
);
toggle_row(
body,
"Reduce Motion",
ReduceMotionText,
on_off_label(settings.reduce_motion_mode),
SettingsButton::ToggleReduceMotion,
"Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.",
font_res,
);
toggle_row(
body,
"Touch Input Mode",
TouchInputModeText,
touch_input_mode_label(&settings.touch_input_mode),
SettingsButton::ToggleTouchInputMode,
"One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.",
font_res,
);
tooltip_delay_row(body, settings.tooltip_delay_secs, font_res);
}
/// Account tab: privacy opt-in (when analytics is configured) and the
/// sync backend controls.
fn spawn_account_tab(
body: &mut ChildSpawnerCommands,
settings: &Settings,
sync_status: &str,
font_res: Option<&FontResource>,
) {
// --- Privacy (only shown when a Matomo URL is configured) ---
if settings.matomo_url.is_some() {
section_label(body, "Privacy", font_res);
toggle_row(
body,
"Share usage data",
AnalyticsEnabledText,
on_off_label(settings.analytics_enabled),
SettingsButton::ToggleAnalytics,
"Sends anonymous game events to Matomo for aggregate analytics.",
font_res,
);
}
// --- Sync ---
section_label(body, "Sync", font_res);
sync_row(body, sync_status, &settings.sync_backend, font_res);
}
/// Section divider — small lavender label inside the scrollable body.
pub(super) fn section_label(
parent: &mut ChildSpawnerCommands,
+119 -51
View File
@@ -17,6 +17,7 @@ use crate::ui_theme::{BORDER_SUBTLE_HC, HighContrastBackground, HighContrastBord
#[allow(clippy::too_many_arguments)]
pub(super) fn sync_settings_panel_visibility(
screen: Res<SettingsScreen>,
active_tab: Res<ActiveSettingsTab>,
panels: Query<Entity, With<SettingsPanel>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
scroll_nodes: Query<&ScrollPosition, With<SettingsScrollNode>>,
@@ -35,60 +36,17 @@ pub(super) fn sync_settings_panel_visibility(
}
if screen.0 {
if panels.is_empty() && other_modal_scrims.is_empty() {
let status_label = sync_status.map_or_else(
|| "Status: local only".to_string(),
|s| sync_status_label(&s.0),
);
let unlocked_backs = progress
.as_ref()
.map_or(&[0][..], |p| p.0.unlocked_card_backs.as_slice());
let unlocked_bgs = progress
.as_ref()
.map_or(&[0][..], |p| p.0.unlocked_backgrounds.as_slice());
// Snapshot themes by id, display_name and (optional)
// thumbnail pair so spawn_settings_panel doesn't have to
// know about the registry / cache shapes. Empty when
// ThemeRegistryPlugin isn't installed (tests under
// MinimalPlugins) — the picker row simply won't render.
// Missing thumbnails (cache not ready, or partial user
// theme) leave `thumbnails: None` so the chip renders its
// plain-text fallback instead of a broken sprite.
let themes: Vec<ThemePickerEntry> = theme_registry
.as_deref()
.map(|r| {
r.iter()
.map(|e| ThemePickerEntry {
id: e.id.clone(),
display_name: e.display_name.clone(),
thumbnails: theme_thumbs
.as_deref()
.and_then(|c| c.get(&e.id))
.filter(|p| p.is_fully_populated())
.cloned(),
})
.collect()
})
.unwrap_or_default();
// The active card-art theme can supply its own back image —
// see `card_plugin::CardImageSet::theme_back`. When that is
// populated the legacy "Card Back" picker has no visible
// effect, so we render it muted with an explanatory caption
// rather than letting the player click swatches that do
// nothing. Absent under `MinimalPlugins`; treated as
// "no override" in that case.
let theme_overrides_back = card_images
.as_ref()
.is_some_and(|cs| cs.theme_back.is_some());
spawn_settings_panel(
build_panel(
&mut commands,
&settings.0,
&status_label,
unlocked_backs,
unlocked_bgs,
&themes,
scroll_pos.0,
sync_status.as_deref(),
progress.as_deref(),
font_res.as_deref(),
theme_overrides_back,
theme_registry.as_deref(),
theme_thumbs.as_deref(),
card_images.as_deref(),
scroll_pos.0,
active_tab.0,
);
}
} else {
@@ -102,6 +60,116 @@ pub(super) fn sync_settings_panel_visibility(
}
}
/// Gathers the spawn context (sync label, unlocks, theme snapshot,
/// back-override flag) and spawns the panel showing `active_tab`.
/// Shared by [`sync_settings_panel_visibility`] (open) and
/// [`rebuild_panel_on_tab_change`] (tab switch).
#[allow(clippy::too_many_arguments)]
fn build_panel(
commands: &mut Commands,
settings: &Settings,
sync_status: Option<&SyncStatusResource>,
progress: Option<&ProgressResource>,
font_res: Option<&FontResource>,
theme_registry: Option<&crate::theme::ThemeRegistry>,
theme_thumbs: Option<&ThemeThumbnailCache>,
card_images: Option<&crate::card_plugin::CardImageSet>,
scroll_offset: f32,
active_tab: SettingsTab,
) {
let status_label = sync_status.map_or_else(
|| "Status: local only".to_string(),
|s| sync_status_label(&s.0),
);
let unlocked_backs = progress.map_or(&[0][..], |p| p.0.unlocked_card_backs.as_slice());
let unlocked_bgs = progress.map_or(&[0][..], |p| p.0.unlocked_backgrounds.as_slice());
// Snapshot themes by id, display_name and (optional)
// thumbnail pair so spawn_settings_panel doesn't have to
// know about the registry / cache shapes. Empty when
// ThemeRegistryPlugin isn't installed (tests under
// MinimalPlugins) — the picker row simply won't render.
// Missing thumbnails (cache not ready, or partial user
// theme) leave `thumbnails: None` so the chip renders its
// plain-text fallback instead of a broken sprite.
let themes: Vec<ThemePickerEntry> = theme_registry
.map(|r| {
r.iter()
.map(|e| ThemePickerEntry {
id: e.id.clone(),
display_name: e.display_name.clone(),
thumbnails: theme_thumbs
.and_then(|c| c.get(&e.id))
.filter(|p| p.is_fully_populated())
.cloned(),
})
.collect()
})
.unwrap_or_default();
// The active card-art theme can supply its own back image —
// see `card_plugin::CardImageSet::theme_back`. When that is
// populated the legacy "Card Back" picker has no visible
// effect, so we render it muted with an explanatory caption
// rather than letting the player click swatches that do
// nothing. Absent under `MinimalPlugins`; treated as
// "no override" in that case.
let theme_overrides_back = card_images.is_some_and(|cs| cs.theme_back.is_some());
spawn_settings_panel(
commands,
settings,
&status_label,
unlocked_backs,
unlocked_bgs,
&themes,
scroll_offset,
font_res,
theme_overrides_back,
active_tab,
);
}
/// Rebuilds the open panel when [`ActiveSettingsTab`] changes (tab chip
/// clicked). No-op while the panel is closed — the next open reads the
/// resource directly.
#[allow(clippy::too_many_arguments)]
pub(super) fn rebuild_panel_on_tab_change(
active_tab: Res<ActiveSettingsTab>,
panels: Query<Entity, With<SettingsPanel>>,
mut scroll_pos: ResMut<SettingsScrollPos>,
mut commands: Commands,
settings: Res<SettingsResource>,
sync_status: Option<Res<SyncStatusResource>>,
progress: Option<Res<ProgressResource>>,
font_res: Option<Res<FontResource>>,
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
theme_thumbs: Option<Res<ThemeThumbnailCache>>,
card_images: Option<Res<crate::card_plugin::CardImageSet>>,
) {
if !active_tab.is_changed() || active_tab.is_added() {
return;
}
if panels.is_empty() {
return;
}
for entity in &panels {
commands.entity(entity).despawn();
}
// A fresh tab starts at the top; per-tab scroll memory isn't worth
// the bookkeeping now that no tab needs much scrolling.
scroll_pos.0 = 0.0;
build_panel(
&mut commands,
&settings.0,
sync_status.as_deref(),
progress.as_deref(),
font_res.as_deref(),
theme_registry.as_deref(),
theme_thumbs.as_deref(),
card_images.as_deref(),
0.0,
active_tab.0,
);
}
/// Keeps the sync-status text node current while the panel is open.
pub(super) fn update_sync_status_text(
sync_status: Option<Res<SyncStatusResource>>,
@@ -0,0 +1,414 @@
//! "Show solution" — solve the current deal off-thread and auto-play
//! the winning line through the normal move pipeline.
//!
//! The pause menu's "Show solution" button fires
//! [`crate::events::ShowSolutionRequestEvent`]. This plugin snapshots
//! the live [`GameState`], runs [`GameState::winning_line`] on
//! [`AsyncComputeTaskPool`] (the solver plus `clean_solution` can take
//! seconds — §2.4 never block the main thread), then steps the returned
//! instructions on a cadence, one `MoveRequestEvent` /
//! `DrawRequestEvent` per tick — the same events player input produces,
//! so animations, scoring, undo history, and win detection all behave
//! exactly as if the player made the moves.
//!
//! Playback cancels on Esc, on pause, on undo / new-game requests, and
//! on any rejected move (which is what a player interfering mid-line
//! produces — their move diverges the state, the next scripted step
//! becomes illegal, and the rejection stops the run cleanly).
use std::collections::VecDeque;
use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_core::game_state::GameState;
use solitaire_core::{
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction,
};
use crate::events::{
DrawRequestEvent, InfoToastEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent,
ShowSolutionRequestEvent, UndoRequestEvent,
};
use crate::game_plugin::GameMutation;
use crate::pause_plugin::PausedResource;
use crate::resources::GameStateResource;
/// Seconds between scripted moves — slow enough to follow, fast enough
/// not to drag on a 100-move line.
const STEP_INTERVAL_SECS: f32 = 0.45;
/// Initial delay before the first scripted move, giving the pause modal
/// time to close and the player a beat to see what's happening.
const FIRST_STEP_DELAY_SECS: f32 = 0.9;
/// In-flight solver task plus the `move_count` snapshot used to detect
/// a stale result (player moved while the solver ran). Mirrors
/// `PendingHintTask`.
#[derive(Resource, Default)]
pub struct SolutionSolveTask {
inner: Option<(u32, Task<SolveTaskOutput>)>,
}
/// What the solver task carries back to the main thread.
enum SolveTaskOutput {
Line(Vec<KlondikeInstruction>),
Unwinnable,
Inconclusive,
}
/// Queue of instructions currently being auto-played, or empty when no
/// playback is active. HUD/UI may read `is_active` to badge the state.
#[derive(Resource, Default)]
pub struct SolutionPlayback {
queue: VecDeque<KlondikeInstruction>,
cooldown: f32,
}
impl SolutionPlayback {
/// `true` while a solution line is being auto-played.
pub fn is_active(&self) -> bool {
!self.queue.is_empty()
}
fn stop(&mut self) {
self.queue.clear();
}
}
/// Bevy plugin for the Show-solution flow. See the module docs.
pub struct SolutionPlaybackPlugin;
impl Plugin for SolutionPlaybackPlugin {
fn build(&self, app: &mut App) {
// add_message is idempotent — GamePlugin registers most of these
// too, but this plugin must also boot standalone under
// MinimalPlugins in tests.
app.init_resource::<SolutionSolveTask>()
.init_resource::<SolutionPlayback>()
.add_message::<ShowSolutionRequestEvent>()
.add_message::<InfoToastEvent>()
.add_message::<MoveRequestEvent>()
.add_message::<DrawRequestEvent>()
.add_message::<MoveRejectedEvent>()
.add_message::<UndoRequestEvent>()
.add_message::<NewGameRequestEvent>()
.add_systems(
Update,
(
handle_show_solution_request,
poll_solution_task,
cancel_playback_on_interrupt,
drive_solution_playback,
)
.chain()
.before(GameMutation),
);
}
}
/// Starts a solver task from the live game state. A repeat request
/// while one is already in flight (or playback is running) is ignored
/// — the button is idempotent, not a queue.
fn handle_show_solution_request(
mut requests: MessageReader<ShowSolutionRequestEvent>,
game: Option<Res<GameStateResource>>,
mut task: ResMut<SolutionSolveTask>,
playback: Res<SolutionPlayback>,
mut toast: MessageWriter<InfoToastEvent>,
) {
if requests.is_empty() {
return;
}
requests.clear();
if task.inner.is_some() || playback.is_active() {
return;
}
let Some(game) = game else { return };
if game.0.is_won() {
return;
}
toast.write(InfoToastEvent("Searching for a solution…".to_string()));
let snapshot: GameState = game.0.clone();
let move_count = snapshot.move_count();
let handle = AsyncComputeTaskPool::get().spawn(async move {
match snapshot.winning_line(DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET) {
Ok(Some(line)) => SolveTaskOutput::Line(line),
Ok(None) => SolveTaskOutput::Unwinnable,
Err(_) => SolveTaskOutput::Inconclusive,
}
});
task.inner = Some((move_count, handle));
}
/// Polls the solver; on completion either starts playback or explains
/// why there is nothing to play. A result computed for a position the
/// player has since moved past is discarded silently.
fn poll_solution_task(
mut task: ResMut<SolutionSolveTask>,
game: Option<Res<GameStateResource>>,
mut playback: ResMut<SolutionPlayback>,
mut toast: MessageWriter<InfoToastEvent>,
) {
let Some((move_count_at_spawn, handle)) = task.inner.as_mut() else {
return;
};
let Some(output) = future::block_on(future::poll_once(handle)) else {
return;
};
let move_count_at_spawn = *move_count_at_spawn;
task.inner = None;
let Some(game) = game else { return };
if game.0.move_count() != move_count_at_spawn {
return; // Stale — the board moved while we were solving.
}
match output {
SolveTaskOutput::Line(line) if line.is_empty() => {}
SolveTaskOutput::Line(line) => {
toast.write(InfoToastEvent(format!(
"Solution found — playing {} moves. Press Esc to stop.",
line.len()
)));
playback.queue = line.into();
playback.cooldown = FIRST_STEP_DELAY_SECS;
}
SolveTaskOutput::Unwinnable => {
toast.write(InfoToastEvent(
"No winning line exists from this position.".to_string(),
));
}
SolveTaskOutput::Inconclusive => {
toast.write(InfoToastEvent(
"Couldn't find a solution within the search budget.".to_string(),
));
}
}
}
/// Stops playback on Esc, pause, undo / new-game requests, or a
/// rejected move (the signature of the player diverging the board
/// mid-line). Runs before `drive_solution_playback` so a cancel takes
/// effect without one extra scripted move slipping out.
fn cancel_playback_on_interrupt(
mut playback: ResMut<SolutionPlayback>,
keys: Option<Res<ButtonInput<KeyCode>>>,
paused: Option<Res<PausedResource>>,
mut rejected: MessageReader<MoveRejectedEvent>,
mut undos: MessageReader<UndoRequestEvent>,
mut new_games: MessageReader<NewGameRequestEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
if !playback.is_active() {
rejected.clear();
undos.clear();
new_games.clear();
return;
}
let esc = keys.is_some_and(|k| k.just_pressed(KeyCode::Escape));
let interrupted = esc
|| paused.is_some_and(|p| p.0)
|| rejected.read().next().is_some()
|| undos.read().next().is_some()
|| new_games.read().next().is_some();
if interrupted {
playback.stop();
toast.write(InfoToastEvent("Solution playback stopped.".to_string()));
}
}
/// Emits the next scripted instruction every [`STEP_INTERVAL_SECS`]
/// while playback is active, translated to the same request events
/// player input produces. Instructions that no longer decode against
/// the live state stop the run instead of guessing.
fn drive_solution_playback(
mut playback: ResMut<SolutionPlayback>,
game: Option<Res<GameStateResource>>,
time: Res<Time>,
mut moves: MessageWriter<MoveRequestEvent>,
mut draws: MessageWriter<DrawRequestEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
if !playback.is_active() {
return;
}
let Some(game) = game else {
playback.stop();
return;
};
if game.0.is_won() {
playback.stop();
return;
}
playback.cooldown -= time.delta_secs();
if playback.cooldown > 0.0 {
return;
}
playback.cooldown = STEP_INTERVAL_SECS;
let Some(instruction) = playback.queue.pop_front() else {
return;
};
match instruction {
KlondikeInstruction::RotateStock => {
draws.write(DrawRequestEvent);
}
other => {
// Decode against the LIVE state — tableau run lengths depend on
// the current face-up counts, so this must happen at step time,
// not at solve time.
let Some((from, to, count)) = game.0.instruction_to_piles(other) else {
playback.stop();
toast.write(InfoToastEvent("Solution playback stopped.".to_string()));
return;
};
moves.write(MoveRequestEvent { from, to, count });
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin;
use bevy::ecs::message::Messages;
use solitaire_core::DrawStockConfig;
/// Seed proven Winnable at 5k budgets by the core
/// `budget_is_passed_through_not_clamped` test. Solved here under
/// standard rules (no take-from-foundation) to match that baseline.
const WINNABLE_SEED: u64 = 0xD1FF_0000_0000_0012;
fn winnable_state() -> GameState {
let mut game = GameState::new(WINNABLE_SEED, DrawStockConfig::DrawOne);
game.take_from_foundation = false;
game
}
/// Full pipeline: GamePlugin consumes the Move/Draw requests the
/// playback driver emits, exactly as in production.
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(SolutionPlaybackPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
fn request_solution(app: &mut App) {
app.world_mut()
.resource_mut::<Messages<ShowSolutionRequestEvent>>()
.write(ShowSolutionRequestEvent);
}
/// Pump updates until the solver task resolves (wall-clock bounded,
/// mirroring `winnable_solver_emits_hint_after_async_completes`).
fn pump_until_solved(app: &mut App) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while app.world().resource::<SolutionSolveTask>().inner.is_some() {
app.update();
std::thread::yield_now();
if std::time::Instant::now() >= deadline {
break;
}
}
assert!(
app.world().resource::<SolutionSolveTask>().inner.is_none(),
"solver task should have completed within 30 s wall-clock",
);
}
#[test]
fn request_solves_and_arms_playback() {
let mut app = headless_app();
app.insert_resource(GameStateResource(winnable_state()));
request_solution(&mut app);
app.update();
assert!(
app.world().resource::<SolutionSolveTask>().inner.is_some(),
"request must spawn a solver task",
);
pump_until_solved(&mut app);
assert!(
app.world().resource::<SolutionPlayback>().is_active(),
"a winnable position must arm playback",
);
}
#[test]
fn playback_reaches_win_through_normal_pipeline() {
let mut app = headless_app();
app.insert_resource(GameStateResource(winnable_state()));
request_solution(&mut app);
app.update();
pump_until_solved(&mut app);
assert!(app.world().resource::<SolutionPlayback>().is_active());
// Force each step instead of waiting out the real cadence; a line
// is at most a few hundred instructions.
for _ in 0..600 {
app.world_mut().resource_mut::<SolutionPlayback>().cooldown = 0.0;
app.update();
if app.world().resource::<GameStateResource>().0.is_won() {
break;
}
}
assert!(
app.world().resource::<GameStateResource>().0.is_won(),
"auto-played line must drive the real game to a win",
);
assert!(
!app.world().resource::<SolutionPlayback>().is_active(),
"playback must deactivate once the game is won",
);
}
#[test]
fn escape_cancels_playback() {
let mut app = headless_app();
app.insert_resource(GameStateResource(winnable_state()));
app.world_mut()
.resource_mut::<SolutionPlayback>()
.queue
.push_back(KlondikeInstruction::RotateStock);
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::Escape);
app.update();
assert!(
!app.world().resource::<SolutionPlayback>().is_active(),
"Esc must stop playback",
);
}
#[test]
fn repeat_request_is_ignored_while_active() {
let mut app = headless_app();
app.insert_resource(GameStateResource(winnable_state()));
app.world_mut()
.resource_mut::<SolutionPlayback>()
.queue
.push_back(KlondikeInstruction::RotateStock);
request_solution(&mut app);
app.update();
assert!(
app.world().resource::<SolutionSolveTask>().inner.is_none(),
"a request during active playback must not spawn a solver task",
);
}
}
+95 -107
View File
@@ -8,7 +8,6 @@
use std::path::PathBuf;
use bevy::input::ButtonInput;
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
use solitaire_data::{
@@ -25,17 +24,13 @@ use crate::events::{
use crate::font_plugin::FontResource;
use crate::game_plugin::GameMutation;
use crate::platform::ClipboardBackendResource;
use crate::progress_plugin::ProgressResource;
use crate::resources::GameStateResource;
use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_modal::{
ButtonVariant, ModalButton, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header,
};
use crate::ui_modal::{ButtonVariant, ModalButton, spawn_modal_button};
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO,
STATE_WARNING, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
};
/// Bevy resource wrapping the current stats.
@@ -225,8 +220,9 @@ impl Plugin for StatsPlugin {
.before(GameMutation)
.before(update_stats_on_new_game),
)
.add_systems(Update, toggle_stats_screen.after(GameMutation))
.add_systems(Update, handle_stats_close_button)
// Open/close/tab handling moved to `you_hub_plugin`
// (Phase E) — this plugin now owns body content, replay
// selector behavior, and scroll.
.add_systems(Update, refresh_replay_history_on_win.after(GameMutation))
.add_systems(Update, handle_watch_replay_button)
.add_systems(Update, handle_copy_share_link_button)
@@ -632,70 +628,17 @@ fn handle_forfeit(
}
}
/// Marker on the "Done" button inside the Stats modal. Click despawns
/// the overlay; `S` keyboard shortcut toggles it the same way.
#[derive(Component, Debug)]
pub struct StatsCloseButton;
#[allow(clippy::too_many_arguments)]
fn toggle_stats_screen(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleStatsRequestEvent>,
stats: Res<StatsResource>,
progress: Option<Res<ProgressResource>>,
time_attack: Option<Res<TimeAttackResource>>,
font_res: Option<Res<FontResource>>,
latest_replay: Res<ReplayHistoryResource>,
selected_index: Res<SelectedReplayIndex>,
screens: Query<Entity, With<StatsScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<StatsScreen>)>,
) {
let button_clicked = requests.read().count() > 0;
if !keys.just_pressed(KeyCode::KeyS) && !button_clicked {
return;
}
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else {
if !other_modal_scrims.is_empty() {
return;
}
spawn_stats_screen(
&mut commands,
&stats.0,
progress.as_deref().map(|p| &p.0),
time_attack.as_deref(),
font_res.as_deref(),
&latest_replay.0.replays,
selected_index.0,
);
}
}
/// Click handler for the modal's "Done" button — despawns the overlay
/// the same way the `S` accelerator does.
fn handle_stats_close_button(
mut commands: Commands,
close_buttons: Query<&Interaction, (With<StatsCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<StatsScreen>>,
) {
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
fn spawn_stats_screen(
commands: &mut Commands,
/// Builds the Stats tab body inside the You hub's card: the 8-cell
/// grid plus per-mode bests, progression, weekly goals, unlocks, and
/// the optional Time Attack line. The replay selector lives on its own
/// hub tab now — see [`spawn_replays_body`]. All markers
/// (`StatsCell`, `PerModeBestsRow`, `StatsScrollable`) are unchanged.
pub(crate) fn spawn_stats_body(
card: &mut ChildSpawnerCommands,
stats: &StatsSnapshot,
progress: Option<&PlayerProgress>,
time_attack: Option<&TimeAttackResource>,
font_res: Option<&FontResource>,
replays: &[Replay],
selected_index: usize,
) {
// --- primary stat cells ---
// First-launch zero-state: when no games have been played yet, render
@@ -756,9 +699,7 @@ fn spawn_stats_screen(
..default()
};
let scrim = spawn_modal(commands, StatsScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Statistics", font_res);
{
// Scrollable body — the Stats panel renders an 8-cell grid plus
// multiple sections (per-mode bests, progression, weekly goals,
// unlocks, optional Time Attack, latest replay caption) and
@@ -935,7 +876,45 @@ fn spawn_stats_screen(
TextColor(STATE_WARNING),
));
}
});
}
}
/// Builds the Replays tab body inside the You hub's card: the
/// Prev/Next selector, detail line, and the Watch / Copy-share-link
/// actions (buttons live in the body now — the hub owns the single
/// Done in the action row). All markers (`ReplayPrevButton`,
/// `ReplayNextButton`, `ReplaySelectorCaption`, `ReplaySelectorDetail`,
/// `WatchReplayButton`, `CopyShareLinkButton`) are unchanged, so the
/// selector/watch/copy systems keep working.
pub(crate) fn spawn_replays_body(
card: &mut ChildSpawnerCommands,
replays: &[Replay],
selected_index: usize,
font_res: Option<&FontResource>,
) {
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_row = TextFont {
font: font_handle,
font_size: TYPE_BODY,
..default()
};
{
// Scrollable body — mirrors the other hub tabs so short
// windows can still reach every control.
card.spawn((
StatsScrollable,
ScrollPosition::default(),
Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_3,
max_height: Val::Vh(70.0),
overflow: Overflow::scroll_y(),
..default()
},
))
.with_children(|body| {
// --- Replay selector ---
// Prev / Next chips step through the full replay history;
// `repaint_replay_selector_caption` and
@@ -1019,12 +998,21 @@ fn spawn_stats_screen(
));
});
spawn_modal_actions(card, |actions| {
// The Watch Replay button is always rendered so the
// affordance is discoverable from a fresh install. When no
// replay exists, the click handler surfaces a clear
// "No replay recorded yet" toast rather than silently
// doing nothing.
// Tab-local actions. The Watch Replay button is always
// rendered so the affordance is discoverable from a fresh
// install — with no replay, its click handler surfaces a
// "No replay recorded yet" toast rather than silently doing
// nothing. Same policy for Copy share link (toast explains
// when no shareable upload exists).
card.spawn(Node {
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
column_gap: VAL_SPACE_2,
row_gap: VAL_SPACE_2,
margin: UiRect::top(VAL_SPACE_2),
..default()
})
.with_children(|actions| {
spawn_modal_button(
actions,
WatchReplayButton,
@@ -1033,11 +1021,6 @@ fn spawn_stats_screen(
ButtonVariant::Secondary,
font_res,
);
// Copy share link only renders when a sharable URL is in
// hand. The button is intentionally absent (rather than
// disabled) when no upload has happened yet — keeps the
// action bar free of dead controls in the local-only and
// first-launch cases.
spawn_modal_button(
actions,
CopyShareLinkButton,
@@ -1046,18 +1029,8 @@ fn spawn_stats_screen(
ButtonVariant::Secondary,
font_res,
);
spawn_modal_button(
actions,
StatsCloseButton,
"Done",
Some("S"),
ButtonVariant::Primary,
font_res,
);
});
});
// Stats is read-only — opt into click-outside-to-dismiss.
commands.entity(scrim).insert(ScrimDismissible);
}
}
/// Spawn one row of the "Per-mode bests" section: the mode label on the
@@ -1290,6 +1263,7 @@ mod tests {
// ProgressResource is an optional dependency for the stats screen;
// include it so toggle tests exercise the progression panel.
app.add_plugins(crate::progress_plugin::ProgressPlugin::headless());
app.add_plugins(crate::you_hub_plugin::YouHubPlugin);
app.update();
app
}
@@ -1832,6 +1806,25 @@ mod tests {
// Prev/Next replay selector spawn-site tests
// -----------------------------------------------------------------------
/// Opens the You hub via `S`, then switches to the Replays tab
/// (where the selector lives since Phase E) and settles the rebuild.
fn open_replays_tab(app: &mut App) {
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyS);
app.update();
// MinimalPlugins never clears input, so the sticky `just_pressed`
// would keep re-requesting the Stats tab every frame.
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.clear();
app.world_mut()
.resource_mut::<crate::you_hub_plugin::ActiveYouTab>()
.0 = crate::you_hub_plugin::YouTab::Replays;
app.update();
app.update();
}
#[test]
fn selector_row_spawns_when_stats_screen_opens() {
let mut app = headless_app();
@@ -1840,10 +1833,7 @@ mod tests {
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
hist.0.replays.push(make_test_replay(90, None));
}
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyS);
app.update();
open_replays_tab(&mut app);
let prev = app
.world_mut()
@@ -1878,10 +1868,7 @@ mod tests {
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
hist.0.replays.push(make_test_replay(120, None));
}
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyS);
app.update();
open_replays_tab(&mut app);
let mut q = app
.world_mut()
@@ -1901,10 +1888,7 @@ mod tests {
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
hist.0.replays.push(make_test_replay(65, None)); // 65s → "1:05"
}
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyS);
app.update();
open_replays_tab(&mut app);
let mut q = app
.world_mut()
@@ -1955,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,
+275 -32
View File
@@ -14,6 +14,9 @@
//! filesystem-based; the plugin is gated out on wasm32 alongside
//! `SyncPlugin`.
use std::collections::HashMap;
use bevy::asset::RenderAssetUsages;
use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use thiserror::Error;
@@ -105,6 +108,21 @@ struct InstallTask(Option<(String, Task<InstallResult>)>);
#[derive(Resource, Default)]
struct StoreBaseUrl(Option<String>);
/// Decoded preview thumbnails by theme id (Phase H). Session-lifetime:
/// previews survive closing and reopening the store, so a revisit
/// renders instantly without refetching.
#[derive(Resource, Default)]
struct PreviewCache(HashMap<String, Handle<Image>>);
/// One in-flight preview download: raw PNG bytes or the fetch error.
type PreviewFetch = Task<Result<Vec<u8>, ThemeStoreError>>;
/// In-flight preview downloads, one per catalog entry that advertises
/// a `preview_url` not already in [`PreviewCache`]. `String` is the
/// theme id the bytes belong to.
#[derive(Resource, Default)]
struct PreviewTasks(Vec<(String, PreviewFetch)>);
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
@@ -118,6 +136,11 @@ impl Plugin for ThemeStorePlugin {
.init_resource::<CatalogTask>()
.init_resource::<InstallTask>()
.init_resource::<StoreBaseUrl>()
.init_resource::<PreviewCache>()
.init_resource::<PreviewTasks>()
// Esc-close reads keyboard input; register defensively so
// the plugin works under MinimalPlugins in tests.
.init_resource::<ButtonInput<KeyCode>>()
.add_message::<ThemeStoreOpenRequestEvent>()
.add_message::<InfoToastEvent>()
.add_message::<WarningToastEvent>()
@@ -126,6 +149,7 @@ impl Plugin for ThemeStorePlugin {
(
handle_open_request,
poll_catalog_task,
poll_preview_tasks,
handle_install_buttons,
poll_install_task,
handle_close_button,
@@ -173,6 +197,7 @@ fn handle_open_request(
mut catalog_task: ResMut<CatalogTask>,
mut store_base: ResMut<StoreBaseUrl>,
mut warning_toast: MessageWriter<WarningToastEvent>,
previews: Res<PreviewCache>,
mut commands: Commands,
font_res: Option<Res<FontResource>>,
) {
@@ -204,16 +229,28 @@ fn handle_open_request(
rt.block_on(async { ThemeStoreClient::new(base_url).fetch_catalog().await })
}));
spawn_store_modal(&mut commands, &catalog_state, None, font_res.as_deref());
spawn_store_modal(
&mut commands,
&catalog_state,
None,
&previews,
font_res.as_deref(),
);
}
/// Polls the catalog fetch; on completion updates [`CatalogState`] and
/// Polls the catalog fetch; on completion updates [`CatalogState`],
/// kicks off preview downloads for entries not yet in the cache, and
/// rebuilds the modal if it is still open.
#[allow(clippy::too_many_arguments)]
fn poll_catalog_task(
mut catalog_task: ResMut<CatalogTask>,
mut catalog_state: ResMut<CatalogState>,
screens: Query<Entity, With<ThemeStoreScreen>>,
registry: Option<Res<ThemeRegistry>>,
rt: Option<Res<TokioRuntimeResource>>,
store_base: Res<StoreBaseUrl>,
previews: Res<PreviewCache>,
mut preview_tasks: ResMut<PreviewTasks>,
mut commands: Commands,
font_res: Option<Res<FontResource>>,
) {
@@ -226,7 +263,29 @@ fn poll_catalog_task(
catalog_task.0 = None;
*catalog_state = match result {
Ok(entries) => CatalogState::Loaded(entries),
Ok(entries) => {
// Fetch previews for anything new. Decorative — failures
// just leave the row text-only, so errors only log.
if let (Some(rt), Some(base_url)) = (rt.as_ref(), store_base.0.as_deref()) {
for entry in entries
.iter()
.filter(|e| e.preview_url.is_some() && !previews.0.contains_key(&e.id))
{
let rt = rt.0.clone();
let base_url = base_url.to_owned();
let entry = entry.clone();
preview_tasks.0.push((
entry.id.clone(),
AsyncComputeTaskPool::get().spawn(async move {
rt.block_on(async {
ThemeStoreClient::new(base_url).fetch_preview(&entry).await
})
}),
));
}
}
CatalogState::Loaded(entries)
}
Err(e) => {
warn!("theme store: catalog fetch failed: {e}");
CatalogState::Error(e.to_string())
@@ -238,10 +297,69 @@ fn poll_catalog_task(
&mut commands,
&catalog_state,
registry.as_deref(),
&previews,
font_res.as_deref(),
);
}
/// Polls in-flight preview downloads. Each finished PNG is decoded into
/// an [`Image`] asset and cached by theme id; the open modal rebuilds
/// once per frame that added at least one preview, so thumbnails pop in
/// as they arrive. Failures log and leave the row text-only. Skipped
/// entirely when `Assets<Image>` is absent (`MinimalPlugins` tests).
#[allow(clippy::too_many_arguments)]
fn poll_preview_tasks(
mut preview_tasks: ResMut<PreviewTasks>,
mut previews: ResMut<PreviewCache>,
images: Option<ResMut<Assets<Image>>>,
catalog_state: Res<CatalogState>,
screens: Query<Entity, With<ThemeStoreScreen>>,
registry: Option<Res<ThemeRegistry>>,
mut commands: Commands,
font_res: Option<Res<FontResource>>,
) {
if preview_tasks.0.is_empty() {
return;
}
let Some(mut images) = images else {
return;
};
let mut added = false;
preview_tasks.0.retain_mut(|(id, task)| {
let Some(result) = future::block_on(future::poll_once(task)) else {
return true; // still downloading
};
match result {
Ok(bytes) => match image::load_from_memory(&bytes) {
Ok(dyn_img) => {
let handle = images.add(Image::from_dynamic(
dyn_img,
true,
RenderAssetUsages::RENDER_WORLD,
));
previews.0.insert(id.clone(), handle);
added = true;
}
Err(e) => warn!("theme store: preview for '{id}' failed to decode: {e}"),
},
Err(e) => warn!("theme store: preview fetch for '{id}' failed: {e}"),
}
false
});
if added {
rebuild_open_modal(
&screens,
&mut commands,
&catalog_state,
registry.as_deref(),
&previews,
font_res.as_deref(),
);
}
}
/// Starts a download + import task when an Install button is pressed.
/// One install at a time; repeat clicks while busy are ignored.
fn handle_install_buttons(
@@ -305,6 +423,7 @@ fn poll_install_task(
screens: Query<Entity, With<ThemeStoreScreen>>,
mut info_toast: MessageWriter<InfoToastEvent>,
mut warning_toast: MessageWriter<WarningToastEvent>,
previews: Res<PreviewCache>,
mut commands: Commands,
font_res: Option<Res<FontResource>>,
) {
@@ -340,23 +459,28 @@ fn poll_install_task(
&mut commands,
&catalog_state,
registry.as_deref(),
&previews,
font_res.as_deref(),
);
}
/// Despawns the store modal when Close is pressed.
/// Despawns the store modal when Close is pressed or on Esc (Phase C
/// dismissal audit). The store only ever stacks over Settings and
/// nothing stacks over the store, so it owns Esc whenever it is open
/// (Settings' own Esc handler is gated on being topmost).
fn handle_close_button(
interactions: Query<&Interaction, (Changed<Interaction>, With<ThemeStoreCloseButton>)>,
keys: Res<ButtonInput<KeyCode>>,
screens: Query<Entity, With<ThemeStoreScreen>>,
mut commands: Commands,
) {
for interaction in &interactions {
if *interaction != Interaction::Pressed {
continue;
}
for entity in &screens {
commands.entity(entity).despawn();
}
let clicked = interactions.iter().any(|i| *i == Interaction::Pressed);
let esc = keys.just_pressed(KeyCode::Escape) && !screens.is_empty();
if !clicked && !esc {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
@@ -371,11 +495,12 @@ fn rebuild_open_modal(
commands: &mut Commands,
catalog_state: &CatalogState,
registry: Option<&ThemeRegistry>,
previews: &PreviewCache,
font_res: Option<&FontResource>,
) {
for entity in screens {
commands.entity(entity).despawn();
spawn_store_modal(commands, catalog_state, registry, font_res);
spawn_store_modal(commands, catalog_state, registry, previews, font_res);
}
}
@@ -384,6 +509,7 @@ fn spawn_store_modal(
commands: &mut Commands,
catalog_state: &CatalogState,
registry: Option<&ThemeRegistry>,
previews: &PreviewCache,
font_res: Option<&FontResource>,
) {
let body_font = TextFont {
@@ -426,7 +552,15 @@ fn spawn_store_modal(
for entry in entries {
let installed =
registry.is_some_and(|registry| registry.find(&entry.id).is_some());
spawn_store_row(card, entry, installed, &body_font, &caption_font, font_res);
spawn_store_row(
card,
entry,
installed,
previews.0.get(&entry.id),
&body_font,
&caption_font,
font_res,
);
}
}
}
@@ -445,12 +579,20 @@ fn spawn_store_modal(
commands.entity(scrim).insert(ScrimDismissible);
}
/// One catalog row: name + author/size caption on the left, Install
/// button (or "Installed" caption) on the right.
/// Height of a row's preview thumbnail in logical pixels; the width
/// follows the theme's own `card_aspect` so a preview is never
/// stretched.
const PREVIEW_THUMB_HEIGHT_PX: f32 = 72.0;
/// One catalog row: preview thumbnail (when downloaded) + name +
/// author/size caption on the left, Install button (or "Installed"
/// caption) on the right.
#[allow(clippy::too_many_arguments)]
fn spawn_store_row(
parent: &mut ChildSpawnerCommands,
entry: &ThemeCatalogEntry,
installed: bool,
preview: Option<&Handle<Image>>,
body_font: &TextFont,
caption_font: &TextFont,
font_res: Option<&FontResource>,
@@ -470,25 +612,45 @@ fn spawn_store_row(
))
.with_children(|row| {
row.spawn(Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_2,
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_3,
..default()
})
.with_children(|col| {
col.spawn((
Text::new(entry.name.clone()),
body_font.clone(),
TextColor(TEXT_PRIMARY),
));
col.spawn((
Text::new(format!(
"by {} — {} KB",
entry.author,
entry.size_bytes.div_ceil(1024)
)),
caption_font.clone(),
TextColor(TEXT_SECONDARY),
));
.with_children(|left| {
if let Some(handle) = preview {
let (aw, ah) = entry.card_aspect;
let width = PREVIEW_THUMB_HEIGHT_PX * aw.max(1) as f32 / ah.max(1) as f32;
left.spawn((
ImageNode::new(handle.clone()),
Node {
width: Val::Px(width),
height: Val::Px(PREVIEW_THUMB_HEIGHT_PX),
..default()
},
));
}
left.spawn(Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_2,
..default()
})
.with_children(|col| {
col.spawn((
Text::new(entry.name.clone()),
body_font.clone(),
TextColor(TEXT_PRIMARY),
));
col.spawn((
Text::new(format!(
"by {} — {} KB",
entry.author,
entry.size_bytes.div_ceil(1024)
)),
caption_font.clone(),
TextColor(TEXT_SECONDARY),
));
});
});
if installed {
row.spawn((
@@ -654,4 +816,85 @@ mod tests {
));
assert_eq!(screen_count(&mut app), 1);
}
// -----------------------------------------------------------------------
// Phase H part 2: preview thumbnails
// -----------------------------------------------------------------------
fn catalog_entry(id: &str, preview: bool) -> ThemeCatalogEntry {
ThemeCatalogEntry {
id: id.into(),
name: id.into(),
author: "Test".into(),
version: "1.0.0".into(),
card_aspect: (2, 3),
size_bytes: 1024,
sha256: "00".into(),
download_url: format!("/api/themes/{id}/download"),
preview_url: preview.then(|| format!("/api/themes/{id}/preview")),
}
}
/// A row whose theme id is in the [`PreviewCache`] renders an
/// `ImageNode` thumbnail; a row without one stays text-only.
#[test]
fn loaded_row_shows_preview_thumbnail_only_when_cached() {
let mut app = headless_app();
let cached = catalog_entry("with-preview", true);
let uncached = catalog_entry("without-preview", false);
let mut cache = PreviewCache::default();
cache
.0
.insert(cached.id.clone(), Handle::<Image>::default());
let state = CatalogState::Loaded(vec![cached, uncached]);
{
let world = app.world_mut();
let mut commands = world.commands();
spawn_store_modal(&mut commands, &state, None, &cache, None);
}
app.update();
let thumbs = app
.world_mut()
.query::<&ImageNode>()
.iter(app.world())
.count();
assert_eq!(
thumbs, 1,
"exactly the cached entry must render a preview thumbnail"
);
}
/// The thumbnail width follows the theme's own card aspect so a
/// preview never renders stretched.
#[test]
fn preview_thumbnail_width_follows_card_aspect() {
let mut app = headless_app();
let mut entry = catalog_entry("wide", true);
entry.card_aspect = (1, 1); // square theme art
let mut cache = PreviewCache::default();
cache.0.insert(entry.id.clone(), Handle::<Image>::default());
let state = CatalogState::Loaded(vec![entry]);
{
let world = app.world_mut();
let mut commands = world.commands();
spawn_store_modal(&mut commands, &state, None, &cache, None);
}
app.update();
let node = app
.world_mut()
.query_filtered::<&Node, With<ImageNode>>()
.single(app.world())
.expect("thumbnail node must exist");
assert_eq!(node.height, Val::Px(PREVIEW_THUMB_HEIGHT_PX));
assert_eq!(
node.width,
Val::Px(PREVIEW_THUMB_HEIGHT_PX),
"a 1:1 aspect must yield a square thumbnail"
);
}
}
+70 -4
View File
@@ -60,9 +60,9 @@ use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{
ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI,
BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder,
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, SCRIM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG,
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, VAL_SPACE_5,
scaled_duration,
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, RADIUS_SM, SCRIM, STATE_SUCCESS, TEXT_PRIMARY,
TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3,
VAL_SPACE_4, VAL_SPACE_5, scaled_duration,
};
// ---------------------------------------------------------------------------
@@ -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()
},
@@ -402,6 +428,46 @@ pub fn spawn_modal_button<M: Component>(
});
}
/// One tab chip for a tabbed modal (Settings, the You hub). The active
/// chip is filled + bright with a success-green border; inactive chips
/// are quiet outlines. `marker` is the plugin's click-target component
/// carrying which tab the chip selects.
pub fn spawn_tab_chip<M: Component>(
parent: &mut ChildSpawnerCommands,
marker: M,
label: &str,
active: bool,
font_res: Option<&FontResource>,
) {
let font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_CAPTION,
..default()
};
parent
.spawn((
marker,
Button,
Node {
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(if active { BG_ELEVATED_HI } else { BG_BASE }),
BorderColor::all(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
HighContrastBorder::with_default(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
))
.with_children(|b| {
b.spawn((
Text::new(label),
font,
TextColor(if active { TEXT_PRIMARY } else { TEXT_SECONDARY }),
));
});
}
// ---------------------------------------------------------------------------
// Generic touch-scroll helper
// ---------------------------------------------------------------------------
+527
View File
@@ -0,0 +1,527 @@
//! One-shot "What's new" card on the first launch after an update
//! (Phase I of the 2026-07 UI redesign).
//!
//! ObtainX updates install silently, so shipped features go unnoticed —
//! nobody found the theme store on their own. On the first launch where
//! the running release differs from `Settings::last_seen_whats_new`,
//! this plugin shows a single dismissible card summarising the latest
//! changelog section, then records the version so the card never
//! repeats. Fresh installs never see it: onboarding completion stamps
//! the current version silently, so the card only ever describes an
//! *upgrade*.
//!
//! # Launch beat
//!
//! Splash → (first run only: onboarding) → **what's new** → Home →
//! table. `spawn_home_on_launch` waits on [`WhatsNewPending`] the same
//! way it waits for the onboarding modal.
//!
//! # Version source
//!
//! The embedded `CHANGELOG.md`'s topmost release section provides both
//! the card's content and the "current version" for change detection —
//! one source of truth, no build-time version plumbing. (The APK
//! `versionName` comes from the release tag at package time and never
//! reaches Rust; the workspace `Cargo.toml` version is static.)
use bevy::input::ButtonInput;
use bevy::prelude::*;
use solitaire_data::save_settings_to;
use crate::font_plugin::FontResource;
use crate::onboarding_plugin::OnboardingScreen;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::ui_modal::{
ButtonVariant, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_button,
spawn_modal_header,
};
use crate::ui_theme::{
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, Z_MODAL_PANEL,
};
/// The changelog ships inside the binary — small, changes only at
/// release cadence, and the card must work offline (§4.2).
const CHANGELOG: &str = include_str!("../../CHANGELOG.md");
/// Most bullets shown on the card; a giant release stays skimmable.
const MAX_CARD_BULLETS: usize = 8;
/// Marker on the What's-new modal's scrim root.
#[derive(Component, Debug)]
pub struct WhatsNewScreen;
/// Marker on the card's "Got it" button.
#[derive(Component, Debug)]
struct WhatsNewCloseButton;
/// `true` until this plugin has made its launch-beat decision — either
/// the card was shown and dismissed, or there was nothing to show.
/// `spawn_home_on_launch` waits on this so the card gets the beat
/// before Home.
#[derive(Resource, Debug)]
pub struct WhatsNewPending(pub bool);
impl Default for WhatsNewPending {
fn default() -> Self {
Self(true)
}
}
/// One display line of the card.
#[derive(Debug, PartialEq, Eq)]
enum NoteLine {
/// A `###` section heading ("Added", "Fixed", …).
Heading(String),
/// The lead sentence of one changelog bullet.
Bullet(String),
}
/// The changelog's topmost release section, reduced to card content.
#[derive(Debug)]
struct ReleaseNotes {
/// Version string without the `v` prefix, e.g. `"0.46.0"`.
version: String,
lines: Vec<NoteLine>,
}
/// The version of the topmost changelog release — the engine's notion
/// of "the running release". Empty only if the changelog is malformed.
pub fn current_release_version() -> String {
parse_latest_release(CHANGELOG).map_or_else(String::new, |notes| notes.version)
}
/// Parses the first `## [x.y.z]` section of `changelog` into card
/// content. `## [Unreleased]` is skipped; `### Internal` subsections
/// are dropped (players don't care about CI); each bullet is reduced
/// to its lead sentence with markdown emphasis stripped.
fn parse_latest_release(changelog: &str) -> Option<ReleaseNotes> {
let mut lines_iter = changelog.lines();
let mut version = None;
for line in lines_iter.by_ref() {
if let Some(rest) = line.strip_prefix("## [") {
let (v, _) = rest.split_once(']')?;
if v.eq_ignore_ascii_case("Unreleased") {
continue;
}
version = Some(v.to_string());
break;
}
}
let version = version?;
let mut lines = Vec::new();
let mut skipping_section = false;
let mut current_bullet: Option<String> = None;
let flush = |bullet: &mut Option<String>, lines: &mut Vec<NoteLine>| {
if let Some(text) = bullet.take() {
lines.push(NoteLine::Bullet(lead_sentence(&text)));
}
};
for line in lines_iter {
if line.starts_with("## [") {
break; // next (older) release section
}
if let Some(heading) = line.strip_prefix("### ") {
flush(&mut current_bullet, &mut lines);
skipping_section = heading.trim().eq_ignore_ascii_case("internal");
if !skipping_section {
lines.push(NoteLine::Heading(heading.trim().to_string()));
}
continue;
}
if skipping_section {
continue;
}
if let Some(rest) = line.strip_prefix("- ") {
flush(&mut current_bullet, &mut lines);
current_bullet = Some(rest.trim().to_string());
} else if current_bullet.is_some() && !line.trim().is_empty() {
// Wrapped continuation of the current bullet.
if let Some(bullet) = current_bullet.as_mut() {
bullet.push(' ');
bullet.push_str(line.trim());
}
}
}
flush(&mut current_bullet, &mut lines);
Some(ReleaseNotes { version, lines })
}
/// Strips markdown emphasis / code ticks and truncates to the first
/// sentence — changelog bullets lead with a bold summary sentence, and
/// that is exactly the card-sized version.
fn lead_sentence(bullet: &str) -> String {
let stripped: String = bullet.replace("**", "").replace('`', "");
// Trailing "(#123)" references never survive the sentence cut, but
// guard against a bullet that is only a reference.
let end = stripped.find(". ").map_or(stripped.len(), |i| i + 1);
stripped[..end].trim().trim_end_matches('.').to_string()
}
/// Registers the launch-gate spawn system and the dismiss handlers.
pub struct WhatsNewPlugin;
impl Plugin for WhatsNewPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<WhatsNewPending>()
.init_resource::<ButtonInput<KeyCode>>()
.add_systems(
Update,
(maybe_spawn_whats_new, handle_whats_new_close).chain(),
);
}
}
/// Shows the card once the launch surface is clear, or stands down when
/// there is nothing to show. See the module docs for the exact beat.
///
/// The seen-version stamp persists on *spawn*, not dismissal — if the
/// app dies with the card open the player has still seen it once, and
/// the card must never nag.
#[allow(clippy::too_many_arguments)]
fn maybe_spawn_whats_new(
mut commands: Commands,
mut pending: ResMut<WhatsNewPending>,
splash: Query<(), With<crate::splash_plugin::SplashRoot>>,
onboarding: Query<(), With<OnboardingScreen>>,
restore_prompts: Query<(), With<crate::game_plugin::RestorePromptScreen>>,
pending_restore: Option<Res<crate::game_plugin::PendingRestoredGame>>,
other_scrims: Query<(), With<ModalScrim>>,
mut settings: Option<ResMut<SettingsResource>>,
storage_path: Option<Res<SettingsStoragePath>>,
font_res: Option<Res<FontResource>>,
screens: Query<(), With<WhatsNewScreen>>,
) {
if !pending.0 {
return;
}
if !screens.is_empty() {
// Card already open — the seen-version stamp landed on spawn,
// so without this guard the next frame would read "already
// seen" and release the beat under the open card.
return;
}
if !splash.is_empty()
|| !onboarding.is_empty()
|| !restore_prompts.is_empty()
|| pending_restore.as_ref().is_some_and(|p| p.0.is_some())
{
return;
}
let Some(settings) = settings.as_mut() else {
// Headless / no settings wired: nothing to compare against.
pending.0 = false;
return;
};
if !settings.0.first_run_complete {
// Fresh install mid-onboarding — completion stamps the version
// itself (see `complete_onboarding`), which resolves this gate
// on a later frame without ever showing the card.
return;
}
let Some(notes) = parse_latest_release(CHANGELOG) else {
pending.0 = false;
return;
};
if settings.0.last_seen_whats_new == notes.version {
pending.0 = false;
return;
}
if !other_scrims.is_empty() {
// Another modal owns the beat right now; try again next frame.
return;
}
spawn_whats_new_card(&mut commands, &notes, font_res.as_deref());
settings.0.last_seen_whats_new = notes.version.clone();
if let Some(p) = storage_path
&& let Some(path) = p.0.as_deref()
&& let Err(e) = save_settings_to(path, &settings.0)
{
warn!("whats-new: failed to persist seen version: {e}");
}
// `pending` stays true until dismissal so Home keeps waiting.
}
/// Dismisses the card on "Got it" or Esc and releases the launch beat.
fn handle_whats_new_close(
mut commands: Commands,
keys: Option<Res<ButtonInput<KeyCode>>>,
buttons: Query<&Interaction, (With<WhatsNewCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<WhatsNewScreen>>,
other_scrims: Query<(), (With<ModalScrim>, Without<WhatsNewScreen>)>,
mut pending: ResMut<WhatsNewPending>,
) {
if screens.is_empty() {
return;
}
let click = buttons.iter().any(|i| *i == Interaction::Pressed);
let esc = keys.is_some_and(|k| k.just_pressed(KeyCode::Escape)) && other_scrims.is_empty();
if !click && !esc {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
pending.0 = false;
}
/// Spawns the card: header with the version, the parsed changelog
/// lines, and a "Got it" action.
fn spawn_whats_new_card(
commands: &mut Commands,
notes: &ReleaseNotes,
font_res: Option<&FontResource>,
) {
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_heading = TextFont {
font: font_handle.clone(),
font_size: TYPE_CAPTION,
..default()
};
let font_bullet = TextFont {
font: font_handle,
font_size: TYPE_BODY,
..default()
};
// Deliberately NOT ScrimDismissible: scrim-tap despawns without
// running the close handler, which would leave the launch beat
// held and Home never spawning. Esc and "Got it" both release it.
let title = format!("What's new in v{}", notes.version);
spawn_modal(commands, WhatsNewScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, &title, font_res);
card.spawn(Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_2,
width: Val::Percent(100.0),
max_height: Val::Vh(60.0),
overflow: Overflow::scroll_y(),
..default()
})
.with_children(|body| {
let mut bullets_shown = 0usize;
for line in &notes.lines {
match line {
NoteLine::Heading(heading) => {
body.spawn((
Text::new(heading.clone()),
font_heading.clone(),
TextColor(TEXT_SECONDARY),
Node {
margin: UiRect::top(VAL_SPACE_1),
..default()
},
));
}
NoteLine::Bullet(text) => {
if bullets_shown >= MAX_CARD_BULLETS {
continue;
}
bullets_shown += 1;
body.spawn((
Text::new(format!("\u{2022} {text}")),
font_bullet.clone(),
TextColor(TEXT_PRIMARY),
));
}
}
}
});
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
WhatsNewCloseButton,
"Got it",
None,
ButtonVariant::Primary,
font_res,
);
});
});
}
#[cfg(test)]
mod tests {
use super::*;
use solitaire_data::Settings;
const SAMPLE: &str = "# Changelog\n\n## [Unreleased]\n\n## [0.46.0] — 2026-07-13\n\n\
### Added\n\n- **Theme-store previews.** The store modal now shows each theme's\n \
preview image next to its name. (#179)\n- **Hint ghost preview.** You see the move. (#179)\n\n\
### Internal\n\n- **CI is faster.** Nobody cares in-game. (#176)\n\n\
### Fixed\n\n- **Touch onboarding copy.** No more left-click. (#178)\n\n\
## [0.45.0] 2026-07-13\n\n### Added\n\n- Old stuff.\n";
#[test]
fn parses_top_section_version_and_skips_unreleased() {
let notes = parse_latest_release(SAMPLE).expect("sample must parse");
assert_eq!(notes.version, "0.46.0");
}
#[test]
fn drops_internal_sections_and_older_releases() {
let notes = parse_latest_release(SAMPLE).expect("sample must parse");
let headings: Vec<&str> = notes
.lines
.iter()
.filter_map(|l| match l {
NoteLine::Heading(h) => Some(h.as_str()),
_ => None,
})
.collect();
assert_eq!(headings, vec!["Added", "Fixed"]);
assert!(
!notes
.lines
.iter()
.any(|l| matches!(l, NoteLine::Bullet(b) if b.contains("CI is faster"))),
"Internal bullets must not reach the card"
);
assert!(
!notes
.lines
.iter()
.any(|l| matches!(l, NoteLine::Bullet(b) if b.contains("Old stuff"))),
"older release sections must not bleed in"
);
}
#[test]
fn bullets_reduce_to_their_lead_sentence() {
let notes = parse_latest_release(SAMPLE).expect("sample must parse");
assert!(
notes
.lines
.contains(&NoteLine::Bullet("Theme-store previews".to_string()))
);
assert!(
notes
.lines
.contains(&NoteLine::Bullet("Hint ghost preview".to_string()))
);
}
#[test]
fn embedded_changelog_parses_to_a_nonempty_version() {
let version = current_release_version();
assert!(
!version.is_empty(),
"the real CHANGELOG.md must yield a version"
);
assert!(
version.chars().next().is_some_and(|c| c.is_ascii_digit()),
"version must be bare (no v prefix): {version}"
);
}
fn app_with(settings: Settings) -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(WhatsNewPlugin);
app.insert_resource(SettingsResource(settings));
app.update();
app
}
fn card_count(app: &mut App) -> usize {
app.world_mut()
.query::<&WhatsNewScreen>()
.iter(app.world())
.count()
}
#[test]
fn upgrade_shows_card_and_stamps_version() {
let mut app = app_with(Settings {
first_run_complete: true,
last_seen_whats_new: "0.1.0".into(),
..Settings::default()
});
app.update();
assert_eq!(card_count(&mut app), 1, "an upgrade must show the card");
assert_eq!(
app.world()
.resource::<SettingsResource>()
.0
.last_seen_whats_new,
current_release_version(),
"the seen version must stamp on spawn"
);
assert!(
app.world().resource::<WhatsNewPending>().0,
"the launch beat stays held until dismissal"
);
}
#[test]
fn seen_version_shows_nothing_and_releases_the_beat() {
let mut app = app_with(Settings {
first_run_complete: true,
last_seen_whats_new: current_release_version(),
..Settings::default()
});
app.update();
assert_eq!(card_count(&mut app), 0);
assert!(
!app.world().resource::<WhatsNewPending>().0,
"nothing to show must release the launch beat"
);
}
#[test]
fn fresh_install_waits_for_onboarding_and_never_shows() {
let mut app = app_with(Settings {
first_run_complete: false,
..Settings::default()
});
app.update();
assert_eq!(card_count(&mut app), 0);
assert!(
app.world().resource::<WhatsNewPending>().0,
"mid-onboarding the beat stays held"
);
// Onboarding completion stamps the version (mirrored from
// complete_onboarding) — afterwards the card must stand down.
{
let mut settings = app.world_mut().resource_mut::<SettingsResource>();
settings.0.first_run_complete = true;
settings.0.last_seen_whats_new = current_release_version();
}
app.update();
assert_eq!(card_count(&mut app), 0, "fresh installs never see the card");
assert!(!app.world().resource::<WhatsNewPending>().0);
}
#[test]
fn got_it_dismisses_and_releases_the_beat() {
let mut app = app_with(Settings {
first_run_complete: true,
..Settings::default()
});
app.update();
assert_eq!(card_count(&mut app), 1);
let button = app
.world_mut()
.query_filtered::<Entity, With<WhatsNewCloseButton>>()
.single(app.world())
.expect("Got it button must exist");
app.world_mut()
.entity_mut(button)
.insert(Interaction::Pressed);
app.update();
app.update();
assert_eq!(card_count(&mut app), 0, "Got it must dismiss the card");
assert!(!app.world().resource::<WhatsNewPending>().0);
}
}
+268 -107
View File
@@ -5,6 +5,13 @@
//! started), a full-screen modal is spawned showing score, time, XP, and a
//! "Play Again" button that fires `NewGameRequestEvent` and closes the modal.
//!
//! # Phase G (docs/ui-redesign-2026-07.md) — action hierarchy
//! The modal leads with actions: **Play Again** (primary, Enter
//! accelerator), then **Watch Replay** and **Share Replay** (shared
//! `stats_plugin` markers, so the global handlers there act on the
//! just-won replay), with the score/time/XP recap reading quietly
//! below.
//!
//! # Task #47 — Win fanfare screen-shake
//! When `GameWonEvent` fires, `ScreenShakeResource` is set. A system offsets
//! the `Camera2d` `Transform` each frame with a decaying oscillation until the
@@ -23,7 +30,7 @@ use crate::game_plugin::GameMutation;
use crate::progress_plugin::ProgressResource;
use crate::resources::GameStateResource;
use crate::settings_plugin::SettingsResource;
use crate::stats_plugin::{StatsResource, StatsUpdate};
use crate::stats_plugin::{CopyShareLinkButton, StatsResource, StatsUpdate, WatchReplayButton};
use crate::ui_modal::ModalScrim;
use crate::ui_theme::{
ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_SCORE_BREAKDOWN_FADE_SECS,
@@ -163,12 +170,15 @@ pub struct SessionAchievements {
#[derive(Component, Debug)]
pub struct WinSummaryOverlay;
/// Marker on the "Play Again" / "Watch Replay" buttons inside the win-summary modal.
/// Marker on the "Play Again" button inside the win-summary modal.
///
/// Watch Replay and Share Replay carry the shared
/// [`WatchReplayButton`] / [`CopyShareLinkButton`] markers from
/// `stats_plugin`, so the global handlers there drive them (both act
/// on [`crate::stats_plugin::SelectedReplayIndex`], which snaps to the
/// just-won replay on every `GameWonEvent`).
#[derive(Component, Debug)]
enum WinSummaryButton {
PlayAgain,
WatchReplay,
}
struct WinSummaryPlayAgainButton;
/// Marker for one row of the win-modal score-breakdown reveal.
///
@@ -230,6 +240,7 @@ impl Plugin for WinSummaryPlugin {
collect_session_achievements,
spawn_win_summary_after_delay,
handle_win_summary_buttons,
close_overlay_on_watch_replay,
handle_win_summary_keyboard,
apply_screen_shake,
reveal_score_breakdown,
@@ -604,50 +615,48 @@ fn spawn_win_summary_after_delay(
}
}
/// Handles "Play Again" and "Watch Replay" in the win-summary modal.
/// Handles "Play Again" and "Watch Replay" in the win-summary modal.
/// Handles "Play Again" in the win-summary modal: collapses the
/// overlay and requests a fresh deal. `NewGameRequestEvent::default()`
/// reuses the current game's `GameMode`, and `handle_new_game` reads
/// the deal options (draw mode, difficulty) from `Settings` — so the
/// rematch is "same mode + same options" in one tap.
fn handle_win_summary_buttons(
interaction_query: Query<(&Interaction, &WinSummaryButton), Changed<Interaction>>,
interaction_query: Query<&Interaction, (Changed<Interaction>, With<WinSummaryPlayAgainButton>)>,
overlays: Query<Entity, With<WinSummaryOverlay>>,
mut commands: Commands,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut toast: MessageWriter<InfoToastEvent>,
history: Option<Res<crate::stats_plugin::ReplayHistoryResource>>,
mut playback: Option<ResMut<crate::replay_playback::ReplayPlaybackState>>,
) {
// Collect all pressed buttons first to avoid moving `playback` inside the loop.
let pressed: Vec<&WinSummaryButton> = interaction_query
.iter()
.filter(|(i, _)| **i == Interaction::Pressed)
.map(|(_, b)| b)
.collect();
if !interaction_query.iter().any(|i| *i == Interaction::Pressed) {
return;
}
for entity in &overlays {
commands.entity(entity).despawn();
}
new_game.write(NewGameRequestEvent::default());
}
for button in pressed {
match button {
WinSummaryButton::PlayAgain => {
for entity in &overlays {
commands.entity(entity).despawn();
}
new_game.write(NewGameRequestEvent::default());
}
WinSummaryButton::WatchReplay => {
let latest = history.as_ref().and_then(|h| h.0.replays.last()).cloned();
match (latest, playback.as_mut()) {
(Some(replay), Some(pb)) => {
for entity in &overlays {
commands.entity(entity).despawn();
}
crate::replay_playback::start_replay_playback(&mut commands, pb, replay);
}
(Some(_), None) => {
toast.write(InfoToastEvent("Replay playback not available".to_string()));
}
(None, _) => {
toast.write(InfoToastEvent("No replay saved yet".to_string()));
}
}
}
}
/// Collapses the win-summary overlay when its "Watch Replay" button is
/// pressed, so playback (started by `stats_plugin`'s global
/// [`WatchReplayButton`] handler reacting to the same press) is not
/// hidden behind the celebration scrim. No-op while the overlay is
/// closed — the Replays-tab copy of the button manages its own modal.
///
/// "Share Replay" ([`CopyShareLinkButton`]) deliberately does NOT
/// close the overlay: the player stays on the celebration while the
/// copy-feedback toast confirms the link.
fn close_overlay_on_watch_replay(
buttons: Query<&Interaction, (Changed<Interaction>, With<WatchReplayButton>)>,
overlays: Query<Entity, With<WinSummaryOverlay>>,
mut commands: Commands,
) {
if overlays.is_empty() {
return;
}
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
for entity in &overlays {
commands.entity(entity).despawn();
}
}
@@ -818,18 +827,68 @@ fn spawn_overlay(
));
}
// Score breakdown reveal — replaces the previous single
// "Score:" line with a per-component multi-row layout.
// --- Action hierarchy (Phase G) ---
// Play Again is the hero action; the two replay
// actions sit under it; the stats recap reads quietly
// below all three. Rematch is one tap.
// Play Again (primary, full row)
card.spawn((
WinSummaryPlayAgainButton,
Button,
Node {
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
justify_content: JustifyContent::Center,
align_self: AlignSelf::Stretch,
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
margin: UiRect::top(VAL_SPACE_2),
..default()
},
BackgroundColor(ACCENT_PRIMARY),
))
.with_children(|b| {
b.spawn((
Text::new("Play Again \u{21B5}"),
TextFont {
font_size: TYPE_BODY_LG,
..default()
},
TextColor(BG_BASE),
));
});
// Watch Replay + Share Replay (secondary, side by side).
// Both reuse the global stats_plugin handlers, which
// target the just-won replay (`SelectedReplayIndex`
// snaps to 0 on every win). Each is always rendered —
// with no replay / no share URL the handler explains
// itself in a toast instead of silently doing nothing.
card.spawn(Node {
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::Center,
column_gap: VAL_SPACE_3,
..default()
})
.with_children(|row| {
spawn_replay_action(row, WatchReplayButton, "Watch Replay");
spawn_replay_action(row, CopyShareLinkButton, "Share Replay");
});
// --- Quiet stats recap, below the actions ---
// Score breakdown reveal — per-component multi-row
// layout with the staggered fade-in.
spawn_score_breakdown(card, &breakdown, anim_speed);
// Time
// Time (demoted to body/secondary — part of the quiet
// recap, not the celebration headline)
card.spawn((
Text::new(format!("Time: {}", format_win_time(pending.time_seconds))),
TextFont {
font_size: TYPE_HEADLINE,
font_size: TYPE_BODY_LG,
..default()
},
TextColor(TEXT_PRIMARY),
TextColor(TEXT_SECONDARY),
));
// XP total
@@ -859,68 +918,40 @@ fn spawn_overlay(
if !session.names.is_empty() {
spawn_achievements_section(card, &session.names);
}
// Button row: Watch Replay + Play Again side by side.
card.spawn(Node {
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::Center,
column_gap: VAL_SPACE_3,
margin: UiRect::top(VAL_SPACE_2),
..default()
})
.with_children(|row| {
// Watch Replay (secondary style)
row.spawn((
WinSummaryButton::WatchReplay,
Button,
Node {
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
justify_content: JustifyContent::Center,
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
border: UiRect::all(Val::Px(1.0)),
..default()
},
BackgroundColor(Color::NONE),
BorderColor::all(ACCENT_PRIMARY),
))
.with_children(|b| {
b.spawn((
Text::new("Watch Replay"),
TextFont {
font_size: TYPE_BODY_LG,
..default()
},
TextColor(ACCENT_PRIMARY),
));
});
// Play Again (primary style)
row.spawn((
WinSummaryButton::PlayAgain,
Button,
Node {
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
justify_content: JustifyContent::Center,
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
..default()
},
BackgroundColor(ACCENT_PRIMARY),
))
.with_children(|b| {
b.spawn((
Text::new("Play Again \u{21B5}"),
TextFont {
font_size: TYPE_BODY_LG,
..default()
},
TextColor(BG_BASE),
));
});
});
});
});
}
/// Spawns one secondary (outline-style) replay action button in the
/// win modal's replay row. `marker` is the shared `stats_plugin`
/// click-target component (`WatchReplayButton` / `CopyShareLinkButton`)
/// whose global handler reacts to the press.
fn spawn_replay_action<M: Component>(row: &mut ChildSpawnerCommands, marker: M, label: &str) {
row.spawn((
marker,
Button,
Node {
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
justify_content: JustifyContent::Center,
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
border: UiRect::all(Val::Px(1.0)),
..default()
},
BackgroundColor(Color::NONE),
BorderColor::all(ACCENT_PRIMARY),
))
.with_children(|b| {
b.spawn((
Text::new(label.to_string()),
TextFont {
font_size: TYPE_BODY_LG,
..default()
},
TextColor(ACCENT_PRIMARY),
));
});
}
/// Maximum number of achievement names shown explicitly in the win modal before
/// the overflow "...and N more" line is shown instead.
const MAX_ACHIEVEMENTS_SHOWN: usize = 3;
@@ -1863,4 +1894,134 @@ mod tests {
assert_eq!(stagger, 0.0);
assert_eq!(fade, 0.0);
}
// -----------------------------------------------------------------------
// Phase G — action hierarchy
// -----------------------------------------------------------------------
/// Like [`make_app`] but with `TimePlugin` disabled and a manual
/// `Time` resource, so tests can step the win-summary delay timer
/// deterministically via `Time::advance_by` (the real clock's
/// microsecond deltas would never reach the 0.5 s threshold).
fn make_app_manual_clock() -> App {
use bevy::time::TimePlugin;
let mut app = App::new();
app.add_plugins(MinimalPlugins.build().disable::<TimePlugin>())
.add_plugins(WinSummaryPlugin)
.insert_resource(StatsResource(StatsSnapshot::default()))
.insert_resource(GameStateResource(GameState::new(
0,
solitaire_core::DrawStockConfig::DrawOne,
)))
.insert_resource(ProgressResource(PlayerProgress::default()));
app.init_resource::<Time>();
app.update();
app
}
/// Drives the real spawn path: fire `GameWonEvent`, then advance
/// `Time` past the 0.5 s celebration delay so
/// `spawn_win_summary_after_delay` spawns the overlay.
fn open_win_overlay(app: &mut App) {
app.world_mut().write_message(GameWonEvent {
score: 1200,
time_seconds: 90,
});
app.update();
{
let mut time = app.world_mut().resource_mut::<Time>();
time.advance_by(std::time::Duration::from_secs_f32(
WIN_SUMMARY_DELAY_SECS + 0.1,
));
}
app.update();
// One more frame so the deferred spawn commands flush.
app.update();
}
fn overlay_count(app: &mut App) -> usize {
app.world_mut()
.query::<&WinSummaryOverlay>()
.iter(app.world())
.count()
}
fn button_entity<M: Component>(app: &mut App) -> Entity {
let entities: Vec<Entity> = app
.world_mut()
.query_filtered::<Entity, With<M>>()
.iter(app.world())
.collect();
assert_eq!(entities.len(), 1, "expected exactly one button");
entities[0]
}
/// The win modal must render all three Phase G actions: the
/// primary Play Again plus the shared Watch/Share replay buttons.
#[test]
fn win_modal_renders_play_again_watch_and_share_actions() {
let mut app = make_app_manual_clock();
open_win_overlay(&mut app);
assert_eq!(overlay_count(&mut app), 1, "overlay must spawn after delay");
button_entity::<WinSummaryPlayAgainButton>(&mut app);
button_entity::<WatchReplayButton>(&mut app);
button_entity::<CopyShareLinkButton>(&mut app);
}
/// Play Again collapses the overlay and requests a fresh deal.
#[test]
fn play_again_press_closes_overlay_and_requests_new_game() {
use bevy::ecs::message::Messages;
let mut app = make_app_manual_clock();
open_win_overlay(&mut app);
let button = button_entity::<WinSummaryPlayAgainButton>(&mut app);
app.world_mut()
.entity_mut(button)
.insert(Interaction::Pressed);
app.update();
assert_eq!(overlay_count(&mut app), 0, "Play Again must close overlay");
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
assert!(
!events.is_empty(),
"Play Again must write NewGameRequestEvent"
);
}
/// Watch Replay collapses the overlay so playback (driven by the
/// global `stats_plugin` handler on the same press) is visible.
#[test]
fn watch_replay_press_closes_the_overlay() {
let mut app = make_app_manual_clock();
open_win_overlay(&mut app);
let button = button_entity::<WatchReplayButton>(&mut app);
app.world_mut()
.entity_mut(button)
.insert(Interaction::Pressed);
app.update();
assert_eq!(
overlay_count(&mut app),
0,
"Watch Replay must close the celebration overlay"
);
}
/// Share Replay leaves the overlay open — the player stays on the
/// celebration while the copy-feedback toast confirms the link.
#[test]
fn share_replay_press_keeps_the_overlay_open() {
let mut app = make_app_manual_clock();
open_win_overlay(&mut app);
let button = button_entity::<CopyShareLinkButton>(&mut app);
app.world_mut()
.entity_mut(button)
.insert(Interaction::Pressed);
app.update();
assert_eq!(
overlay_count(&mut app),
1,
"Share Replay must not close the overlay"
);
}
}
+486
View File
@@ -0,0 +1,486 @@
//! The "You" hub — Profile · Stats · Achievements · Replays folded
//! into one tabbed modal (Phase E of `docs/ui-redesign-2026-07.md`).
//!
//! The four screens were previously standalone modals reached one at a
//! time through the HUD popover. The hub owns the modal shell (header,
//! tab chips via the shared [`spawn_tab_chip`] widget, a single Done
//! button); each tab's content is a body builder that lives in its
//! original plugin (`spawn_profile_body`, `spawn_stats_body`,
//! `spawn_achievements_body`, `spawn_replays_body`) so every marker
//! component and per-row update system keeps working unchanged.
//!
//! Legacy screen markers (`ProfileScreen`, `StatsScreen`,
//! `AchievementsScreen`) are inserted on the hub scrim while their tab
//! is active, so existing queries and tests keep their meaning.
//!
//! Open paths: `ToggleProfileRequestEvent` / `ToggleStatsRequestEvent`
//! / `ToggleAchievementsRequestEvent` (HUD popover) and the P / S / A
//! accelerators — each opens the hub pre-selected to its tab, toggles
//! the hub closed when its tab is already showing, or switches tabs
//! when a different tab is showing. Esc, Done, and scrim-click close.
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use solitaire_data::StatsSnapshot;
use crate::achievement_plugin::{AchievementsResource, AchievementsScreen};
use crate::events::{
ToggleAchievementsRequestEvent, ToggleProfileRequestEvent, ToggleStatsRequestEvent,
};
use crate::font_plugin::FontResource;
use crate::profile_plugin::{AvatarResource, ProfileScreen};
use crate::progress_plugin::ProgressResource;
use crate::resources::SyncStatusResource;
use crate::settings_plugin::SettingsResource;
use crate::stats_plugin::{ReplayHistoryResource, SelectedReplayIndex, StatsResource, StatsScreen};
use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_focus::FocusRow;
use crate::ui_modal::{
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header, spawn_tab_chip,
};
use crate::ui_theme::{VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL};
/// Which tab of the You hub is showing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum YouTab {
#[default]
Profile,
Stats,
Achievements,
Replays,
}
impl YouTab {
/// Every tab, in display order.
pub const ALL: [Self; 4] = [
Self::Profile,
Self::Stats,
Self::Achievements,
Self::Replays,
];
/// Chip label.
pub fn label(self) -> &'static str {
match self {
Self::Profile => "Profile",
Self::Stats => "Stats",
Self::Achievements => "Awards",
Self::Replays => "Replays",
}
}
}
/// The active hub tab. Session-only; reopening returns to the last tab
/// unless an open request names a different one.
#[derive(Resource, Debug, Default)]
pub struct ActiveYouTab(pub YouTab);
/// Marker on the hub modal's scrim root.
#[derive(Component)]
pub struct YouHubScreen;
/// Per-chip tab selector button.
#[derive(Component, Debug)]
struct YouHubTabButton(YouTab);
/// Marker on the hub's Done button.
#[derive(Component)]
struct YouHubCloseButton;
/// Read-only bundle of everything the tab bodies render from. Split
/// out as a [`SystemParam`] because the open + rebuild systems both
/// need the full set and Bevy caps systems at 16 parameters.
#[derive(SystemParam)]
struct YouHubContext<'w> {
settings: Option<Res<'w, SettingsResource>>,
sync_status: Option<Res<'w, SyncStatusResource>>,
progress: Option<Res<'w, ProgressResource>>,
achievements: Option<Res<'w, AchievementsResource>>,
stats: Option<Res<'w, StatsResource>>,
avatar: Option<Res<'w, AvatarResource>>,
time_attack: Option<Res<'w, TimeAttackResource>>,
replay_history: Option<Res<'w, ReplayHistoryResource>>,
selected_replay: Option<Res<'w, SelectedReplayIndex>>,
font_res: Option<Res<'w, FontResource>>,
}
/// Bevy plugin owning the You hub lifecycle. Requires the profile,
/// stats, and achievement plugins for live data; degrades to empty tab
/// bodies without them (headless tests).
pub struct YouHubPlugin;
impl Plugin for YouHubPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ActiveYouTab>()
.add_message::<ToggleProfileRequestEvent>()
.add_message::<ToggleStatsRequestEvent>()
.add_message::<ToggleAchievementsRequestEvent>()
.add_systems(
Update,
// Chained: a toggle/chip press must be observed before
// the rebuild, and the rebuild before close — total
// order prevents double-spawns within one frame.
(
open_or_toggle_you_hub,
handle_tab_buttons,
rebuild_on_tab_change,
handle_close_button,
)
.chain(),
);
}
}
/// Maps this frame's toggle events + accelerator keys to a requested
/// tab, mirroring the semantics the three standalone screens had.
fn requested_tab(
profile_events: &mut MessageReader<ToggleProfileRequestEvent>,
stats_events: &mut MessageReader<ToggleStatsRequestEvent>,
achievements_events: &mut MessageReader<ToggleAchievementsRequestEvent>,
keys: &ButtonInput<KeyCode>,
) -> Option<YouTab> {
let profile = profile_events.read().count() > 0 || keys.just_pressed(KeyCode::KeyP);
let stats = stats_events.read().count() > 0 || keys.just_pressed(KeyCode::KeyS);
let achievements = achievements_events.read().count() > 0 || keys.just_pressed(KeyCode::KeyA);
if profile {
Some(YouTab::Profile)
} else if stats {
Some(YouTab::Stats)
} else if achievements {
Some(YouTab::Achievements)
} else {
None
}
}
/// Opens the hub on the requested tab, switches tabs when it's already
/// open on a different one, toggles it closed on a same-tab request or
/// Esc.
#[allow(clippy::too_many_arguments)]
fn open_or_toggle_you_hub(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
mut profile_events: MessageReader<ToggleProfileRequestEvent>,
mut stats_events: MessageReader<ToggleStatsRequestEvent>,
mut achievements_events: MessageReader<ToggleAchievementsRequestEvent>,
screens: Query<Entity, With<YouHubScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<YouHubScreen>)>,
mut active: ResMut<ActiveYouTab>,
ctx: YouHubContext,
) {
let requested = requested_tab(
&mut profile_events,
&mut stats_events,
&mut achievements_events,
&keys,
);
let open = !screens.is_empty();
if open {
// Esc closes the topmost modal — the hub, when it's showing.
if keys.just_pressed(KeyCode::Escape) {
for entity in &screens {
commands.entity(entity).despawn();
}
return;
}
match requested {
Some(tab) if tab == active.0 => {
// Same-tab request toggles closed (P opens Profile,
// P again closes — parity with the old screens).
for entity in &screens {
commands.entity(entity).despawn();
}
}
Some(tab) => {
// Different tab: switch in place. The rebuild system
// (next in the chain) observes the change.
active.0 = tab;
}
None => {}
}
return;
}
let Some(tab) = requested else { return };
if !other_modal_scrims.is_empty() {
return; // Another modal is already visible (§14.2).
}
if active.0 != tab {
// Written before the spawn; the rebuild system also runs this
// frame but sees no live hub (the spawn below is deferred), so
// no double-spawn.
active.0 = tab;
}
spawn_you_hub(&mut commands, tab, &ctx);
}
/// Switches the active tab when a chip is pressed.
fn handle_tab_buttons(
interactions: Query<(&Interaction, &YouHubTabButton), Changed<Interaction>>,
mut active: ResMut<ActiveYouTab>,
) {
for (interaction, chip) in &interactions {
if *interaction != Interaction::Pressed {
continue;
}
if active.0 != chip.0 {
active.0 = chip.0;
}
}
}
/// Rebuilds the open hub when [`ActiveYouTab`] changes.
fn rebuild_on_tab_change(
active: Res<ActiveYouTab>,
screens: Query<Entity, With<YouHubScreen>>,
mut commands: Commands,
ctx: YouHubContext,
) {
if !active.is_changed() || active.is_added() {
return;
}
if screens.is_empty() {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
spawn_you_hub(&mut commands, active.0, &ctx);
}
/// Despawns the hub when Done is pressed.
fn handle_close_button(
interactions: Query<&Interaction, (Changed<Interaction>, With<YouHubCloseButton>)>,
screens: Query<Entity, With<YouHubScreen>>,
mut commands: Commands,
) {
for interaction in &interactions {
if *interaction != Interaction::Pressed {
continue;
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
}
/// Spawns the hub modal showing `tab`, and stamps the scrim with the
/// tab's legacy screen marker so pre-hub queries keep working.
fn spawn_you_hub(commands: &mut Commands, tab: YouTab, ctx: &YouHubContext) {
let font_res = ctx.font_res.as_deref();
let scrim = spawn_modal(commands, YouHubScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "You", font_res);
// Tab chips — shared widget with the Settings panel.
card.spawn((
FocusRow,
Node {
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
column_gap: VAL_SPACE_2,
row_gap: VAL_SPACE_2,
margin: UiRect::bottom(VAL_SPACE_3),
..default()
},
))
.with_children(|row| {
for chip_tab in YouTab::ALL {
spawn_tab_chip(
row,
YouHubTabButton(chip_tab),
chip_tab.label(),
chip_tab == tab,
font_res,
);
}
});
match tab {
YouTab::Profile => crate::profile_plugin::spawn_profile_body(
card,
ctx.settings.as_deref(),
ctx.sync_status.as_deref(),
ctx.progress.as_deref(),
ctx.achievements.as_deref(),
ctx.stats.as_deref(),
font_res,
ctx.avatar.as_deref(),
),
YouTab::Stats => {
let default_stats = StatsSnapshot::default();
crate::stats_plugin::spawn_stats_body(
card,
ctx.stats.as_deref().map_or(&default_stats, |s| &s.0),
ctx.progress.as_deref().map(|p| &p.0),
ctx.time_attack.as_deref(),
font_res,
);
}
YouTab::Achievements => crate::achievement_plugin::spawn_achievements_body(
card,
ctx.achievements
.as_deref()
.map_or(&[][..], |a| a.0.as_slice()),
font_res,
),
YouTab::Replays => crate::stats_plugin::spawn_replays_body(
card,
ctx.replay_history
.as_deref()
.map_or(&[][..], |h| h.0.replays.as_slice()),
ctx.selected_replay.as_deref().map_or(0, |s| s.0),
font_res,
),
}
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
YouHubCloseButton,
"Done",
None,
ButtonVariant::Primary,
font_res,
);
});
});
let mut scrim_commands = commands.entity(scrim);
scrim_commands.insert(ScrimDismissible);
// Legacy markers: pre-hub code and tests query these.
match tab {
YouTab::Profile => {
scrim_commands.insert(ProfileScreen);
}
YouTab::Stats | YouTab::Replays => {
scrim_commands.insert(StatsScreen);
}
YouTab::Achievements => {
scrim_commands.insert(AchievementsScreen);
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(YouHubPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
fn hub_count(app: &mut App) -> usize {
app.world_mut()
.query::<&YouHubScreen>()
.iter(app.world())
.count()
}
/// Each Toggle*RequestEvent opens the hub pre-selected to its tab,
/// stamped with the tab's legacy screen marker.
#[test]
fn toggle_events_open_hub_on_their_tab() {
use bevy::ecs::message::Messages;
let mut app = headless_app();
app.world_mut()
.resource_mut::<Messages<ToggleStatsRequestEvent>>()
.write(ToggleStatsRequestEvent);
app.update();
app.update();
assert_eq!(hub_count(&mut app), 1, "stats request must open the hub");
assert_eq!(app.world().resource::<ActiveYouTab>().0, YouTab::Stats);
assert_eq!(
app.world_mut()
.query::<&StatsScreen>()
.iter(app.world())
.count(),
1,
"legacy StatsScreen marker must ride the hub scrim"
);
// A different tab's request switches in place — still one hub.
app.world_mut()
.resource_mut::<Messages<ToggleAchievementsRequestEvent>>()
.write(ToggleAchievementsRequestEvent);
app.update();
app.update();
assert_eq!(hub_count(&mut app), 1, "tab switch must not stack hubs");
assert_eq!(
app.world().resource::<ActiveYouTab>().0,
YouTab::Achievements
);
assert_eq!(
app.world_mut()
.query::<&AchievementsScreen>()
.iter(app.world())
.count(),
1
);
// Same-tab request toggles the hub closed.
app.world_mut()
.resource_mut::<Messages<ToggleAchievementsRequestEvent>>()
.write(ToggleAchievementsRequestEvent);
app.update();
app.update();
assert_eq!(hub_count(&mut app), 0, "same-tab request must close");
}
/// Chip-driven tab switches rebuild the single hub with the new
/// tab's body (Profile scrollable swaps for the Stats one).
#[test]
fn tab_switch_swaps_bodies_without_stacking() {
use crate::profile_plugin::ProfileScrollable;
use crate::stats_plugin::StatsScrollable;
use bevy::ecs::message::Messages;
let mut app = headless_app();
app.world_mut()
.resource_mut::<Messages<ToggleProfileRequestEvent>>()
.write(ToggleProfileRequestEvent);
app.update();
app.update();
assert_eq!(
app.world_mut()
.query::<&ProfileScrollable>()
.iter(app.world())
.count(),
1,
"profile body must spawn on the Profile tab"
);
app.world_mut().resource_mut::<ActiveYouTab>().0 = YouTab::Stats;
app.update();
app.update();
assert_eq!(hub_count(&mut app), 1, "rebuild must not stack scrims");
assert_eq!(
app.world_mut()
.query::<&ProfileScrollable>()
.iter(app.world())
.count(),
0,
"profile body must despawn when leaving the tab"
);
assert_eq!(
app.world_mut()
.query::<&StatsScrollable>()
.iter(app.world())
.count(),
1,
"stats body must spawn on the Stats tab"
);
}
}
+67
View File
@@ -1,3 +1,68 @@
# --- WASM build stage ---
# Builds solitaire_server/web/pkg/ (replay viewer + Bevy canvas app) from
# source. The artifacts are not committed to the repo (issue #156); this
# stage is their single source of truth for deployment.
FROM rust:1.95-slim AS wasm-builder
WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN rustup target add wasm32-unknown-unknown
# Pinned wasm toolchain — keep versions in sync with
# .gitea/workflows/web-e2e.yml and build_wasm.sh prerequisites.
RUN curl -sSL https://github.com/rustwasm/wasm-bindgen/releases/download/0.2.120/wasm-bindgen-0.2.120-x86_64-unknown-linux-musl.tar.gz \
| tar xz -C /opt \
&& ln -s /opt/wasm-bindgen-0.2.120-x86_64-unknown-linux-musl/wasm-bindgen /usr/local/bin/wasm-bindgen \
&& curl -sSL https://github.com/rustwasm/wasm-pack/releases/download/v0.14.0/wasm-pack-v0.14.0-x86_64-unknown-linux-musl.tar.gz \
| tar xz -C /opt \
&& ln -s /opt/wasm-pack-v0.14.0-x86_64-unknown-linux-musl/wasm-pack /usr/local/bin/wasm-pack \
&& curl -sSL https://github.com/WebAssembly/binaryen/releases/download/version_130/binaryen-version_130-x86_64-linux.tar.gz \
| tar xz -C /opt \
&& ln -s /opt/binaryen-version_130/bin/wasm-opt /usr/local/bin/wasm-opt
# Manifests first so the dependency-fetch layer caches across source changes
# (same pattern as the server build stage below).
COPY .cargo/config.toml ./.cargo/config.toml
COPY Cargo.toml Cargo.lock ./
COPY solitaire_core/Cargo.toml ./solitaire_core/Cargo.toml
COPY solitaire_sync/Cargo.toml ./solitaire_sync/Cargo.toml
COPY solitaire_data/Cargo.toml ./solitaire_data/Cargo.toml
COPY solitaire_engine/Cargo.toml ./solitaire_engine/Cargo.toml
COPY solitaire_server/Cargo.toml ./solitaire_server/Cargo.toml
COPY solitaire_app/Cargo.toml ./solitaire_app/Cargo.toml
COPY solitaire_assetgen/Cargo.toml ./solitaire_assetgen/Cargo.toml
COPY solitaire_wasm/Cargo.toml ./solitaire_wasm/Cargo.toml
COPY solitaire_web/Cargo.toml ./solitaire_web/Cargo.toml
RUN for crate in solitaire_core solitaire_sync solitaire_data solitaire_engine \
solitaire_server solitaire_app solitaire_assetgen solitaire_wasm solitaire_web; do \
mkdir -p $crate/src && echo "pub fn _stub() {}" > $crate/src/lib.rs; \
done && \
echo "fn main() {}" > solitaire_server/src/main.rs && \
echo "fn main() {}" > solitaire_app/src/main.rs && \
echo "fn main() {}" > solitaire_assetgen/src/main.rs
RUN cargo fetch --locked
# Real source for the wasm-feeding crates. Whole crate directories (not just
# src/) because solitaire_engine embeds theme/audio/font assets at compile
# time from its own assets/ and the workspace assets/.
COPY build_wasm.sh ./
COPY solitaire_core ./solitaire_core
COPY solitaire_sync ./solitaire_sync
COPY solitaire_data ./solitaire_data
COPY solitaire_engine ./solitaire_engine
COPY solitaire_wasm ./solitaire_wasm
COPY solitaire_web ./solitaire_web
COPY assets ./assets
RUN ./build_wasm.sh
# --- Build stage ---
FROM rust:1.95-slim AS builder
@@ -67,6 +132,8 @@ COPY --from=builder /build/target/release/solitaire_server ./server
# /app/assets → /assets route
# Card themes (dark + classic) are embedded in the binary; no theme files needed here.
COPY solitaire_server/web ./solitaire_server/web
# The wasm bundles are never in the repo — they come from the wasm-builder stage.
COPY --from=wasm-builder /build/solitaire_server/web/pkg ./solitaire_server/web/pkg
COPY assets ./assets
ENV SERVER_PORT=8080
@@ -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);
File diff suppressed because it is too large Load Diff
Binary file not shown.
-595
View File
@@ -1,595 +0,0 @@
/**
* Browser-side replay state machine. Owns a live `GameState` and the
* replay's move list; each `step()` applies the next move.
*/
export class ReplayPlayer {
__destroy_into_raw() {
const ptr = this.__wbg_ptr;
this.__wbg_ptr = 0;
ReplayPlayerFinalization.unregister(this);
return ptr;
}
free() {
const ptr = this.__destroy_into_raw();
wasm.__wbg_replayplayer_free(ptr, 0);
}
/**
* Returns `true` once every move has been applied.
* @returns {boolean}
*/
is_finished() {
const ret = wasm.replayplayer_is_finished(this.__wbg_ptr);
return ret !== 0;
}
/**
* Construct from a raw replay JSON string.
* @param {string} replay_json
*/
constructor(replay_json) {
const ptr0 = passStringToWasm0(replay_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.replayplayer_new(ptr0, len0);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
this.__wbg_ptr = ret[0];
ReplayPlayerFinalization.register(this, this.__wbg_ptr, this);
return this;
}
/**
* Snapshot the current `GameState` as a JS object (see `StateSnapshot`).
*
* Throws a JS string exception on serialisation failure (should never
* occur in practice `StateSnapshot` contains only primitive types).
* @returns {any}
*/
state() {
const ret = wasm.replayplayer_state(this.__wbg_ptr);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
}
/**
* Apply the next move; returns the post-step snapshot, or `null`
* once the move list is exhausted.
*
* Returns `null` (not an exception) when the replay is finished.
* Throws `"replay_desync"` when the next recorded move is illegal for
* the current state, and logs the underlying core error to the JS console.
* Throws a JS string exception on serialisation failure.
* @returns {any}
*/
step() {
const ret = wasm.replayplayer_step(this.__wbg_ptr);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
}
/**
* 0-indexed position of the next move to apply.
* @returns {number}
*/
step_idx() {
const ret = wasm.replayplayer_step_idx(this.__wbg_ptr);
return ret >>> 0;
}
/**
* Total number of moves the replay contains.
* @returns {number}
*/
total_steps() {
const ret = wasm.replayplayer_total_steps(this.__wbg_ptr);
return ret >>> 0;
}
}
if (Symbol.dispose) ReplayPlayer.prototype[Symbol.dispose] = ReplayPlayer.prototype.free;
/**
* Interactive Klondike game backed by the real `solitaire_core` rules engine.
*
* Construct with `new(seed, draw_three)`, then call `draw()`, `move_cards()`,
* `undo()`, `auto_complete_step()` to advance the game. `state()` returns the
* full pile snapshot at any time without mutating state.
*/
export class SolitaireGame {
static __wrap(ptr) {
const obj = Object.create(SolitaireGame.prototype);
obj.__wbg_ptr = ptr;
SolitaireGameFinalization.register(obj, obj.__wbg_ptr, obj);
return obj;
}
__destroy_into_raw() {
const ptr = this.__wbg_ptr;
this.__wbg_ptr = 0;
SolitaireGameFinalization.unregister(this);
return ptr;
}
free() {
const ptr = this.__destroy_into_raw();
wasm.__wbg_solitairegame_free(ptr, 0);
}
/**
* Apply one auto-complete move (only valid when `is_auto_completable`).
*
* If no card can go directly to a foundation this step, advances the
* waste by calling `draw()` so the next step can try again. Returns the
* post-move snapshot, or `null` when no progress is possible.
* @returns {any}
*/
auto_complete_step() {
const ret = wasm.solitairegame_auto_complete_step(this.__wbg_ptr);
return ret;
}
/**
* Applies the legal move currently at `index` from `debug_legal_moves()`.
* @param {number} index
* @returns {any}
*/
debug_apply_legal_move(index) {
const ret = wasm.solitairegame_debug_apply_legal_move(this.__wbg_ptr, index);
return ret;
}
/**
* Applies one debug move encoded as JSON.
*
* JSON must match [`DebugMove`], for example:
* `{"kind":"move","from":"tableau-0","to":"foundation-1","count":1}` or
* `{"kind":"stock_click"}`.
* @param {string} move_json
* @returns {any}
*/
debug_apply_move_json(move_json) {
const ptr0 = passStringToWasm0(move_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.solitairegame_debug_apply_move_json(this.__wbg_ptr, ptr0, len0);
return ret;
}
/**
* Returns all currently-legal debug moves as a JS array.
*
* Includes [`DebugMove::StockClick`] when stock interaction is legal.
* @returns {any}
*/
debug_legal_moves() {
const ret = wasm.solitairegame_debug_legal_moves(this.__wbg_ptr);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
}
/**
* Returns deterministic instruction history for the current game.
*
* Together with `seed()` and `draw_mode`, this history is replayable.
* @returns {any}
*/
debug_move_history() {
const ret = wasm.solitairegame_debug_move_history(this.__wbg_ptr);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
}
/**
* Returns a comprehensive debug snapshot for automated verification.
* @returns {any}
*/
debug_snapshot() {
const ret = wasm.solitairegame_debug_snapshot(this.__wbg_ptr);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
}
/**
* Draw from stock to waste (or recycle waste stock when stock is empty).
* Returns `{ok, error?, snapshot?}`.
* @returns {any}
*/
draw() {
const ret = wasm.solitairegame_draw(this.__wbg_ptr);
return ret;
}
/**
* Restore a game from a JSON string previously produced by [`SolitaireGame::serialize`].
*
* Returns an error string if the JSON is malformed or describes a state
* that can't be deserialised (e.g. from a future schema version).
* @param {string} json
* @returns {SolitaireGame}
*/
static from_saved(json) {
const ptr0 = passStringToWasm0(json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.solitairegame_from_saved(ptr0, len0);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return SolitaireGame.__wrap(ret[0]);
}
/**
* Move `count` cards from pile `from` to pile `to`.
*
* Pile names: `"stock"`, `"waste"`, `"foundation-0"` .. `"foundation-3"`,
* `"tableau-0"` .. `"tableau-6"`.
*
* Returns `{ok, error?, snapshot?}`.
* @param {string} from
* @param {string} to
* @param {number} count
* @returns {any}
*/
move_cards(from, to, count) {
const ptr0 = passStringToWasm0(from, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passStringToWasm0(to, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
const ret = wasm.solitairegame_move_cards(this.__wbg_ptr, ptr0, len0, ptr1, len1, count);
return ret;
}
/**
* Create a new DrawOne or DrawThree Classic game from the given seed.
*
* `seed` is a JS `number` (f64); values up to 2^53 are represented exactly.
* Pass `Date.now()` or a random integer from JS for variety.
* @param {number} seed
* @param {boolean} draw_three
*/
constructor(seed, draw_three) {
const ret = wasm.solitairegame_new(seed, draw_three);
this.__wbg_ptr = ret;
SolitaireGameFinalization.register(this, this.__wbg_ptr, this);
return this;
}
/**
* Returns replay moves encoded in the `solitaire_data::Replay` wire format
* a list of upstream [`KlondikeInstruction`]s.
*
* This is the deterministic instruction history; together with `seed()`
* and the draw mode it replays cleanly via `apply_instruction`.
* @returns {any}
*/
replay_moves() {
const ret = wasm.solitairegame_replay_moves(this.__wbg_ptr);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
}
/**
* The seed used to deal this game.
* @returns {number}
*/
seed() {
const ret = wasm.solitairegame_seed(this.__wbg_ptr);
return ret;
}
/**
* Serialise the full game state as a JSON string for `localStorage`.
*
* Use [`SolitaireGame::from_saved`] to restore it. The returned string is
* opaque callers should treat it as a blob and store/restore it verbatim.
* @returns {string}
*/
serialize() {
let deferred2_0;
let deferred2_1;
try {
const ret = wasm.solitairegame_serialize(this.__wbg_ptr);
var ptr1 = ret[0];
var len1 = ret[1];
if (ret[3]) {
ptr1 = 0; len1 = 0;
throw takeFromExternrefTable0(ret[2]);
}
deferred2_0 = ptr1;
deferred2_1 = len1;
return getStringFromWasm0(ptr1, len1);
} finally {
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
}
}
/**
* Full pile snapshot as a JS object.
*
* Throws a JS string exception on serialisation failure.
* @returns {any}
*/
state() {
const ret = wasm.solitairegame_state(this.__wbg_ptr);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
}
/**
* Undo the last move. Returns `{ok, error?, snapshot?}`.
* @returns {any}
*/
undo() {
const ret = wasm.solitairegame_undo(this.__wbg_ptr);
return ret;
}
}
if (Symbol.dispose) SolitaireGame.prototype[Symbol.dispose] = SolitaireGame.prototype.free;
function __wbg_get_imports() {
const import0 = {
__proto__: null,
__wbg_Error_3639a60ed15f87e7: function(arg0, arg1) {
const ret = Error(getStringFromWasm0(arg0, arg1));
return ret;
},
__wbg_String_8564e559799eccda: function(arg0, arg1) {
const ret = String(arg1);
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
},
__wbg___wbindgen_throw_9c75d47bf9e7731e: function(arg0, arg1) {
throw new Error(getStringFromWasm0(arg0, arg1));
},
__wbg_error_48655ee7e4756f8b: function(arg0) {
console.error(arg0);
},
__wbg_error_a6fa202b58aa1cd3: function(arg0, arg1) {
let deferred0_0;
let deferred0_1;
try {
deferred0_0 = arg0;
deferred0_1 = arg1;
console.error(getStringFromWasm0(arg0, arg1));
} finally {
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
}
},
__wbg_new_227d7c05414eb861: function() {
const ret = new Error();
return ret;
},
__wbg_new_2fad8ca02fd00684: function() {
const ret = new Object();
return ret;
},
__wbg_new_3baa8d9866155c79: function() {
const ret = new Array();
return ret;
},
__wbg_set_6be42768c690e380: function(arg0, arg1, arg2) {
arg0[arg1] = arg2;
},
__wbg_set_f614f6a0608d1d1d: function(arg0, arg1, arg2) {
arg0[arg1 >>> 0] = arg2;
},
__wbg_stack_3b0d974bbf31e44f: function(arg0, arg1) {
const ret = arg1.stack;
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
},
__wbindgen_cast_0000000000000001: function(arg0) {
// Cast intrinsic for `F64 -> Externref`.
const ret = arg0;
return ret;
},
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Ref(String) -> Externref`.
const ret = getStringFromWasm0(arg0, arg1);
return ret;
},
__wbindgen_cast_0000000000000003: function(arg0) {
// Cast intrinsic for `U64 -> Externref`.
const ret = BigInt.asUintN(64, arg0);
return ret;
},
__wbindgen_init_externref_table: function() {
const table = wasm.__wbindgen_externrefs;
const offset = table.grow(4);
table.set(0, undefined);
table.set(offset + 0, undefined);
table.set(offset + 1, null);
table.set(offset + 2, true);
table.set(offset + 3, false);
},
};
return {
__proto__: null,
"./solitaire_wasm_bg.js": import0,
};
}
const ReplayPlayerFinalization = (typeof FinalizationRegistry === 'undefined')
? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry(ptr => wasm.__wbg_replayplayer_free(ptr, 1));
const SolitaireGameFinalization = (typeof FinalizationRegistry === 'undefined')
? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry(ptr => wasm.__wbg_solitairegame_free(ptr, 1));
let cachedDataViewMemory0 = null;
function getDataViewMemory0() {
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
}
return cachedDataViewMemory0;
}
function getStringFromWasm0(ptr, len) {
return decodeText(ptr >>> 0, len);
}
let cachedUint8ArrayMemory0 = null;
function getUint8ArrayMemory0() {
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
}
return cachedUint8ArrayMemory0;
}
function passStringToWasm0(arg, malloc, realloc) {
if (realloc === undefined) {
const buf = cachedTextEncoder.encode(arg);
const ptr = malloc(buf.length, 1) >>> 0;
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
WASM_VECTOR_LEN = buf.length;
return ptr;
}
let len = arg.length;
let ptr = malloc(len, 1) >>> 0;
const mem = getUint8ArrayMemory0();
let offset = 0;
for (; offset < len; offset++) {
const code = arg.charCodeAt(offset);
if (code > 0x7F) break;
mem[ptr + offset] = code;
}
if (offset !== len) {
if (offset !== 0) {
arg = arg.slice(offset);
}
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
const ret = cachedTextEncoder.encodeInto(arg, view);
offset += ret.written;
ptr = realloc(ptr, len, offset, 1) >>> 0;
}
WASM_VECTOR_LEN = offset;
return ptr;
}
function takeFromExternrefTable0(idx) {
const value = wasm.__wbindgen_externrefs.get(idx);
wasm.__externref_table_dealloc(idx);
return value;
}
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
const MAX_SAFARI_DECODE_BYTES = 2146435072;
let numBytesDecoded = 0;
function decodeText(ptr, len) {
numBytesDecoded += len;
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
numBytesDecoded = len;
}
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}
const cachedTextEncoder = new TextEncoder();
if (!('encodeInto' in cachedTextEncoder)) {
cachedTextEncoder.encodeInto = function (arg, view) {
const buf = cachedTextEncoder.encode(arg);
view.set(buf);
return {
read: arg.length,
written: buf.length
};
};
}
let WASM_VECTOR_LEN = 0;
let wasmModule, wasmInstance, wasm;
function __wbg_finalize_init(instance, module) {
wasmInstance = instance;
wasm = instance.exports;
wasmModule = module;
cachedDataViewMemory0 = null;
cachedUint8ArrayMemory0 = null;
wasm.__wbindgen_start();
return wasm;
}
async function __wbg_load(module, imports) {
if (typeof Response === 'function' && module instanceof Response) {
if (typeof WebAssembly.instantiateStreaming === 'function') {
try {
return await WebAssembly.instantiateStreaming(module, imports);
} catch (e) {
const validResponse = module.ok && expectedResponseType(module.type);
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
} else { throw e; }
}
}
const bytes = await module.arrayBuffer();
return await WebAssembly.instantiate(bytes, imports);
} else {
const instance = await WebAssembly.instantiate(module, imports);
if (instance instanceof WebAssembly.Instance) {
return { instance, module };
} else {
return instance;
}
}
function expectedResponseType(type) {
switch (type) {
case 'basic': case 'cors': case 'default': return true;
}
return false;
}
}
function initSync(module) {
if (wasm !== undefined) return wasm;
if (module !== undefined) {
if (Object.getPrototypeOf(module) === Object.prototype) {
({module} = module)
} else {
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
}
}
const imports = __wbg_get_imports();
if (!(module instanceof WebAssembly.Module)) {
module = new WebAssembly.Module(module);
}
const instance = new WebAssembly.Instance(module, imports);
return __wbg_finalize_init(instance, module);
}
async function __wbg_init(module_or_path) {
if (wasm !== undefined) return wasm;
if (module_or_path !== undefined) {
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
({module_or_path} = module_or_path)
} else {
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
}
}
if (module_or_path === undefined) {
module_or_path = new URL('solitaire_wasm_bg.wasm', import.meta.url);
}
const imports = __wbg_get_imports();
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
module_or_path = fetch(module_or_path);
}
const { instance, module } = await __wbg_load(await module_or_path, imports);
return __wbg_finalize_init(instance, module);
}
export { initSync, __wbg_init as default };
Binary file not shown.
+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 -51
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,
})
}
@@ -228,11 +245,6 @@ impl ReplayPlayer {
pub fn step_idx(&self) -> usize {
self.step_idx
}
/// Returns `true` once every move has been applied.
pub fn is_finished(&self) -> bool {
self.step_idx >= self.moves.len()
}
}
// ---------------------------------------------------------------------------
@@ -515,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);
@@ -699,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.
@@ -902,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,
@@ -967,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 {