Compare commits

..

130 Commits

Author SHA1 Message Date
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 2c2f0b592a feat(core): Spider rules as a second card_game::Game implementation
Test / test (pull_request) Successful in 7m37s
Two-deck (104-card) Spider on the upstream Stack/Pile containers,
exercising the multi-deck Card encoding for the first time:

- SpiderGame: 10-pile deal (4x6 + 6x5), build-down-any-suit,
  same-suit-run pickup, deal-10 gated on no empty pile, automatic
  K->A run removal, win at 8 runs
- SpiderSuits difficulty (1/2/4 suits over the same 104 cards);
  1- and 2-suit games contain identical Card values by construction
  (documented — engine entity mapping will need positional keys)
- Seeded deals via inline SplitMix64 + Fisher-Yates (core has no
  rand dep; Spider's seed space is deliberately self-contained)
- SpiderGameState session wrapper mirroring GameState conventions:
  Result<_, MoveError> mutations, upstream undo/score bookkeeping,
  Microsoft-style scoring (500 base, -1/move, +100/run, -1/undo)
- 17 unit tests + card-conservation/validity proptest over random
  legal walks; stacked-deal win-path test included

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:20:51 -07: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
funman300 6f27e775f2 chore(deploy): mount theme store at /data/theme_store
Points THEME_STORE_DIR at the existing solitaire-db PVC so catalog
content survives pod restarts. Scan is startup-only; restart the
deployment after adding zips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:47:28 -07:00
Gitea CI 358bbc7eb5 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m23s
Web E2E / web-e2e (push) Successful in 3m57s
2026-07-07 21:17:01 +00:00
funman300 444f8d7e33 Merge pull request 'feat: in-game theme store — server catalog + verified downloads + install UI' (#154) from feat/theme-store into master
Build and Deploy / build-and-push (push) Successful in 6m2s
Test / test (push) Failing after 8m38s
Web E2E / web-e2e (push) Successful in 6m55s
Web WASM Rebuild / rebuild (push) Successful in 9m25s
2026-07-07 20:23:52 +00:00
funman300 a80547c514 Merge pull request 'fix: July 7 code-review remediation (M1–M3, L1–L3)' (#153) from fix/review-2026-07-07 into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 5m56s
Test / test (push) Successful in 13m51s
Web E2E / web-e2e (push) Successful in 4m18s
2026-07-07 20:16:19 +00:00
funman300 d87397b382 feat: in-game theme store — server catalog + verified downloads + install UI
Test / test (pull_request) Successful in 10m15s
Phase 1+2 of the theme-store roadmap: a free catalog served by
solitaire_server and an in-app browse/install flow, making custom
themes installable on Android for the first time (the manual
drop-a-zip flow can't reach the app-private themes dir there).

- solitaire_sync: ThemeCatalogEntry/ThemeCatalogResponse wire types
  (additive module; SyncPayload and SyncProvider untouched)
- solitaire_server: THEME_STORE_DIR scan at startup (meta-only
  theme.ron parse, sha256, 20 MiB cap, best-effort per archive);
  public GET /api/themes, /api/themes/{id}/download, /{id}/preview;
  compose volume + README_SERVER docs
- solitaire_data: ThemeStoreClient — catalog fetch + download with
  mandatory size/sha256 verification before bytes are released
- solitaire_engine: ThemeStorePlugin — 'Browse theme store' button in
  Settings → Cosmetic, modal catalog (leaderboard-style rebuild),
  download on AsyncComputeTaskPool, atomic .tmp+rename write into
  user_theme_dir, then the existing hardened import_theme pipeline and
  an in-place registry refresh

New deps: sha2 (workspace, server+data); ron/zip reused in server;
serde_json added to solitaire_sync dev-deps for DTO round-trip tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:12:41 -07:00
funman300 018b69285d fix(engine): unify light/dark theme manifest URL resolution
Test / test (pull_request) Successful in 13m4s
- set_theme resolved every id to themes:// — the bundled dark/classic
  themes live at embedded://, so switching to them via the public API
  would NotFound and silently keep the old theme. All three load paths
  now share one theme_manifest_url resolver.
- load_initial_theme's settings-absent fallback was "classic", a
  leftover from v0.33 when classic was the default; it now derives from
  Settings::default() so it can't drift from the data crate again, with
  a test pinning the default id to a bundled theme.
- settings.rs doc no longer claims "classic" is migrated to "dark";
  only the pre-rename "default" id is rewritten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:25:35 -07:00
Gitea CI a4ad848c93 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 5m28s
Web E2E / web-e2e (push) Successful in 4m12s
2026-07-07 18:54:30 +00:00
funman300 ff8c00d2f4 Merge pull request 'fix(engine): poll decor-view size to catch fold resizes winit misses (#130)' (#152) from fix/130-fold-stale-relayout into master
Build and Deploy / build-and-push (push) Successful in 1m56s
Test / test (push) Failing after 16m17s
Web WASM Rebuild / rebuild (push) Successful in 8m23s
Android Release / build-apk (push) Successful in 4m19s
2026-07-07 18:12:57 +00:00
funman300 abf1312cf5 fix(data): serialise token refreshes so overlapping 401s can't force a logout
Test / test (pull_request) Successful in 15m33s
Refresh tokens are single-use since the rotation change (PR #136). Two
in-flight requests hitting 401 together (replay upload racing a manual
sync) both called /api/auth/refresh; the second presented an already-
consumed token, got rejected, and surfaced as a spurious 'session
expired' re-login prompt.

refresh_token() now takes the stale access token that earned the 401
and runs behind a tokio::sync::Mutex; a loser of the lock race that
finds the stored token already rotated returns Ok and retries with it
instead of spending the new refresh token.

Untested caveat: exercising the race in a unit test needs an in-memory
auth_tokens double (the real keyring is unavailable on headless Linux
runners) — noted for a future test-support addition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:04:30 -07:00
funman300 8b09c51271 docs(architecture): correct KlondikePile sketch — upstream has no Waste variant
§8 documented a Waste variant that doesn't exist in klondike 0.4; the
real convention (Stock denotes the waste pile in pile-coordinate space)
is now stated where the enum is sketched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:01:42 -07:00
funman300 f61573513c fix(engine): remove latent unreachable! panic in difficulty seed cursor
The Random arm was only unreachable because seeds_for(Random) returns
None in a different function — a future catalog change would turn it
into a shipped runtime panic. Returning a system-time seed is the
correct Random behaviour either way. CLAUDE.md §2.3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:01:25 -07:00
funman300 757c35e4a0 fix(engine): degrade gracefully when the platform data dir is missing
user_theme_dir() panicked on desktop when dirs::data_dir() returned
None (broken $HOME / $XDG_*). Return an empty path instead — exactly
what the wasm32 branch already did — so theme discovery reports 'no
user themes' and the bundled default keeps working. Warns once with
the set_user_theme_dir() workaround. CLAUDE.md §2.3: no panic! in
runtime logic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:00:20 -07:00
funman300 113a933170 style: cargo fmt under rustfmt 1.9 and gate formatting in CI
The repo was formatted under an older stable; rustfmt 1.9 (Rust 1.95)
wraps signatures and call sites differently, so every touched file was
picking up unrelated formatting hunks. One mechanical pass, and a
'cargo fmt --check' step in the test workflow (same pinned 1.95.0
toolchain) so drift can't accumulate again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:59:20 -07:00
funman300 18bb1fa0be fix(ci): cap test-build debuginfo so the linker fits runner memory
Two runs (447, 486) died with 'ld terminated with signal 9' linking the
solitaire_engine test binary — the runner OOMs on full dev debuginfo for
the Bevy dependency graph. line-tables-only preserves file:line panic
backtraces at a fraction of the link footprint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:58:43 -07:00
funman300 38b81a4004 fix(engine): poll decor-view size to catch fold resizes winit misses
Test / test (pull_request) Successful in 23m3s
winit's Android backend does not forward content-rect changes that happen
while backgrounded (open TODOs in winit 0.30 logged on every resume), so a
fold/unfold cycle can leave Bevy rendering and laying out for the previous
screen: tableau clipped off the left edge, bottom third empty (#130).

Add a continuous decor-view size poller (JNI, every 30 frames) that, on
mismatch with the cached Window resolution: writes the real physical size
into window.resolution, emits a synthetic WindowResized in logical pixels,
and re-arms the safe-area inset poller — covering both the fold-size and
inset-staleness cases in one mechanism, without relying on
AppLifecycle::WillResume being delivered (it may never be; a new evidence
log in rearm_on_resumed settles that question on-device).

Closes #130

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:49:17 -07:00
Gitea CI ae7af9adf4 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 7m12s
Web E2E / web-e2e (push) Successful in 5m16s
2026-07-07 09:12:14 +00:00
funman300 c0cd7c2c15 Merge pull request 'docs(changelog): cut v0.42.0 section' (#151) from docs/changelog-v0.42.0 into master
Android Release / build-apk (push) Successful in 5m20s
2026-07-07 04:37:30 +00:00
funman300 ac002d8255 docs(changelog): cut v0.42.0 section
Test / test (pull_request) Successful in 24m8s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:37:29 -07:00
funman300 0fc1fa139e Merge pull request 'docs(changelog): record the July 6 evening arc' (#150) from docs/changelog-evening-arc into master 2026-07-07 04:14:51 +00:00
funman300 4f0c5bb808 docs(changelog): record the July 6 evening arc
Test / test (pull_request) Failing after 15m12s
CI gate, ambiguity gate + same-day 302-to-zero burn-down, 36% smaller
browser canvas, Quaternions API adoption, sync/auth concurrency fixes,
bounded exit push, server auth hardening.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:14:50 -07:00
funman300 a6b22df666 Merge pull request 'refactor(engine): ambiguity burn-down batch 4 — ZERO ambiguities, gate enforced' (#149) from refactor/ambiguity-tail into master
Build and Deploy / build-and-push (push) Successful in 2m18s
Test / test (push) Failing after 13m51s
Web WASM Rebuild / rebuild (push) Successful in 8m37s
2026-07-07 04:14:00 +00:00
funman300 b402c01918 refactor(engine): ambiguity burn-down batch 4 — zero ambiguities, gate enforced
Test / test (pull_request) Failing after 17m5s
Clears the final 47 pairs and turns the ratchet into a hard gate
(AMBIGUITY_BASELINE = 0, assert_eq):

- HudButtons: the 14 HUD button/popover handlers run as one chain,
  before ui_focus::FocusKeys — Esc/keyboard consumption order is now
  defined (restore prompt → buttons/popovers → focus navigation →
  settings toggle) instead of scheduler-dependent.
- HUD text updaters (update_hud, update_selection_hud,
  update_won_previously) chained, in UiTextFx, after the new
  AutoComplete set (update_hud reads AutoCompleteState).
- restore_hud_on_modal → apply_hud_visibility chained before
  UpdateOnResize: HudVisibility writes, application, and the layout
  read happen in a fixed order.
- New writer sets UndoRequestWriters / InfoToastWriters (same
  self-ambiguous pattern as NewGameRequestWriters).
- Logic-before-paint: check_no_moves and the AutoComplete chain order
  before BoardVisuals; SettingsMutation after UpdateOnResize.
- MarkerVisuals set wraps the table painter chain; chrome fx declare
  disjointness from it.
- update_hud_typography after BoardVisuals; avatar/settings-toggle
  interaction handlers declared disjoint from HudButtons.

302 → 198 → 171 → 47 → 0 in four batches, one day. New systems now
fail CI unless they declare their ordering or their disjointness.

Closes #143

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:13:58 -07:00
funman300 be478acde7 Merge pull request 'refactor(engine): ambiguity burn-down batch 3 — board paint chain (171 → 47)' (#148) from refactor/ambiguity-board-visuals into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 2m20s
Test / test (push) Successful in 25m2s
2026-07-07 03:57:20 +00:00
funman300 379873765d refactor(engine): ambiguity burn-down batch 3 — board paint chain, 171 → 47
Test / test (pull_request) Failing after 17m32s
The Sprite/Transform cluster (112 pairs) was every board painter
racing every other one. Two structural moves:

- BoardVisuals set (card_plugin): all card/marker painters run as ONE
  deterministic chain in data-flow order — layout refinement → card
  authority (sync_cards_on_change) → flip anims → shadows → highlights
  → stock indicators → resize snapping → corner labels — with
  LayoutSystem::UpdateOnResize ordered before the whole set and the
  table plugin's marker painters chained after it. Paint order is now
  identical every frame instead of scheduler-dependent.

- UiTextFx set (ui_theme): chrome text effects (score pulse/floater,
  streak flourish, modal enter, focus-ring pulse) animate Transform on
  UI entities only; they are declared ambiguous with BoardVisuals and
  with each other — the entity domains are disjoint by construction.

All chain members are cheap and change-gated; sequential execution is
not a measurable cost for a card game. Existing ordering constraints
(fan-frac before sync, shadows after sync, snap after collect) are
preserved inside the chain.

Baseline ratchets 171 → 47. Remaining: long tail of small subjects
(input, event writers, AutoCompleteState, HudVisibility).

Refs #143

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:57:15 -07:00
funman300 713a292057 Merge pull request 'refactor(engine): ambiguity burn-down batch 2 — SettingsResource cleared (198 → 171)' (#147) from refactor/ambiguity-settings into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 2m17s
Test / test (push) Successful in 19m18s
2026-07-07 03:48:11 +00:00
funman300 58c2dfd0a9 refactor(engine): ambiguity burn-down batch 2 — SettingsResource cleared, 198 → 171
Test / test (pull_request) Successful in 28m7s
New SettingsMutation set: the per-frame settings mutators
(handle_volume_keys → record_window_geometry_changes →
persist_window_geometry_after_debounce) run as a deterministic chain
ordered before GameMutation, so every reader already after GameMutation
observes the current frame's settings transitively. The four readers
outside that ordering (modal enter-speed chain, focus-ring pulse, HUD
avatar, and the game plugin's pre-mutation chain) are ordered after the
set explicitly.

SettingsResource ambiguities: 21 → 0. Baseline ratchets 198 → 171.

Refs #143

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:48:08 -07:00
funman300 710555bd7e Merge pull request 'refactor(engine): first ambiguity burn-down batch — 302 → 198 pairs' (#146) from refactor/ambiguity-burndown into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 1m53s
Test / test (push) Successful in 20m32s
2026-07-07 03:37:53 +00:00
funman300 42a5f3bc3b refactor(engine): first ambiguity burn-down batch — 302 → 198 pairs
Test / test (pull_request) Failing after 15m16s
Two structural fixes from the #143 backlog:

- Game-state ordering spine: the three pre-mutation GameStateResource
  writers (tick_elapsed_time, sync_settings_to_game,
  handle_restore_prompt) are now a deterministic chain before
  GameMutation, and the remaining unordered readers
  (update_selection_hud, handle_hint_button, tick_hint_highlight,
  handle_right_click, snap_cards_on_window_resize,
  sync_pile_marker_visibility, auto_save_game_state) are ordered after
  it. Readers now see the current frame's moves deterministically
  instead of racing the mutators.

- NewGameRequestWriters set: every in-cluster writer of
  NewGameRequestEvent (buttons, modals, mode picker, seed poller,
  restore prompt) is registered in a shared set marked ambiguous with
  itself — writer-vs-writer append order is meaningless since consumers
  drain the whole queue. Out-of-cluster writers (home, challenge,
  time-attack, win-summary, play-by-seed, difficulty, stats plugins)
  can join the set when the test cluster grows.

AMBIGUITY_BASELINE ratchets 302 → 198. Remaining backlog is dominated
by the Sprite (72) / Transform (48) visual-domain cluster, which needs
per-domain set architecture — next batch.

Refs #143

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:37:51 -07:00
funman300 19647b5209 Merge pull request 'test(engine): ratchet on Bevy system-order ambiguities' (#145) from test/schedule-ambiguity into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 2m6s
Test / test (push) Successful in 19m47s
2026-07-07 03:22:29 +00:00
funman300 d8a255869c test(engine): ratchet on Bevy system-order ambiguities
Test / test (pull_request) Failing after 16m20s
First measurement of schedule hygiene (issue #143): the headless
gameplay cluster (Game/Table/Card/Hud/AutoComplete/UiModal/UiFocus/
Settings) carries 302 system pairs with conflicting data access and no
ordering edge. Too many to triage in one pass and most are likely
benign event/resource writers — but unproven, and nothing stopped the
count from growing.

New schedule_checks test builds the cluster with ambiguity detection
promoted to error, parses the reported pair count, and asserts it never
exceeds AMBIGUITY_BASELINE (302). New ambiguous pairs now fail CI at
the PR; the legacy backlog can be burned down incrementally by adding
.before/.after or .ambiguous_with and lowering the baseline.

Refs #143

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:22:27 -07:00
funman300 f0336d784d Merge pull request 'fix(server): login timing pad, 409 on register race, avatar magic-byte check' (#144) from fix/server-low-findings into master
Build and Deploy / build-and-push (push) Successful in 6m5s
Test / test (push) Failing after 16m42s
Web E2E / web-e2e (push) Successful in 4m58s
2026-07-07 03:15:03 +00:00
funman300 a218999243 fix(server): login timing pad, 409 on register race, avatar magic-byte check
Test / test (pull_request) Failing after 17m26s
Closes the three actionable server Low findings from the 2026-07-06
review:

- #139: unknown-username logins now verify against a static bcrypt
  dummy hash so both failure paths pay the same cost — response timing
  no longer reveals which usernames exist.
- #140: register maps a unique-constraint violation to UsernameTaken
  (409) — the SELECT pre-check stays as the friendly fast path, the
  constraint is the arbiter for the concurrent case.
- #141: avatar uploads must start with the magic bytes of the declared
  image type; the stored extension (which decides how the file is
  re-served) is now backed by content, not the Content-Type header.

Unit tests: real/spoofed/truncated signatures for all four formats,
and the dummy hash's validity at BCRYPT_COST.

Closes #139
Closes #140
Closes #141

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:15:01 -07:00
funman300 55fa7df2bf Merge pull request 'fix(engine): bounded blocking sync push on exit' (#138) from fix/exit-push-bounded into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 2m5s
Test / test (push) Successful in 19m38s
2026-07-07 03:05:16 +00:00
funman300 b2341c652b Merge pull request 'refactor(core,engine,wasm): canonical FOUNDATIONS/TABLEAUS consts; adopt upstream SUITS/RANKS' (#137) from refactor/quat-enum-consts into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 6m3s
Test / test (push) Successful in 26m20s
Web E2E / web-e2e (push) Successful in 4m56s
2026-07-07 03:05:11 +00:00
funman300 021c5d6ad8 Merge pull request 'fix(server): transactional sync push; single-use refresh rotation' (#136) from fix/sync-auth-races into master
Build and Deploy / build-and-push (push) Successful in 5m24s
Test / test (push) Successful in 17m41s
Web E2E / web-e2e (push) Successful in 4m41s
2026-07-07 03:04:41 +00:00
funman300 d1264a7797 fix(engine): bounded blocking sync push on exit
Test / test (pull_request) Successful in 24m6s
The exit push spawned a detached task on AppExit, which process
teardown almost always killed before the network round-trip completed —
the final session's sync silently never happened (2026-07-06 review,
finding M3). Local persistence meant no data loss, but stats stayed
unsynced until the next launch.

push_on_exit now blocks the closing app's final frame for at most
EXIT_PUSH_TIMEOUT (2s) via tokio::time::timeout: long enough for one
healthy round-trip, short enough that quitting never feels hung when
the server is unreachable. Timeout and errors are logged and skipped —
the next launch's pull/push converges as before.

Closes review finding M3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:03:41 -07:00
funman300 7a5f03987d refactor(core,engine,wasm): canonical FOUNDATIONS/TABLEAUS consts; adopt upstream SUITS/RANKS
Review findings 1+2 (Quat-underuse lens, 2026-07-06):

- solitaire_core gains pub const FOUNDATIONS / TABLEAUS — the canonical
  iteration source for the upstream pile enums (upstream klondike has no
  Foundation::ALL, and inherent impls cannot be added to foreign types).
  Deletes three identical private const-fn copies (radial_menu,
  table_plugin, input_plugin) and the hand-enumerated variants in
  card_plugin::sync::all_cards and solitaire_wasm.

- Hand-rolled [Suit; 4] / [Rank; 13] arrays replaced with upstream
  Suit::SUITS / Rank::RANKS. The order-sensitive CardImageSet indexing
  is re-keyed through canonical card_plugin::{suit_index, rank_index}
  helpers that match upstream order, with regression tests asserting
  the correspondence — one ordering everywhere instead of three
  divergent local ones.

Net -177 lines. No behaviour change; all consumers go through the
canonical helpers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:58:33 -07:00
funman300 8eb316751d fix(server): transactional sync push; single-use refresh rotation
Two concurrency fixes from the 2026-07-06 review (findings M1, M2):

sync push (M1): the load→merge→store cycle ran as three separate DB
operations. Two devices pushing concurrently both read the same stored
payload, merged independently, and the second store overwrote the first
merge — the server visibly regressed until the losing device pushed
again. The whole cycle (including the leaderboard update) now runs in
one transaction; SQLite serialises the writers. The leaderboard
helper's stale docstring (claiming a single conditional UPDATE that was
actually two statements) is corrected — the transaction now provides
the atomicity it described.

refresh rotation (M2): SELECT-then-DELETE let two concurrent refreshes
with the same token both pass the liveness check and both mint fresh
token pairs. The SELECT is gone; rotation now gates on the DELETE's
rows_affected — whoever removes the jti row wins, everyone else gets
401. Sequential reuse was already covered by
consumed_refresh_token_is_rejected, which still passes unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:00:00 -07:00
funman300 d0c1db6c1d Merge pull request 'ci: add workspace clippy + test gate workflow' (#135) from ci/test-workflow into master
Test / test (push) Successful in 28m59s
2026-07-06 23:29:10 +00:00
funman300 f6e57b759e ci: pin toolchain to 1.95.0 and install Bevy native deps
Test / test (pull_request) Successful in 27m47s
Review feedback on #135: floating 'stable' + -D warnings lets every new
clippy release redden master with new lints — pin 1.95.0 like the
web-wasm-rebuild workflow. And ubuntu-latest lacks the ALSA/udev/X11/
Wayland dev packages the Bevy crates link against; install the standard
Bevy CI set (the project's own builder images have no desktop-Bevy
precedent — android-builder targets the NDK and web-e2e only builds the
server).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 16:28:28 -07:00
funman300 c9adcaa5e4 ci: add workspace clippy + test gate workflow
Test / test (pull_request) Failing after 1m35s
Until now no CI workflow ran the test suite or clippy at all — the
android-release, docker-build, web-e2e, web-wasm-rebuild, and
builder-image workflows cover packaging and e2e, but a direct push to
master (including the web-wasm-rebuild bot commit) never executed
cargo test or clippy. The gate discipline in CLAUDE.md §6 existed only
on developer machines.

test.yml runs the exact §6 commands (clippy --workspace --all-targets
-D warnings; cargo test --workspace) on master pushes and PRs, with
SQLX_OFFLINE against the checked-in .sqlx cache, path-filtered so
docs-only merges don't burn CI. Toolchain/cache mirror web-e2e.yml.

Found during the 2026-07-06 large-scale review (finding H1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 16:21:19 -07:00
Gitea CI 0d5204b5ec chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m30s
Web E2E / web-e2e (push) Successful in 4m38s
2026-07-06 23:05:53 +00:00
funman300 15bb136c79 Merge pull request 'perf(web): size-focused wasm-release profile for the canvas build (36.2 → 23.2 MB)' (#134) from perf/wasm-size-profile into master
Build and Deploy / build-and-push (push) Successful in 7m4s
Web E2E / web-e2e (push) Successful in 4m51s
Web WASM Rebuild / rebuild (push) Successful in 9m30s
2026-07-06 22:44:33 +00:00
funman300 16a1139eab perf(web): size-focused wasm-release profile for the canvas build
canvas_bg.wasm shipped at 36.2 MB — plain release (opt-level 3, thin
LTO) piped through wasm-opt -O2. Download size, not throughput, is the
binding constraint for the browser canvas, so solitaire_web now builds
with a dedicated wasm-release profile: opt-level "s", fat LTO, one
codegen unit. Local result: 23.2 MB after the unchanged wasm-opt -O2
pass — 35.9% smaller.

The binaryen pass stays at -O2 (the -Oz grey-screen miscompile note in
build_wasm.sh still applies). profile.strip is deliberately NOT set: on
wasm it also removes the target_features custom section, which makes
wasm-opt reject the module ('all used features should be allowed' on
trunc_sat).

Verified with the new artifact: all 5 play_canvas e2e specs pass (debug
bridge, draw3 param, apply/undo, replay diagnostics, 40-seed autoplay
invariants). Headless pixel verification is inconclusive in this
environment — wgpu cannot create a usable adapter locally and panics
identically on the OLD artifact too — so visual parity should be
confirmed against production after the next deploy.

pkg/ artifacts are intentionally not committed: the web-wasm-rebuild
workflow is the single source of truth and will regenerate them with
this profile on merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:44:16 -07:00
funman300 710cabcf0d Merge pull request 'docs(architecture): bring source-of-truth docs to post-migration reality' (#133) from docs/architecture-post-migration into master 2026-07-06 22:26:18 +00:00
funman300 5acd8e4cd0 docs(architecture): bring source-of-truth docs to post-migration reality
ARCHITECTURE.md predated the card_game migration (PR #88, 2026-06-22)
and still documented the pre-migration core: local Card with face_up,
PileType, stored score/undo/recycle, and a snapshot undo stack. Rewrites
the Core Game Models section around the upstream card_game/klondike
types and the derived-stats/replay-undo model, updates the
solitaire_core crate section (deps + ownership), adds solitaire_web and
solitaire_assetgen to the workspace tree, and corrects the
solitaire_app and Settings entries. Version bumped to 1.4.

Also adds the two missing crates to the CLAUDE.md crate map (AGENTS.md,
its gitignored local twin, was regenerated in place) and records the
full device checklist passing on the Fold 7 in SESSION_HANDOFF.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:26:06 -07:00
funman300 652c9290b9 Merge pull request 'docs: add v0.41.1 changelog section and refresh session handoff' (#132) from docs/handoff-v0.41.1 into master 2026-07-06 22:19:37 +00:00
funman300 beddbcf94d docs: add v0.41.1 changelog section and refresh session handoff
CHANGELOG gains the missing [0.41.1] section (pile-marker child-resize
fix + Android relayout diagnostics). SESSION_HANDOFF is brought forward
from the stale v0.40.0 state to today's: v0.41.x releases, the July 6
review arc (PRs #121-#131), Fold 7 on-device verification results, the
remaining device-checklist items, and new architectural notes (plugin
submodule pattern, marker child-resize rule, Fold 7 inset quirks).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:19:35 -07:00
Gitea CI 3c6b6e8c22 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m59s
Web E2E / web-e2e (push) Successful in 4m7s
2026-07-06 21:38:35 +00:00
funman300 dbe2addc30 Merge pull request 'fix(engine): resize pile-marker outline and watermark children on relayout' (#131) from fix/pile-marker-child-resize into master
Build and Deploy / build-and-push (push) Successful in 2m19s
Web WASM Rebuild / rebuild (push) Successful in 8m12s
Android Release / build-apk (push) Successful in 5m6s
2026-07-06 21:28:23 +00:00
funman300 c286593415 fix(engine): resize pile-marker outline and watermark children on relayout
Found during Fold 7 on-device verification of v0.41.0: on_window_resized
resized the marker fill sprite but never its children, so after any
resize (fold/unfold, rotation) the outline frame and the A/K watermark
kept their spawn-time size — rendering as oversized grey slabs over the
empty foundation slots.

The resize handler now re-derives both children from the new layout
(outline = card_size + 2*PILE_MARKER_OUTLINE_WIDTH, watermark font =
card_size.x * 0.28 — same formulas as spawn). Adds a regression test
and an Android-gated relayout log line (width/height/insets) as the
evidence channel for the remaining foldable layout bug (#130).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:28:09 -07:00
Gitea CI ddba5c0b26 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m52s
Web E2E / web-e2e (push) Successful in 4m41s
2026-07-06 21:14:34 +00:00
funman300 5b37f35eb8 Merge pull request 'refactor(engine): split card_plugin runtime code into submodules' (#129) from refactor/card-plugin-submodules into master
Build and Deploy / build-and-push (push) Successful in 2m25s
Web WASM Rebuild / rebuild (push) Successful in 8m36s
2026-07-06 21:01:24 +00:00
funman300 d1e87765af refactor(engine): split card_plugin runtime code into submodules
Final runtime split for #118: the 2,536-line mod.rs becomes seven
focused submodules along existing system boundaries —

  mod.rs      574  fan-step helpers, CardImageSet, markers, plugin build
  sync.rs     748  asset loading + card entity lifecycle (spawn/update/position)
  layout.rs   351  resize snapping, in-place resize, tableau fan spread
  stock.rs    291  empty-stock recycle hint + count badge
  highlights.rs 276 hint/right-click highlights, cursor hit-testing
  labels.rs   208  desktop text labels + Android corner labels
  anim.rs     200  flip animation, drag shadows

Moved items are pub(super); code moved verbatim apart from relocating
the two stock colour constants next to their consumers. No behaviour
change; all 70 card tests pass unchanged.

Refs #118

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:01:10 -07:00
funman300 b3b53c4adf Merge pull request 'refactor(engine): split hud_plugin runtime code into submodules' (#128) from refactor/hud-plugin-submodules into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 2m23s
2026-07-06 20:54:17 +00:00
funman300 ba76936aba refactor(engine): split hud_plugin runtime code into submodules
Continues #118 after settings_plugin (PR #127): the 2,725-line mod.rs
becomes five focused submodules along existing system boundaries —

  mod.rs        553  markers, resources, popover enums, plugin build
  spawn.rs      552  band / columns / avatar / action-bar construction
  interaction.rs 616 button handlers, Modes/Menu popovers, tap gesture
  fx.rs         430  action fades, score pulses/floaters, streak flourish
  updates.rs    612  HUD text / typography / visibility updaters

Moved items are pub(super) (fx re-exported pub for HudActionFade and
format_time_limit consumers). Code moved verbatim; no behaviour change;
all 44 hud tests pass unchanged.

Refs #118

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:54:04 -07:00
funman300 59ba7ba4c3 Merge pull request 'refactor(engine): split settings_plugin runtime code into submodules' (#127) from refactor/settings-plugin-submodules into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 2m10s
2026-07-06 20:46:10 +00:00
funman300 1ca1efb3b6 refactor(engine): split settings_plugin runtime code into submodules
Second phase of #118 for settings_plugin: the 2,857-line mod.rs becomes
four focused submodules along existing system boundaries —

  mod.rs     561  types, markers, SettingsButton, plugin build, persistence
  input.rs   632  button/hotkey handlers, focus attachment, scrolling
  updates.rs 495  per-frame value-text updater systems + label helpers
  ui.rs    1,231  spawn_settings_panel + row builders + thumbnails

Moved items are pub(super); mod.rs glob-imports the submodules so
system registration and tests keep their bare names. No behaviour
change; all 29 settings tests pass unchanged.

Refs #118

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:45:57 -07:00
Gitea CI c5ad487256 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m2s
Web E2E / web-e2e (push) Successful in 5m16s
2026-07-06 20:41:54 +00:00
funman300 8274581edf Merge pull request 'docs(changelog): cut v0.41.0 section' (#126) from docs/changelog-v0.41.0 into master
Android Release / build-apk (push) Successful in 5m50s
2026-07-06 20:27:43 +00:00
funman300 704e70f60a docs(changelog): cut v0.41.0 section
Renames Unreleased to 0.41.0 (2026-07-06), noting it consolidates the
v0.40.x patch tags that were cut without sectioning. Adds entries for
today's safe-area resume re-poll fix (#116) and the plugin module
test-split phase (#118).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:27:42 -07:00
funman300 b81b314197 Merge pull request 'refactor(engine): move hud/settings/game/input plugin tests into tests.rs files' (#125) from refactor/split-plugin-tests into master
Build and Deploy / build-and-push (push) Successful in 2m19s
Web WASM Rebuild / rebuild (push) Successful in 8m15s
2026-07-06 20:20:10 +00:00
funman300 a2375d2fd9 refactor(engine): move hud/settings/game/input plugin tests into tests.rs files
Continues #118 after card_plugin (PR #124): the four remaining
oversized plugin files become module directories with their trailing
#[cfg(test)] blocks extracted verbatim into sibling tests.rs files,
following the replay_overlay/ pattern.

  hud_plugin:      3,598 -> 2,725 + 872   (44 tests)
  settings_plugin: 3,512 -> 2,857 + 654   (25 tests)
  game_plugin:     2,562 -> 1,310 + 1,251 (39 tests)
  input_plugin:    2,540 -> 1,832 + 707   (31 tests)

Pure mechanical moves via git mv — no runtime code changes; all 139
tests preserved and passing.

Refs #118

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:19:56 -07:00
funman300 3f5e4f4290 Merge pull request 'refactor(engine): move card_plugin tests into card_plugin/tests.rs' (#124) from refactor/card-plugin-split-tests into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 2m23s
2026-07-06 20:16:55 +00:00
funman300 0797e9a993 refactor(engine): move card_plugin tests into card_plugin/tests.rs
First increment of the module-split plan: card_plugin.rs becomes
card_plugin/{mod.rs, tests.rs} (2,536 + 1,592 lines), following the
replay_overlay/ pattern. Pure mechanical move via git mv — no runtime
code changes; all 70 tests preserved and passing.

Refs #118

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:16:21 -07:00
funman300 c53b42342b Merge pull request 'chore(scripts): add Gitea deploy watcher' (#123) from chore/commit-watch-deploy into master 2026-07-06 20:10:33 +00:00
funman300 123aa6d099 chore(scripts): add Gitea deploy watcher
Polls the docker-build / web-wasm-rebuild workflow runs and prints a
compact status block until the newest deploy is live. Reads the API
token from ~/.config/tea/config.yml and never prints it.

Closes #119

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:10:24 -07:00
funman300 331d932c4b Merge pull request 'docs(core): record unlimited stock recycling as an intentional rules decision' (#122) from docs/recycle-rule-decision into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 6m32s
Web E2E / web-e2e (push) Successful in 5m1s
2026-07-06 20:10:00 +00:00
Gitea CI 716e5f04cf chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m40s
Web E2E / web-e2e (push) Successful in 6m17s
2026-07-06 20:03:54 +00:00
funman300 ef9d914b99 docs(core): record unlimited stock recycling as an intentional rules decision
Resolves the Draw-1 recycle question from the June 500-game audit:
unlimited recycling with upstream score penalties is deliberate,
matching mainstream digital solitaire rather than strict 3-pass
tournament rules. The difficulty seed catalog and the winnable-deal
solver are verified under this rule, so a hard pass limit must not be
introduced casually.

- ARCHITECTURE.md: new 'Rules decisions' note in the solitaire_core
  section
- GameState::draw() doc comment points at the decision record
- New lock-in test draw_one_recycling_is_unlimited_by_design asserts
  10+ recycles are never rejected

Closes #117

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:03:22 -07:00
funman300 005efa2ee4 Merge pull request 'fix(engine): re-poll safe-area insets after app resume' (#121) from fix/safe-area-resume-repoll into master
Build and Deploy / build-and-push (push) Successful in 3m18s
Web WASM Rebuild / rebuild (push) Successful in 8m43s
2026-07-06 19:52:59 +00:00
funman300 1190ed3ce6 fix(engine): re-poll safe-area insets after app resume
refresh_insets gated its loop on insets.is_populated(), so once insets
resolved at first launch, rearm_on_resumed's poll-counter reset was a
no-op and JNI was never queried again. Insets that changed while the
app was backgrounded (fold/unfold, rotation, gesture/3-button nav
switch) stayed stale until process restart.

Gate the loop on the poll counter alone and settle a cycle by
exhausting it once a populated reading arrives. The cached resource is
rewritten only when the value actually differs, so resumes where
nothing moved trigger no change detection and no relayout — preserving
the no-flash resume behaviour. Also correct the stale on_app_resumed
doc that still described the old inset-zeroing approach.

Closes #116

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 12:50:01 -07:00
Gitea CI 2869e1c34e chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 5m50s
Web E2E / web-e2e (push) Successful in 4m55s
2026-06-26 21:25:24 +00:00
funman300 783f01628e Merge pull request 'fix(engine): raise dynamic tableau fan cap to fill tall viewports' (#115) from fix/cover-screen-fan-cap into master
Build and Deploy / build-and-push (push) Successful in 2m8s
Web WASM Rebuild / rebuild (push) Successful in 7m24s
Android Release / build-apk (push) Successful in 5m17s
2026-06-26 21:15:53 +00:00
funman300 94a6feb5db fix(engine): raise dynamic tableau fan cap to fill tall viewports
MAX_DYNAMIC_FAN_FRAC 0.6 -> 0.9 so the dynamic tableau fill spreads
further on very tall / narrow viewports (e.g. a foldable cover screen),
which were left ~40% empty at the cap. Fills the unfolded near-square
screen to ~100% and lets the cover screen fill further (and the rest fills
as columns deepen during play). Normal phones are unaffected — their fill
fraction is already below the cap. apply_dynamic_tableau_fan still floors
at TABLEAU_FAN_FRAC and deeper columns drive the fraction down, so nothing
overflows and hit-testing stays in sync.

Vertical centring of the residual was investigated but dropped: a 21:9
phone is aspect-identical to the cover screen, so centring can't be
targeted to foldables without also disconnecting the board from the HUD on
tall phones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:56:31 -07:00
Gitea CI 3daaf47689 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m3s
Web E2E / web-e2e (push) Successful in 4m47s
2026-06-26 20:20:59 +00:00
funman300 c00656ec8f Merge pull request 'docs(changelog): note foldable tableau fill and card-move jank fixes' (#114) from docs/changelog-v0.40.2 into master
Android Release / build-apk (push) Successful in 5m3s
2026-06-26 20:12:47 +00:00
funman300 aea2167eee docs(changelog): note foldable tableau fill and card-move jank fixes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:12:45 -07:00
funman300 0ed91ea24c Merge pull request 'fix(engine): fill tableau fan to viewport on all aspect ratios' (#113) from fix/foldable-tableau-fan-fill into master
Build and Deploy / build-and-push (push) Successful in 2m3s
Web WASM Rebuild / rebuild (push) Successful in 7m27s
2026-06-26 20:11:33 +00:00
funman300 18af49c0f3 fix(engine): fill tableau fan to viewport on all aspect ratios
On a near-square viewport (e.g. an unfolded Galaxy Fold) a fresh deal left
the bottom ~40% of the screen empty: the dynamic fan (update_tableau_fan_frac)
measured only face-up column depth and returned early at a fresh deal (face-up
depth 1), so it never spread the tableau, and the deep face-down stacks were
ignored. The cold-start deal also never fired StateChangedEvent, and on Android
the safe-area-inset resize (frames 1-3) reset the fan to compute_layout's
sparse worst-case value, so even the post-move fill was wiped.

Move the fill into layout::apply_dynamic_tableau_fan, driven by each column's
TOTAL weighted depth (face-down cards count, scaled by the face-down/face-up
step ratio) so the deepest column fills the available height. Run it in three
places so every path stays filled: PostStartup (cold-start deal), on
StateChangedEvent (moves), and inside on_window_resized after compute_layout
(safe-area resize + fold/unfold). MAX_DYNAMIC_FAN_FRAC caps the spread so a
near-empty column keeps readable overlap; TABLEAU_FAN_FRAC floors it. Deeper
columns drive the fraction down so everything still fits — no overflow.
card_position/card_positions read the same fractions, so hit-testing stays
in sync.

Adds regression tests: cold-start deal fills the fan, and a resize re-fills it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:11:00 -07:00
Gitea CI e208245036 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m48s
Web E2E / web-e2e (push) Successful in 4m47s
2026-06-26 19:11:53 +00:00
funman300 0247efcb07 Merge pull request 'perf(engine): rebuild card children only on appearance change' (#112) from fix/card-move-anim-jank into master
Build and Deploy / build-and-push (push) Successful in 2m1s
Web WASM Rebuild / rebuild (push) Successful in 6m40s
2026-06-26 19:03:19 +00:00
funman300 1d5266a811 perf(engine): rebuild card children only on appearance change
Every move fired StateChangedEvent -> sync_cards, which rebuilt the full
visual for all 52 cards: despawning and respawning every child entity
(drop-shadow, border frame, and on Android a Text2d corner label needing
a glyph re-layout) even for the ~50 cards that did not move. That ~250
entity despawn/spawns plus 52 text re-layouts in a single frame spiked
the StateChangedEvent frame and stuttered the slide animation on
high-resolution devices (reported on a Galaxy Fold 7).

Add a CardChildrenKey component capturing the only inputs the child
entities depend on (face_up, card_size, color_blind, high_contrast).
update_card_entity now rebuilds children only when that key changes (a
flip, resize/fold, or accessibility toggle); a position-only move just
updates the Transform. The Sprite is still refreshed every sync (a cheap
handle swap), so theme/card-back image changes need no child rebuild.

Adds a regression test asserting an appearance-neutral StateChangedEvent
no longer despawns/respawns card label children.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:02:42 -07:00
126 changed files with 21462 additions and 19051 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
+83
View File
@@ -0,0 +1,83 @@
# 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 was entirely unguarded.
name: Test
on:
push:
branches: [master]
paths:
- 'solitaire_app/**'
- 'solitaire_assetgen/**'
- 'solitaire_core/**'
- 'solitaire_data/**'
- 'solitaire_engine/**'
- 'solitaire_server/src/**'
- 'solitaire_server/tests/**'
- 'solitaire_server/migrations/**'
- 'solitaire_sync/**'
- 'solitaire_wasm/**'
- 'solitaire_web/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.cargo/**'
- '.sqlx/**'
- '.gitea/workflows/test.yml'
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: rust-host
# 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 cache-warm build.
env:
CARGO_PROFILE_DEV_DEBUG: line-tables-only
CARGO_BUILD_JOBS: '2'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust 1.95.0
uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.95.0
components: clippy, rustfmt
- name: Cache cargo build
uses: Swatinem/rust-cache@v2
# Native link deps for the Bevy crates (engine/app/web) on a bare
# ubuntu runner: ALSA + udev for input/audio, X11 + Wayland for winit.
- name: Install Bevy native dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
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)
env:
SQLX_OFFLINE: 'true'
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Test
env:
SQLX_OFFLINE: 'true'
run: cargo test --workspace
+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/
+92 -46
View File
@@ -1,9 +1,11 @@
# Ferrous Solitaire — Architecture Document
> **Version:** 1.3
> **Version:** 1.4
> **Language:** Rust (Edition 2024)
> **Engine:** Bevy (latest stable)
> **Last Updated:** 2026-05-12
> **Last Updated:** 2026-07-06 — post card_game/klondike migration (PR #88):
> core card/pile types come from the upstream `card_game` workspace and
> score/undo/recycle are derived from the upstream session, not stored.
---
@@ -82,13 +84,15 @@ ferrous_solitaire/
│ ├── win_fanfare.wav
│ └── ambient_loop.wav
├── solitaire_core/ # Pure Rust game logic — zero external deps beyond rand/serde
├── solitaire_core/ # Pure Rust game rules — wraps upstream card_game/klondike (serde + thiserror only otherwise)
├── solitaire_sync/ # Shared API types — used by client and server
├── solitaire_data/ # Persistence, sync client, settings
├── solitaire_engine/ # Bevy ECS systems, components, plugins
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite)
├── solitaire_wasm/ # WebAssembly bindings — browser-side replay player
── solitaire_app/ # Main binary entry point
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite) + web frontend
├── solitaire_wasm/ # WebAssembly bindings — browser-side logic/replay + debug bridge
── solitaire_web/ # Bevy WASM canvas build for the browser /play route
├── solitaire_assetgen/ # One-shot generator for card/background PNG assets
└── solitaire_app/ # Main binary entry point (desktop + Android cdylib)
```
---
@@ -96,18 +100,42 @@ ferrous_solitaire/
## 3. Crate Responsibilities
### `solitaire_core`
**Dependencies:** `rand`, `serde`, `chrono` only.
**Dependencies:** `serde`, `thiserror`, plus the upstream `card_game` and
`klondike` crates (pinned via the Quaternions registry — never edit upstream).
The entire game rules engine. No Bevy, no network, no file I/O. Designed to be tested in isolation with `cargo test -p solitaire_core`.
The game rules layer. No Bevy, no network, no file I/O. Designed to be tested
in isolation with `cargo test -p solitaire_core`.
Since the card_game migration (2026-06-22, PR #88) the primitive types are
**upstream**: `Card`, `Deck`, `Suit`, `Rank`, `Session` come from `card_game`;
`Klondike`, `KlondikePile`, `KlondikeInstruction`, `DrawStockConfig`,
`Foundation`, `Tableau` come from `klondike`. `solitaire_core` re-exports them
so downstream crates import from one place and never depend on the upstream
crates directly.
Owns:
- All game data models (`Card`, `Suit`, `Rank`, `Pile`, `GameState`)
- Move validation logic
- Scoring engine
- Undo stack
- `GameState` — a wrapper around the upstream `Session<Klondike>`; the session
is the single source of truth for board state and stats
- `MoveError` and the `Result`-based mutation API
- `KlondikeConfig` adaptation (`klondike_adapter`) — draw mode, scoring,
take-from-foundation
- `GameMode` (Classic / Zen / Challenge / TimeAttack) and mode-aware scoring
- Solvability check API (`SolveOutcome`, delegating to `Session::solve`)
- Win / auto-complete detection
- Achievement unlock condition evaluation
- Seeded RNG for reproducible deals
- Seeded deals (same seed ⇒ same layout, via the upstream dealer)
**Rules decisions:**
- **Stock recycling is unlimited in every draw mode — by design.** Extra
passes through the stock are discouraged via the upstream score penalty
(applied by the `card_game`/`klondike` session), never blocked with a
`MoveError`. This matches mainstream digital solitaire (unlimited redeals
in Draw-1) rather than strict tournament rules (3-pass cap). It is load-
bearing: the difficulty seed catalog and the winnable-deal solver are
verified under unlimited recycling, so introducing a hard pass limit would
invalidate both. Locked in by the
`draw_one_recycling_is_unlimited_by_design` test in
`solitaire_core/src/game_state.rs`. (Decision record: Gitea issue #117.)
### `solitaire_sync`
**Dependencies:** `serde`, `serde_json`, `uuid`, `chrono` only.
@@ -117,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`
@@ -165,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.
@@ -177,9 +204,13 @@ Owns:
Because `ReplayPlayer` uses the same `solitaire_core::GameState` as the desktop client, the two implementations cannot drift: the same seed + move list produces identical pile state at every step on both platforms.
### `solitaire_app`
**Dependencies:** `bevy`, `solitaire_engine`.
**Dependencies:** `bevy`, `solitaire_engine`, `solitaire_data` (+ `jni` on Android).
Thin binary entry point. Registers all Bevy plugins and sets initial window properties.
Thin entry point (desktop binary + Android `cdylib`). Registers all Bevy
plugins and sets initial window properties. The one crate in the workspace
allowed `unsafe`: the Android entry point reconstructs the raw JNI handles and
hands them to the safe `solitaire_data::android_jni` bridge; everything else
is `forbid(unsafe_code)`.
---
@@ -225,7 +256,7 @@ solitaire_sync::merge(local, remote)
Write merged result to disk
fires SyncCompleteEvent
Bevy main thread reads updated StatsResource
```
@@ -345,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
@@ -547,26 +577,36 @@ This ensures all players worldwide get the same challenge for a given date, rega
### Core Game Models (`solitaire_core`)
Since the card_game migration, the primitives are upstream types re-exported
through `solitaire_core`:
```rust
pub enum Suit { Clubs, Diamonds, Hearts, Spades }
pub enum Rank { Ace, Two, Three, Four, Five, Six, Seven, Eight, Nine, Ten, Jack, Queen, King }
// From `card_game` (upstream — never edit):
pub enum Suit { /* Clubs, Diamonds, Hearts, Spades */ }
pub enum Rank { /* Ace ..= King */ }
pub struct Card { /* deck + suit + rank; identity type, no face_up flag —
facing is positional, tracked by the Klondike board */ }
pub struct Session<G> { /* replayable instruction log + derived stats */ }
pub struct Card {
pub id: u32,
pub suit: Suit,
pub rank: Rank,
pub face_up: bool,
// From `klondike` (upstream — never edit):
pub enum KlondikePile {
Stock, // NB: no Waste variant — see below
Foundation(Foundation), // 4 slots, any suit may claim any slot
Tableau(Tableau), // 7 columns
}
// Pile-coordinate convention: upstream has no `Waste` variant. In
// pile-coordinate space `KlondikePile::Stock` denotes the face-up
// *waste* pile (the only stock-side pile cards move out of); use
// `GameState::stock_cards()` / `waste_cards()` when the face-down
// draw stack must be distinguished. Documented on `GameState::pile`.
pub enum DrawStockConfig { DrawOne, DrawThree }
pub enum KlondikeInstruction { /* RotateStock, DstFoundation, ... — the
serialized move format (schema v4+) */ }
```
pub enum PileType {
Stock,
Waste,
Foundation(Suit),
Tableau(usize), // 06
}
pub enum DrawMode { DrawOne, DrawThree }
Owned by `solitaire_core`:
```rust
/// Active game mode. Classic is the default; others unlock at level 5.
pub enum GameMode { Classic, Zen, Challenge, TimeAttack }
@@ -577,24 +617,30 @@ pub enum MoveError {
RuleViolation(String),
UndoStackEmpty,
GameAlreadyWon,
StockEmpty,
}
pub struct GameState {
pub piles: HashMap<PileType, Vec<Card>>,
pub draw_mode: DrawMode,
pub mode: GameMode,
pub score: i32,
pub move_count: u32,
pub undo_count: u32, // number of undos used in this game
pub recycle_count: u32, // number of stock recycles
pub elapsed_seconds: u64,
pub seed: u64,
pub is_won: bool,
pub is_auto_completable: bool,
undo_stack: VecDeque<StateSnapshot>, // private, max 64 (VecDeque for O(1) pop_front)
pub seed: u64, // same seed ⇒ same deal
pub take_from_foundation: bool,
session: Session<Klondike>, // private — the single source of truth
}
```
**Derived, not stored:** `score()`, `move_count()`, `undo_count()`,
`recycle_count()`, `is_won()`, `is_auto_completable()`, and all pile
accessors read through the session. Undo replays the instruction log
(no snapshot stack); the 15 undo penalty is applied by the upstream
score formula via the session config. Persistence (schema v5) saves
`saved_moves` as upstream `KlondikeInstruction`s and rebuilds the
session by replay on load — older files carrying `score`/`undo_count`/
`recycle_count` keys load fine, the extra fields are ignored.
**Rules decision:** stock recycling is unlimited in every draw mode
(see the "Rules decisions" note in §3 `solitaire_core`).
### Persistence Models (`solitaire_data`)
```rust
@@ -632,7 +678,7 @@ pub struct AchievementRecord {
}
pub struct Settings {
pub draw_mode: DrawMode,
pub draw_mode: DrawStockConfig,
pub sfx_volume: f32, // 0.01.0
pub music_volume: f32,
pub animation_speed: AnimSpeed,
@@ -697,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
+109
View File
@@ -6,8 +6,95 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [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
- **CI workspace gate.** New `test.yml` workflow runs clippy (deny warnings)
and the full test suite on every master push and PR — previously no CI ran
tests at all. Caught its own first bug (missing Bevy native deps) on its
own PR. (#135)
- **Schedule ambiguity gate.** A headless test builds the gameplay plugin
cluster with Bevy ambiguity detection promoted to error. The initial
measurement found 302 system pairs with conflicting data access and no
ordering; four burn-down batches (PRs #146#149) took it to ZERO the same
day, and the gate now enforces 0. Keyboard consumption, board painting,
and HUD updates all have deterministic order for the first time.
### Changed
- **Browser canvas 36% smaller.** `canvas_bg.wasm` shrank 36.2 MB → 23.2 MB
via a size-focused `wasm-release` profile (fat LTO, single codegen unit,
opt-level "s"); verified visually identical in production. (#134)
- **Quaternions API adoption.** Canonical `FOUNDATIONS`/`TABLEAUS` consts in
`solitaire_core` replace five scattered enum lists; upstream
`Suit::SUITS`/`Rank::RANKS` replace nine hand-rolled arrays, with the
texture-atlas indexing re-keyed through tested canonical helpers. Net
177 lines. (#137)
### Fixed
- **Sync push race.** The server's load→merge→store cycle now runs in one
transaction; concurrent pushes from two devices can no longer overwrite
each other's merge. (#136)
- **Refresh-token rotation is single-use under concurrency** — rotation
gates on the DELETE's row count, so a stolen-then-replayed refresh token
loses the race and gets 401. (#136)
- **Exit sync push actually completes.** Was a detached task killed by
process teardown; now a bounded 2-second blocking wait on the app's final
frame. (#138)
- **Server auth hardening.** Login timing no longer reveals whether a
username exists; concurrent duplicate registration returns 409 instead of
500; avatar uploads are magic-byte checked. (#144, issues #139#141)
## [0.41.1] — 2026-07-06
### Fixed
- **Oversized pile-marker frames after fold/unfold.** `on_window_resized`
resized the marker fill sprite but never its children, so the outline frame
and the "A"/"K" watermark kept their spawn-time size after any resize —
rendering as oversized grey slabs over empty foundation slots on foldables
(found during Galaxy Fold 7 on-device verification of v0.41.0, fixed and
re-verified on the same device). Both children are now re-derived from the
new layout on every relayout.
### Added
- **Android relayout diagnostics.** Every layout recompute on Android now
logs its window dimensions and insets (`layout: resize to WxH …`) — the
evidence channel for foldable layout reports (#130).
## [0.41.0] — 2026-07-06
> Consolidates everything shipped since v0.39.0, including the v0.40.0v0.40.3
> patch tags (which were cut from this section without renaming it at the time).
### Added
- **Rules decision record: unlimited stock recycling.** Documented in
`ARCHITECTURE.md` that unlimited recycling with score penalties (matching
mainstream digital solitaire) is intentional, and locked it in with a core
test — the difficulty seed catalog and winnable-deal solver are verified
under this rule. Resolves the last open finding from the June 500-game
audit (issue #117).
- **Analytics validation runbook.** Documented native Matomo live validation,
expected event payloads, and the current web/WASM analytics split.
- **Android smoke-test runbook.** Updated the Android doc with the current
@@ -26,6 +113,10 @@ project follows [Semantic Versioning](https://semver.org/).
### Changed
- **Engine plugin modules restructured.** The five oversized plugin files
(card, hud, settings, game, input) are now module directories with their
test suites in sibling `tests.rs` files — no behaviour change; first phase
of the module-split plan (#118).
- **Core type ownership.** Routed all klondike/card imports through
`solitaire_core` and unified local `Suit` / `Rank` with upstream `card_game`
types.
@@ -37,6 +128,24 @@ project follows [Semantic Versioning](https://semver.org/).
### Fixed
- **Safe-area insets now re-polled after app resume.** The inset poller settled
permanently once insets first resolved, so inset changes while backgrounded
(fold/unfold, rotation, gesture ↔ 3-button nav switch) kept stale layout until
the app was killed. Each resume now re-arms a fresh poll window; the cached
value is rewritten only when it actually changed, so unchanged resumes still
cause no relayout flash. (#116)
- **Tableau fill on foldables / tall screens.** The tableau fan now spreads from
each column's total depth (face-down cards included) and refills on the
cold-start deal, every move, and on resize (incl. the Android safe-area-inset
resize and fold/unfold), so a near-square viewport such as an unfolded Galaxy
Fold no longer leaves the bottom of the screen empty. The fan spread cap was
raised so very tall / narrow viewports (e.g. a foldable cover screen) fill
further.
- **Card-move animation jank.** A move now rebuilds a card's child visuals only
when its appearance changes (flip / resize / accessibility) instead of
despawning and respawning every card's children each `StateChangedEvent`,
removing the per-move spike that stuttered the slide animation on
high-resolution devices.
- **Android and modal safe-area layout.** Modal cards now center within the
usable area between status and gesture bars, additional modal-spawn guards were
added, and Android build scripts now auto-discover SDK/NDK paths and strip
+3 -1
View File
@@ -30,7 +30,9 @@ solitaire_data/ # Persistence + sync client
solitaire_engine/ # Bevy ECS + UI + gameplay orchestration
solitaire_server/ # Axum backend (optional sync layer)
solitaire_wasm/ # WASM bindings for browser-side replay player
solitaire_app/ # Entry binary
solitaire_web/ # Bevy WASM canvas build for the browser /play route
solitaire_assetgen/ # One-shot card/background PNG asset generator
solitaire_app/ # Entry binary (desktop + Android cdylib)
assets/ # Runtime assets (except audio + default theme)
```
Generated
+10
View File
@@ -7331,7 +7331,9 @@ dependencies = [
"card_game",
"klondike",
"proptest",
"rand 0.10.1",
"serde",
"serde_json",
"thiserror 2.0.18",
]
@@ -7351,13 +7353,16 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"sha2",
"solitaire_core",
"solitaire_server",
"solitaire_sync",
"sqlx",
"tempfile",
"thiserror 2.0.18",
"tokio",
"uuid",
"zip",
]
[[package]]
@@ -7402,10 +7407,13 @@ dependencies = [
"chrono",
"dotenvy",
"jsonwebtoken",
"ron",
"serde",
"serde_json",
"sha2",
"solitaire_sync",
"sqlx",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tower",
@@ -7414,6 +7422,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"uuid",
"zip",
]
[[package]]
@@ -7422,6 +7431,7 @@ version = "0.1.0"
dependencies = [
"chrono",
"serde",
"serde_json",
"thiserror 2.0.18",
"uuid",
]
+17
View File
@@ -47,6 +47,7 @@ dirs = "6"
keyring = "4"
keyring-core = "1"
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
sha2 = "0.10"
arboard = { version = "3", default-features = false }
jni = { version = "0.21", default-features = false }
@@ -156,3 +157,19 @@ opt-level = 3
[profile.release]
opt-level = 3
lto = "thin"
# Size-focused profile for the browser canvas build (solitaire_web →
# canvas_bg.wasm). Download size is the constraint on the web, not peak
# throughput: fat LTO + one codegen unit + opt-level "s" cut the Bevy wasm
# bundle substantially versus plain release. This is rustc-side sizing only —
# the binaryen pass in build_wasm.sh stays at wasm-opt -O2 because -Oz has
# miscompiled Bevy's render pipeline before (grey screen on first load).
[profile.wasm-release]
inherits = "release"
opt-level = "s"
lto = "fat"
codegen-units = 1
# No `strip`: on wasm it also removes the target_features custom section,
# which makes wasm-opt reject the module ("all used features should be
# allowed" on trunc_sat). wasm-opt drops the name section in its output
# anyway, so strip buys nothing here.
+20
View File
@@ -44,6 +44,26 @@ docker compose up -d
```
## Theme store
The server can offer card-art themes for in-game download. Drop theme
`.zip` archives (the same format the game's Settings → Import accepts:
a `theme.ron` manifest plus 53 SVGs) into the directory named by
`THEME_STORE_DIR` (default: `theme_store/` next to the binary), then
restart the server — the catalog is scanned once at startup. An
optional `<theme-id>.png` in the same directory becomes the theme's
store preview.
Endpoints (public, no auth):
- `GET /api/themes` — catalog JSON (id, name, author, size, sha256)
- `GET /api/themes/<id>/download` — the archive
- `GET /api/themes/<id>/preview` — the preview PNG, if present
Archives that are oversized (> 20 MiB), unreadable, or have a
malformed `theme.ron` are skipped with a warning in the server log;
they never fail startup.
## Admin — Password Reset
If a player loses access to their account, the server binary includes a
+74 -30
View File
@@ -1,16 +1,57 @@
# Ferrous Solitaire — Session Handoff
**Last updated:** 2026-06-25 — v0.40.0 released (Android APK published); physical-device gate remains.
**Last updated:** 2026-07-06 — v0.41.0 + v0.41.1 released and verified on a
physical Galaxy Fold 7; issue tracker empty except low-priority #130.
---
## Current state
- **Branch state:** `master` pushed to origin; latest commits are the Draw-Three waste fan fix, its regression tests, and the NDK doc update (PRs #105#108).
- **Latest tag:** `v0.40.0` (released — signed arm64-v8a APK published to the Gitea release for Obtainium/sideload). `v0.39.1` was the prior published release.
- **Working tree:** clean. Local `scripts/` helpers (incl. `scripts/watch_deploy.sh`) are intentionally not committed.
- **Latest verification this session:** `cargo clippy --workspace --all-targets -- -D warnings`; `cargo test --workspace`; `cargo build -p solitaire_app`; Android cross-compile + clippy for `aarch64-linux-android` (clean); full local signed arm64-v8a APK via `scripts/build_android_apk.sh`; CI `android-release` for `v0.40.0` completed/success with APK download HTTP 200.
- **Full previous gate:** card_game work pushed to origin with `cargo test` / `clippy` gates passing.
- **Branch state:** `master` pushed to origin; latest work is the 2026-07-06
arc (PRs #121#131): scripted repo review, five issues filed and fixed,
plugin module splits, two releases.
- **Latest tags:** `v0.41.1` (pile-marker child-resize fix + Android relayout
logging) on top of `v0.41.0` (consolidated release for everything since
v0.39.0). Both released via tag push → CI signed APK; both verified
installed on hardware (`versionCode 4101`).
- **Working tree:** clean. Local `scripts/*.go` helpers are intentionally
gitignored (`.gitignore:43`); `scripts/watch_deploy.sh` is now committed.
- **Latest verification:** workspace clippy `--all-targets -D warnings`,
full test suite, `cargo ndk` clippy for `aarch64-linux-android`, CI release
builds green, and an on-device pass on the Fold 7 (fold/unfold layout,
safe-area resume, marker fix).
- **Issue tracker:** #116/#117/#118/#119/#120 all closed 2026-07-06. #130
(transient tableau clip after fold) open at low priority — did not
reproduce in repeat testing; v0.41.1's relayout logging is the evidence
channel if it recurs.
---
## 2026-07-06 session summary (v0.40.3 → v0.41.1)
- **Scripted repo review** (cratemap/todoctx/cargoclip/testfail): clippy
clean, tests green, error/SQL policies compliant. Five issues filed and
all resolved same-day.
- **#116 safe-area re-poll after resume** (PR #121): `refresh_insets` now
gates on the poll counter, settles per cycle, and rewrites insets only on
change. Verified on Fold 7 — note both Fold screens report identical
insets (top=110 bottom=0), so the re-poll path is a no-op on this device.
- **#117 Draw-1 recycle** (PR #122): unlimited recycling documented as an
intentional rules decision (ARCHITECTURE.md "Rules decisions" +
`draw_one_recycling_is_unlimited_by_design` lock-in test). A hard limit
would invalidate the difficulty seed catalog and the winnable-deal solver.
- **#118 module splits** (PRs #124/#125/#127/#128/#129): card, hud,
settings, game, and input plugins are now module directories; tests in
sibling `tests.rs`, runtime code split along system boundaries
(`pub(super)` items, mod.rs glob-imports children). Largest runtime file
is now `card_plugin/sync.rs` at 748 lines (was `card_plugin.rs` at 4,129).
- **v0.41.1 pile-marker fix** (PR #131): marker outline + "A"/"K" watermark
children are re-derived from the layout on every resize — previously
spawn-time-sized, rendering as oversized grey slabs on empty foundations
after fold/unfold. Found via photo evidence, fixed, re-verified on device.
- **Obtainium note:** reported "no suitable release" for v0.41.0 even though
the anonymous releases API, `releases/latest`, and the APK download were
all verified fine — client-side issue; sideload via adb was used instead.
---
@@ -127,32 +168,17 @@ Three bugs fixed:
## Open punch list
### 1. Physical-device smoke test — THE ONLY REMAINING v0.40.0 ITEM
### 1. Physical-device smoke test — DONE (2026-07-06, Galaxy Fold 7)
This is the **single outstanding task** for the v0.40.0 Android release. Everything
else is done and verified: workspace gates, `aarch64-linux-android` cross-compile +
clippy, release manifest sanity, a full local signed APK, the published release, and
Obtainium-facing delivery (public releases API, latest non-draft release, APK
downloadable anonymously). The only thing that cannot be done without hardware is
running the app on a real phone.
v0.41.1 installed via adb and the full device checklist passed on hardware:
fold/unfold layout on both screens (incl. the #116 resume path and the
pile-marker fix), safe-area inset resolution, Draw-Three waste fan tap
accuracy (#106), modal centring on both screens, drag-and-drop across all
pile types, text rendering, kill-and-restore, and the sync token flow.
Reminder for future gates: AVD is not a substitute — `adb shell input tap`
doesn't deliver real touch events.
Install the published APK on a real Android device (not AVD) and run the checklist
in `docs/ANDROID.md §4`. This has never been gated in CI — AVD `adb shell input tap`
doesn't deliver real touch events, so physical-device smoke testing is the only gate.
The signed release APK is published (grab it from the release page, or use the local
`target/debug/apk/ferrous-solitaire.apk`). When testing, specifically exercise the
Draw-Three waste fan fixed in #106: switch to Draw-Three, draw several cards, and
confirm dragging the visible top waste card plays *that* card, not the one beneath it.
Latest AVD smoke (2026-06-08 local / 2026-06-09 UTC): built
`target/debug/apk/ferrous-solitaire.apk` for `x86_64-linux-android`, installed
it on AVD `Pixel_7`, launched `android.app.NativeActivity`, confirmed Bevy
rendered the board, safe-area insets resolved as `top=136 bottom=63 left=0
right=0` after 2 frames, onboarding could be dismissed via AVD input, and
filtered logcat showed no Ferrous panic/fatal/ANR.
### 2. Matomo analytics live validation (independent — NOT a v0.40.0 release blocker)
### 2. Matomo analytics live validation (independent — NOT a release blocker)
Separate, ongoing task unrelated to the Android release. `Settings` has
`analytics_enabled`, `matomo_url`, and `matomo_site_id`; the engine consumes them via
@@ -164,6 +190,24 @@ validation checklist and the current web/WASM decision notes.
## Architectural notes for next session
- **Plugin submodule pattern (2026-07-06 splits):** big plugins are module
directories: `mod.rs` holds types/markers/plugin-build and glob-imports the
children (`use input::*;`); children hold `pub(super)` systems and start
with `use super::*;` plus their own external imports. Tests (`tests.rs`)
may need explicit imports for names no longer used by `mod.rs` itself.
- **Marker child-resize rule:** anything spawned as a *child* of a
layout-sized entity (outline frames, watermark text) must be re-derived in
`on_window_resized` too — resizing only the parent sprite leaves children
at spawn-time size (the v0.41.1 foldable bug).
- **Fold 7 quirks:** both screens report identical safe-area insets
(top=110, bottom=0), so inset-driven relayout never fires on fold; layout
correctness across folds rides entirely on `WindowResized`. winit 0.30
logs `TODO: find a way to notify application of content rect change` on
resume — see #130 if a stale-width layout ever reproduces; v0.41.1 logs
every Android relayout (`layout: resize to WxH`) for exactly this.
- **Reduce-motion pattern:** always gate in the `start_*` / `detect_*` system
(the trigger), not the `tick_*` system. If the component is never inserted, the
tick path never runs. See `hud_plugin.rs::detect_score_change` and
+8 -5
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
@@ -67,7 +68,9 @@ if ! command -v wasm-bindgen &> /dev/null; then
fi
echo "Building solitaire_web (Bevy WASM app)..."
cargo build --release --target wasm32-unknown-unknown -p solitaire_web
# wasm-release is the size-focused profile (fat LTO, CGU=1, opt-level "s") —
# see [profile.wasm-release] in Cargo.toml. Download size is the constraint.
cargo build --profile wasm-release --target wasm32-unknown-unknown -p solitaire_web
echo "Running wasm-bindgen for solitaire_web..."
wasm-bindgen \
@@ -75,7 +78,7 @@ wasm-bindgen \
--out-name canvas \
--target web \
--no-typescript \
"$REPO_ROOT/target/wasm32-unknown-unknown/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.
+5
View File
@@ -34,6 +34,11 @@ spec:
key: jwt-secret
- name: SERVER_PORT
value: "8080"
# Theme-store catalog directory on the persistent volume.
# Scanned once at startup — after dropping new theme zips
# into /data/theme_store, restart the deployment.
- name: THEME_STORE_DIR
value: /data/theme_store
volumeMounts:
- name: db-data
mountPath: /data
+5
View File
@@ -6,8 +6,13 @@ services:
# Override DATABASE_URL so the DB always lands in the persistent volume,
# regardless of what .env contains.
DATABASE_URL: sqlite:///data/solitaire.db
# Theme-store catalog directory (scanned once at startup; the
# host ./theme_store folder is where the operator drops theme
# zips + preview PNGs).
THEME_STORE_DIR: /theme_store
volumes:
- ./data:/data
- ./theme_store:/theme_store:ro
restart: unless-stopped
expose:
- "${SERVER_PORT:-8080}"
+204
View File
@@ -0,0 +1,204 @@
# Menu UX Redesign — July 2026
Status: PLANNING. Visual identity (Terminal / base16-eighties) is settled and
out of scope — this is about **structure and interaction**, not colors or type.
## 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 (proposed) vs. keep global row?
3. Time Attack + Seed: top-level cards (proposed, grid stays symmetric) vs.
tucked under a "More" card?
4. Stitch mockups for Phase B, or iterate directly in-engine?
5. Phase F bottom bar: touch-only (proposed) or also desktop?
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# 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 — 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).
set -uo pipefail
REPO="funman300/Ferrous-Solitaire"
BASE="https://git.aleshym.co/api/v1/repos/${REPO}"
INTERVAL="${1:-20}"
CFG="${HOME}/.config/tea/config.yml"
TOKEN="$(grep -E '^[[:space:]]*token:' "$CFG" | head -1 | sed -E 's/.*token:[[:space:]]*//' | tr -d '"'\'' ')"
[ -z "$TOKEN" ] && { echo "error: no token in $CFG" >&2; exit 1; }
echo "── watching ${REPO} deploy (poll ${INTERVAL}s, Ctrl-C to stop) ──"
while :; do
json="$(curl -s --max-time 20 -H "Authorization: token ${TOKEN}" "${BASE}/actions/runs?limit=6")"
# Print rows + emit the deploy state on the last line (parsed below).
# JSON is passed via env var because `python3 -` reads its program from the
# heredoc on stdin, so stdin can't also carry the data.
out="$(JSON_DATA="$json" python3 - <<'PY'
import os, sys, json, datetime
now = datetime.datetime.now().strftime("%H:%M:%S")
raw = os.environ.get("JSON_DATA", "").strip()
try:
d = json.loads(raw)
except Exception:
print("[%s] (api unavailable, retrying)" % now)
print("STATE=DEPLOYING")
sys.exit(0)
runs = d.get("workflow_runs", [])[:6]
icons = {("completed","success"):"OK ", ("completed","failure"):"FAIL",
("completed","cancelled"):"CXL "}
def ic(s, c):
if s == "queued": return "queue"
if s == "in_progress": return "run.."
return icons.get((s, c), s or "?")
print("[%s]" % datetime.datetime.now().strftime("%H:%M:%S"))
for r in runs:
wf = str(r.get("path","")).split("@")[0].split("/")[-1].replace(".yml","")
print(" %-5s %-5s %-7s %-18s %s/%s" % (
ic(r.get("status"), r.get("conclusion")),
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"))]
top = db[0] if db else None
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."
echo " Test: https://klondike.aleshym.co/play?v=${RANDOM}"
break
fi
sleep "$INTERVAL"
done
-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
+539 -63
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.
///
@@ -439,10 +580,7 @@ impl GameState {
self.session.history().len()
}
fn cards_with_face(
cards: impl IntoIterator<Item = Card>,
face_up: bool,
) -> Vec<(Card, bool)> {
fn cards_with_face(cards: impl IntoIterator<Item = Card>, face_up: bool) -> Vec<(Card, bool)> {
cards.into_iter().map(|card| (card, face_up)).collect()
}
@@ -507,8 +645,10 @@ impl GameState {
Self::cards_with_face(cards.iter().cloned(), true)
}
KlondikePile::Tableau(tableau) => {
let mut cards =
Self::cards_with_face(state.tableau_face_down_cards(tableau).iter().cloned(), false);
let mut cards = Self::cards_with_face(
state.tableau_face_down_cards(tableau).iter().cloned(),
false,
);
cards.extend(Self::cards_with_face(
state.tableau_face_up_cards(tableau).iter().cloned(),
true,
@@ -823,9 +963,7 @@ impl GameState {
) -> Option<(KlondikePile, KlondikePile, usize)> {
let state = self.session.state().state().state();
match instruction {
KlondikeInstruction::RotateStock => {
Some((KlondikePile::Stock, KlondikePile::Stock, 1))
}
KlondikeInstruction::RotateStock => Some((KlondikePile::Stock, KlondikePile::Stock, 1)),
KlondikeInstruction::DstFoundation(dst_foundation) => {
if matches!(dst_foundation.src, KlondikePile::Foundation(_)) {
return None;
@@ -863,6 +1001,13 @@ impl GameState {
}
/// Draw cards from stock to waste. When stock is empty, recycles waste back to stock.
///
/// Recycling is deliberately unlimited in every draw mode: extra passes
/// are discouraged through the upstream score penalty, never rejected
/// with a [`MoveError`]. The difficulty seed catalog and the
/// winnable-deal solver are verified under this rule — see the
/// "Rules decisions" note in `ARCHITECTURE.md` (`solitaire_core`
/// section) before considering a hard pass limit.
pub fn draw(&mut self) -> Result<(), MoveError> {
if self.is_won() {
return Err(MoveError::GameAlreadyWon);
@@ -921,10 +1066,7 @@ impl GameState {
///
/// Returns [`MoveError::RuleViolation`] if the instruction is illegal in the
/// current position, or [`MoveError::GameAlreadyWon`] once the game is over.
pub fn apply_instruction(
&mut self,
instruction: KlondikeInstruction,
) -> Result<(), MoveError> {
pub fn apply_instruction(&mut self, instruction: KlondikeInstruction) -> Result<(), MoveError> {
if self.is_won() {
return Err(MoveError::GameAlreadyWon);
}
@@ -1108,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
@@ -1125,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::*;
@@ -1222,6 +1443,30 @@ mod tests {
);
}
#[test]
fn draw_one_recycling_is_unlimited_by_design() {
// Rules decision (Gitea #117): stock recycling is unlimited in every
// draw mode. Extra passes are discouraged via the upstream score
// penalty, never blocked with a MoveError. The difficulty seed
// catalog and the winnable-deal solver are verified under this rule,
// so a hard pass limit must not be introduced casually — see the
// "Rules decisions" note in ARCHITECTURE.md (`solitaire_core`).
let mut game = game_at_first_recycle().expect("could not reach recycle");
// ~24 draws per Draw-1 pass; 2_000 draws is ample budget for 10 passes.
for _ in 0..2_000 {
if game.recycle_count() >= 10 {
break;
}
game.draw()
.expect("unlimited recycling: draw must never fail on pass count");
}
assert!(
game.recycle_count() >= 10,
"expected at least 10 recycles within the draw budget, got {}",
game.recycle_count(),
);
}
#[test]
fn undo_applies_minus_15_penalty_via_upstream_score() {
// A foundation move scores +10 upstream; undoing it nets the move score
@@ -1265,12 +1510,13 @@ mod tests {
game.take_from_foundation = false;
assert!(!game.can_move_cards(&from, &to, 1));
assert!(
legal_pile_moves(&game)
.iter()
.all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_))
|| !matches!(t, KlondikePile::Tableau(_)))
);
assert!(legal_pile_moves(&game).iter().all(|(f, t, _)| !matches!(
f,
KlondikePile::Foundation(_)
) || !matches!(
t,
KlondikePile::Tableau(_)
)));
assert!(game.move_cards(from, to, 1).is_err());
}
@@ -1325,14 +1571,244 @@ 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
// budget reaches the solver unchanged.
let easy = GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 1_000, 1_000);
let medium =
GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 5_000, 5_000);
let easy = GameState::solve_fresh_deal(
0xD1FF_0000_0000_0012,
DrawStockConfig::DrawOne,
1_000,
1_000,
);
let medium = GameState::solve_fresh_deal(
0xD1FF_0000_0000_0012,
DrawStockConfig::DrawOne,
5_000,
5_000,
);
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);
}
}
+43 -3
View File
@@ -3,6 +3,7 @@ pub mod error;
pub mod game_state;
pub mod klondike_adapter;
pub mod scoring;
pub mod spider;
// Re-export the upstream types that cross the solitaire_core API boundary so
// downstream crates (engine, wasm) can import from one place without a direct
@@ -12,12 +13,51 @@ pub mod scoring;
// 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 klondike::{DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau};
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::{
RunLength, Spider, SpiderConfig, SpiderGameState, SpiderInstruction, SpiderIter, SpiderMove,
SpiderScoring, SpiderStats, SpiderSuits, SpiderTableau,
};
/// All four foundation slots, in slot order.
///
/// Canonical iteration source for `Foundation` — upstream `klondike` has no
/// `Foundation::ALL` (unlike `Suit::SUITS` / `Rank::RANKS` in `card_game`),
/// and inherent impls cannot be added to a foreign type, so the workspace
/// const lives here. Use this instead of hand-rolling `[Foundation; 4]`
/// arrays; scattered copies can silently diverge.
pub const FOUNDATIONS: [Foundation; 4] = [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
];
/// All seven tableau columns, in column order (left to right on screen).
///
/// Canonical iteration source for `Tableau` — see [`FOUNDATIONS`] for why
/// this lives here rather than upstream.
pub const TABLEAUS: [Tableau; 7] = [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
];
#[cfg(test)]
mod proptest_tests;
+9 -2
View File
@@ -41,13 +41,20 @@ fn all_cards(game: &GameState) -> Vec<Card> {
);
}
for t in &tableaux {
cards.extend(game.pile(KlondikePile::Tableau(*t)).iter().map(|(c, _)| c.clone()));
cards.extend(
game.pile(KlondikePile::Tableau(*t))
.iter()
.map(|(c, _)| c.clone()),
);
}
cards
}
fn draw_mode_strategy() -> impl Strategy<Value = DrawStockConfig> {
prop_oneof![Just(DrawStockConfig::DrawOne), Just(DrawStockConfig::DrawThree)]
prop_oneof![
Just(DrawStockConfig::DrawOne),
Just(DrawStockConfig::DrawThree)
]
}
/// Apply a sequence of random actions to a game, silently ignoring errors.
File diff suppressed because it is too large Load Diff
+7
View File
@@ -24,6 +24,9 @@ uuid = { workspace = true }
dirs = { workspace = true }
reqwest = { workspace = true }
tokio = { workspace = true }
# Theme-store downloads are verified against the catalog's SHA-256
# before the archive is handed to the engine's theme importer.
sha2 = { workspace = true }
# `keyring-core` is the typed Entry/Error API used by
# `auth_tokens`. The crate's own dependency tree pulls in
@@ -47,6 +50,10 @@ sqlx = { workspace = true }
jsonwebtoken = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
# theme_store_round_trip builds a theme zip in a tempdir for the
# in-process store server.
zip = { workspace = true }
tempfile = { workspace = true }
[lints]
workspace = true
+12 -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,15 +149,18 @@ 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;
#[cfg(not(target_arch = "wasm32"))]
pub use sync_client::{SolitaireServerClient, provider_for_backend};
#[cfg(not(target_arch = "wasm32"))]
pub mod theme_store_client;
#[cfg(not(target_arch = "wasm32"))]
pub use theme_store_client::{ThemeStoreClient, ThemeStoreError};
pub mod replay;
pub use replay::{
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay,
+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);
+4 -2
View File
@@ -161,8 +161,10 @@ pub struct Settings {
/// Identifier of the active card-art theme. Matches `meta.id` from
/// the theme's `theme.ron` manifest. `"dark"` and `"classic"` are
/// always present; user-supplied themes register under their own ids.
/// Older `settings.json` files that stored `"default"` or `"classic"`
/// are migrated to `"dark"` by [`Settings::sanitized`].
/// Older `settings.json` files that stored `"default"` (the
/// pre-rename id of the dark theme) are migrated to `"dark"` by
/// [`Settings::sanitized`]; `"classic"` is a valid player choice
/// and is never rewritten.
#[serde(default = "default_theme_id")]
pub selected_theme_id: String,
/// Set to `true` once the achievement-onboarding info-toast has been
+17 -36
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,19 +518,24 @@ 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 = load_game_state_from(&path)
.expect("a valid in-progress game must load without error");
let loaded =
load_game_state_from(&path).expect("a valid in-progress game must load without error");
// 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!(
@@ -569,7 +546,11 @@ mod tests {
// Derived board reads match the live game (move count + recycle count are
// both rebuilt from the replayed forward history).
assert_eq!(loaded.move_count(), gs.move_count(), "move_count round-trips");
assert_eq!(
loaded.move_count(),
gs.move_count(),
"move_count round-trips"
);
assert_eq!(
loaded.recycle_count(),
gs.recycle_count(),
+35 -9
View File
@@ -77,6 +77,11 @@ pub struct SolitaireServerClient {
username: String,
/// Shared `reqwest` client (keeps connection pools alive across calls).
client: reqwest::Client,
/// Serialises token refreshes. The server rotates refresh tokens and each
/// is single-use, so two overlapping 401-retries must not both spend one:
/// the loser's (already-consumed) token would be rejected and surface as
/// a spurious "session expired" to the player. See [`Self::refresh_token`].
refresh_lock: tokio::sync::Mutex<()>,
}
#[cfg(not(target_arch = "wasm32"))]
@@ -90,6 +95,7 @@ impl SolitaireServerClient {
base_url: base_url.into().trim_end_matches('/').to_owned(),
username: username.into(),
client: reqwest::Client::new(),
refresh_lock: tokio::sync::Mutex::new(()),
}
}
@@ -172,7 +178,27 @@ impl SolitaireServerClient {
/// The server rotates refresh tokens on each call: the response includes a
/// new refresh token that replaces the old one. Both tokens are persisted
/// to the OS keychain on success.
async fn refresh_token(&self) -> Result<(), SyncError> {
///
/// `stale_access` is the access token that just earned the caller a 401.
/// Refreshes are serialised behind [`Self::refresh_lock`]: with single-use
/// rotated refresh tokens, two overlapping 401-retries (e.g. a replay
/// upload racing a manual sync) must not both call `/api/auth/refresh` —
/// the second call would present an already-consumed token, get rejected,
/// and force a re-login for no user-visible reason. Whoever loses the lock
/// race checks whether the stored access token has already moved past
/// `stale_access`; if so the refresh already happened and there is nothing
/// left to do.
async fn refresh_token(&self, stale_access: &str) -> Result<(), SyncError> {
let _guard = self.refresh_lock.lock().await;
if let Ok(current) = load_access_token(&self.username)
&& current != stale_access
{
// Another task refreshed while we waited on the lock; retry with
// the token it stored rather than spending the new refresh token.
return Ok(());
}
let old_refresh =
load_refresh_token(&self.username).map_err(|e| SyncError::Auth(e.to_string()))?;
@@ -231,7 +257,7 @@ impl SyncProvider for SolitaireServerClient {
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
// Token expired — refresh and retry once.
self.refresh_token().await?;
self.refresh_token(&token).await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -264,7 +290,7 @@ impl SyncProvider for SolitaireServerClient {
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
// Token expired — refresh and retry once.
self.refresh_token().await?;
self.refresh_token(&token).await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -331,7 +357,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?;
self.refresh_token(&token).await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -366,7 +392,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?;
self.refresh_token(&token).await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -406,7 +432,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?;
self.refresh_token(&token).await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -446,7 +472,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?;
self.refresh_token(&token).await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -480,7 +506,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?;
self.refresh_token(&token).await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -542,7 +568,7 @@ impl SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?;
self.refresh_token(&token).await?;
let new_token = self.access_token()?;
let resp = self
.client
+200
View File
@@ -0,0 +1,200 @@
//! HTTP client for the server's theme-store endpoints.
//!
//! Fetches the catalog (`GET /api/themes`) and downloads theme
//! archives, verifying each download's size and SHA-256 against the
//! catalog entry before returning the bytes. The caller (the engine's
//! theme-store UI) hands verified bytes to the theme importer, which
//! independently re-validates the archive's structure — the store
//! pipeline never trusts a byte the importer hasn't checked.
//!
//! Native-only: gated out on wasm32 alongside the other `reqwest`
//! consumers in this crate.
use sha2::{Digest, Sha256};
use solitaire_sync::{ThemeCatalogEntry, ThemeCatalogResponse};
use thiserror::Error;
/// Hard cap on a theme archive download, matching the engine
/// importer's `MAX_ARCHIVE_BYTES` — anything larger can never import,
/// so downloading it is pure waste.
pub const MAX_THEME_DOWNLOAD_BYTES: u64 = 20 * 1024 * 1024;
/// Errors surfaced by [`ThemeStoreClient`].
#[derive(Debug, Error)]
pub enum ThemeStoreError {
/// The request could not be sent or the response body not read.
#[error("network error: {0}")]
Network(String),
/// The server answered with a non-success status code.
#[error("server returned HTTP {0}")]
Http(u16),
/// The catalog JSON did not parse.
#[error("malformed catalog: {0}")]
MalformedCatalog(String),
/// The archive is larger than [`MAX_THEME_DOWNLOAD_BYTES`] or its
/// catalog-declared size.
#[error("archive size {got} exceeds the expected {expected} bytes")]
Oversized { expected: u64, got: u64 },
/// The downloaded bytes do not hash to the catalog's SHA-256 —
/// the file changed on the server or was corrupted in transit.
#[error("archive checksum mismatch (expected {expected}, got {got})")]
ChecksumMismatch { expected: String, got: String },
}
/// Client for one server's theme store.
///
/// Unauthenticated: the catalog and downloads are public endpoints,
/// so unlike `SolitaireServerClient` there is no token handling.
pub struct ThemeStoreClient {
/// Base URL of the server, trailing slash stripped.
base_url: String,
client: reqwest::Client,
}
impl ThemeStoreClient {
/// Construct a client for the server at `base_url`
/// (e.g. `"https://solitaire.example.com"`).
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into().trim_end_matches('/').to_owned(),
client: reqwest::Client::new(),
}
}
/// Fetch the theme catalog, sorted by display name (server-side).
pub async fn fetch_catalog(&self) -> Result<Vec<ThemeCatalogEntry>, ThemeStoreError> {
let resp = self
.client
.get(format!("{}/api/themes", self.base_url))
.send()
.await
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
if !resp.status().is_success() {
return Err(ThemeStoreError::Http(resp.status().as_u16()));
}
let catalog: ThemeCatalogResponse = resp
.json()
.await
.map_err(|e| ThemeStoreError::MalformedCatalog(e.to_string()))?;
Ok(catalog.themes)
}
/// Download `entry`'s archive and verify it against the catalog's
/// size and SHA-256. Returns the verified `.zip` bytes, ready for
/// the engine's theme importer.
pub async fn download_theme(
&self,
entry: &ThemeCatalogEntry,
) -> Result<Vec<u8>, ThemeStoreError> {
// The catalog itself could name an absurd size; refuse before
// buffering anything.
if entry.size_bytes > MAX_THEME_DOWNLOAD_BYTES {
return Err(ThemeStoreError::Oversized {
expected: MAX_THEME_DOWNLOAD_BYTES,
got: entry.size_bytes,
});
}
let resp = self
.client
.get(format!("{}{}", self.base_url, entry.download_url))
.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()))?;
verify_archive(&bytes, entry)?;
Ok(bytes.to_vec())
}
}
/// Checks downloaded `bytes` against the catalog `entry`'s declared
/// size and SHA-256. Pure so it can be unit-tested without a server.
fn verify_archive(bytes: &[u8], entry: &ThemeCatalogEntry) -> Result<(), ThemeStoreError> {
if bytes.len() as u64 != entry.size_bytes {
return Err(ThemeStoreError::Oversized {
expected: entry.size_bytes,
got: bytes.len() as u64,
});
}
let got = hex_digest(bytes);
// Case-insensitive: the server emits lowercase hex, but a
// hand-authored catalog mirror shouldn't fail on case alone.
if !got.eq_ignore_ascii_case(&entry.sha256) {
return Err(ThemeStoreError::ChecksumMismatch {
expected: entry.sha256.clone(),
got,
});
}
Ok(())
}
/// Lowercase-hex SHA-256 of `bytes`. Mirrors the server's digest
/// encoding in `solitaire_server::theme_store`.
fn hex_digest(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut out = String::with_capacity(digest.len() * 2);
for byte in digest {
out.push_str(&format!("{byte:02x}"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn entry_for(bytes: &[u8]) -> ThemeCatalogEntry {
ThemeCatalogEntry {
id: "neon".into(),
name: "Neon".into(),
author: "Test".into(),
version: "1.0.0".into(),
card_aspect: (2, 3),
size_bytes: bytes.len() as u64,
sha256: hex_digest(bytes),
download_url: "/api/themes/neon/download".into(),
preview_url: None,
}
}
#[test]
fn verify_accepts_matching_bytes() {
let bytes = b"theme archive bytes";
assert!(verify_archive(bytes, &entry_for(bytes)).is_ok());
}
#[test]
fn verify_accepts_uppercase_catalog_hash() {
let bytes = b"theme archive bytes";
let mut entry = entry_for(bytes);
entry.sha256 = entry.sha256.to_uppercase();
assert!(verify_archive(bytes, &entry).is_ok());
}
#[test]
fn verify_rejects_size_mismatch() {
let bytes = b"theme archive bytes";
let mut entry = entry_for(bytes);
entry.size_bytes += 1;
assert!(matches!(
verify_archive(bytes, &entry),
Err(ThemeStoreError::Oversized { .. })
));
}
#[test]
fn verify_rejects_checksum_mismatch() {
let bytes = b"theme archive bytes";
let mut entry = entry_for(bytes);
entry.sha256 = "0".repeat(64);
assert!(matches!(
verify_archive(bytes, &entry),
Err(ThemeStoreError::ChecksumMismatch { .. })
));
}
}
@@ -0,0 +1,118 @@
//! End-to-end theme-store test: a real `solitaire_server` router
//! serving a scanned catalog over a localhost TCP socket, consumed by
//! [`solitaire_data::ThemeStoreClient`].
//!
//! Mirrors the `sync_round_trip` harness: `TcpListener` on port 0,
//! router in a background `tokio::spawn`, no explicit shutdown.
#![cfg(not(target_arch = "wasm32"))]
use std::io::Write as _;
use std::path::{Path, PathBuf};
use solitaire_data::ThemeStoreClient;
use solitaire_server::theme_store::ThemeStore;
/// Minimal theme archive: the catalog scan only reads the `meta`
/// block, so no face SVGs are needed.
fn write_store_zip(dir: &Path, file_name: &str, id: &str, name: &str) -> PathBuf {
let manifest = format!(
r#"(
meta: (
id: "{id}",
name: "{name}",
author: "Round Trip",
version: "1.0.0",
card_aspect: (2, 3),
),
faces: {{}},
back: "back.svg",
)"#
);
let path = dir.join(file_name);
let file = std::fs::File::create(&path).expect("create zip");
let mut writer = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default();
writer.start_file("theme.ron", options).expect("start_file");
writer
.write_all(manifest.as_bytes())
.expect("write manifest");
writer.finish().expect("finish zip");
path
}
/// Spawn the test server with a theme store scanned from `store_dir`
/// and return its base URL.
async fn spawn_store_server(store_dir: &Path) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("failed to bind test listener");
let addr = listener.local_addr().expect("listener has no local addr");
let pool = solitaire_server::build_test_pool().await;
let app =
solitaire_server::build_test_router_with_theme_store(pool, ThemeStore::scan(store_dir));
tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app).await {
eprintln!("test server crashed: {e}");
}
});
format!("http://{addr}")
}
/// Catalog fetch → download → verified bytes match the file the
/// server scanned. This is the exact path the engine's store UI runs.
#[tokio::test]
async fn catalog_fetch_and_verified_download_round_trip() {
let dir = tempfile::tempdir().expect("tempdir");
let zip_path = write_store_zip(dir.path(), "neon.zip", "neon", "Neon");
let base_url = spawn_store_server(dir.path()).await;
let client = ThemeStoreClient::new(&base_url);
let catalog = client.fetch_catalog().await.expect("fetch catalog");
assert_eq!(catalog.len(), 1);
let entry = &catalog[0];
assert_eq!(entry.id, "neon");
let bytes = client.download_theme(entry).await.expect("download");
assert_eq!(bytes, std::fs::read(&zip_path).expect("read zip"));
}
/// A catalog entry whose hash no longer matches the served file must
/// be rejected client-side — the importer never sees unverified bytes.
#[tokio::test]
async fn download_with_stale_catalog_hash_is_rejected() {
let dir = tempfile::tempdir().expect("tempdir");
write_store_zip(dir.path(), "neon.zip", "neon", "Neon");
let base_url = spawn_store_server(dir.path()).await;
let client = ThemeStoreClient::new(&base_url);
let mut entry = client.fetch_catalog().await.expect("fetch catalog")[0].clone();
entry.sha256 = "0".repeat(64);
let err = client
.download_theme(&entry)
.await
.expect_err("mismatched hash must fail");
assert!(
matches!(
err,
solitaire_data::ThemeStoreError::ChecksumMismatch { .. }
),
"expected ChecksumMismatch, got: {err:?}"
);
}
/// An empty (or absent) store directory serves an empty catalog — the
/// client sees a store with nothing in it, not an error.
#[tokio::test]
async fn empty_store_yields_empty_catalog() {
let dir = tempfile::tempdir().expect("tempdir");
let base_url = spawn_store_server(dir.path()).await;
let client = ThemeStoreClient::new(&base_url);
let catalog = client.fetch_catalog().await.expect("fetch catalog");
assert!(catalog.is_empty());
}
+39 -103
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>>();
@@ -911,8 +862,7 @@ mod tests {
// Put the active game in Zen mode. evaluate_on_win reads
// GameStateResource.mode directly to populate last_win_is_zen.
app.world_mut().resource_mut::<GameStateResource>().0.mode =
GameMode::Zen;
app.world_mut().resource_mut::<GameStateResource>().0.mode = GameMode::Zen;
app.world_mut().write_message(GameWonEvent {
score: 0,
@@ -1393,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`
@@ -1414,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],
),
)
}
@@ -1458,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),
@@ -1492,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
@@ -1523,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();
@@ -1546,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();
@@ -1570,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();
+17 -3
View File
@@ -252,10 +252,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 +565,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 +607,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
+5 -3
View File
@@ -74,9 +74,11 @@ pub const ALL_RANKS: [Rank; 13] = [
Rank::King,
];
/// Every suit in `Clubs, Diamonds, Hearts, Spades` order — matches
/// `card_plugin::load_card_images` so the suit index used here lines
/// up with `CardImageSet.faces[suit]`.
/// Iteration order for the SVG generator and the pin test only —
/// output files are keyed by `suit_filename`, so no runtime index
/// depends on this order. Kept local (not `Suit::SUITS`) because
/// reordering would churn the pinned snapshot ordering for no
/// benefit.
pub const ALL_SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
/// The rank component of the on-disk filename — `A`, `2`..`10`, `J`,
+27 -24
View File
@@ -53,12 +53,12 @@ pub fn set_user_theme_dir(path: PathBuf) -> Result<(), PathBuf> {
/// Returns the absolute path of the user-themes directory on the
/// current platform.
///
/// # Panics
///
/// Panics if [`solitaire_data::data_dir`] returns `None`, which on
/// desktop indicates a broken `$HOME` / `$XDG_*` configuration.
/// Android always returns `Some`. The panic message names the
/// supported workaround ([`set_user_theme_dir`]).
/// When [`solitaire_data::data_dir`] returns `None` (broken `$HOME` /
/// `$XDG_*` on desktop; always on wasm32, which has no filesystem) this
/// returns an empty path — callers treat that as "no user themes" and
/// the bundled default theme still works. A warning naming the
/// [`set_user_theme_dir`] workaround is logged once. Android always
/// resolves.
pub fn user_theme_dir() -> PathBuf {
if let Some(p) = USER_THEME_DIR_OVERRIDE.get() {
return p.clone();
@@ -76,29 +76,32 @@ fn user_theme_dir_for(data_dir: PathBuf) -> PathBuf {
/// Per-target-os resolution of the platform's data dir. Delegates
/// to [`solitaire_data::data_dir`] which encapsulates the
/// per-target shape (desktop: `dirs::data_dir()`; android: the
/// hardcoded `/data/data/<package>/files` sandbox path). Panics
/// only when the underlying resolver returns `None`, which on
/// desktop indicates a broken `$HOME` / `$XDG_*` configuration —
/// the panic message names the supported workaround.
/// hardcoded `/data/data/<package>/files` sandbox path).
///
/// When the resolver returns `None` — always on wasm32 (no
/// filesystem), or a broken `$HOME` / `$XDG_*` configuration on
/// desktop — this degrades to an empty path, which downstream theme
/// scanning treats as "no user themes"; the bundled default theme is
/// unaffected. CLAUDE.md §2.3 forbids panicking here: losing custom
/// themes must not take the whole game down with it.
fn detected_platform_data_dir() -> PathBuf {
solitaire_data::data_dir().unwrap_or_else(|| {
// On wasm32, data_dir() always returns None — there is no filesystem.
// User themes are not supported in the browser build; return an empty
// path so callers produce a benign empty dir rather than panicking.
#[cfg(target_arch = "wasm32")]
{
PathBuf::new()
}
#[cfg(not(target_arch = "wasm32"))]
{
panic!(
"user_theme_dir(): platform data directory is unavailable. \
On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \
the OS reported no Application Support / AppData path. \
As a workaround call solitaire_engine::assets::user_dir::\
set_user_theme_dir() before App::run()."
)
use std::sync::Once;
static WARN_ONCE: Once = Once::new();
WARN_ONCE.call_once(|| {
bevy::log::warn!(
"user_theme_dir(): platform data directory is unavailable; \
user themes are disabled. On Linux check $XDG_DATA_HOME or \
$HOME; on macOS / Windows the OS reported no Application \
Support / AppData path. As a workaround call \
solitaire_engine::assets::user_dir::set_user_theme_dir() \
before App::run()."
);
});
}
PathBuf::new()
})
}
+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 {
+19 -3
View File
@@ -46,6 +46,11 @@ pub struct AutoCompleteState {
/// Plugin that drives the auto-complete sequence.
pub struct AutoCompletePlugin;
/// Set wrapping the auto-complete detect/drive chain; HUD readers of
/// [`AutoCompleteState`] order themselves after it (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct AutoComplete;
impl Plugin for AutoCompletePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<AutoCompleteState>()
@@ -58,7 +63,9 @@ impl Plugin for AutoCompletePlugin {
drive_auto_complete,
)
.chain()
.after(GameMutation),
.in_set(AutoComplete)
.after(GameMutation)
.before(crate::card_plugin::BoardVisuals),
);
}
}
@@ -140,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;
@@ -147,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 {
@@ -167,9 +179,9 @@ mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{Deck, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::{Foundation, KlondikePile, Tableau};
fn headless_app() -> App {
let mut app = App::new();
@@ -207,7 +219,11 @@ mod tests {
}
g.set_test_tableau_cards(
Tableau::Tableau1,
vec![solitaire_core::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
vec![solitaire_core::Card::new(
Deck::Deck1,
Suit::Clubs,
Rank::Ace,
)],
);
g.set_test_auto_completable(true);
let expected = (
@@ -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:?}"
);
}
}
}
@@ -72,9 +72,7 @@ pub struct HoverState {
/// Describes a user action that arrived while cards were still animating.
#[derive(Debug, Clone)]
pub enum BufferedInput {
Move {
from: MoveRequestEvent,
},
Move { from: MoveRequestEvent },
Draw,
Undo,
}
+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}"
);
}
}
}
File diff suppressed because it is too large Load Diff
+197
View File
@@ -0,0 +1,197 @@
//! Card flip animation and drag shadows.
use super::*;
use std::collections::HashSet;
use solitaire_core::Card;
use crate::animation_plugin::EffectiveSlideDuration;
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
use crate::layout::LayoutResource;
use crate::resources::DragState;
use crate::ui_theme::{CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z};
/// Listens for `CardFlippedEvent` and inserts a `CardFlipAnim` on the entity.
///
/// Skipped when `EffectiveSlideDuration::slide_secs == 0.0` (Instant speed).
pub(super) fn start_flip_anim(
mut events: MessageReader<CardFlippedEvent>,
slide_dur: Option<Res<EffectiveSlideDuration>>,
mut commands: Commands,
card_entities: Query<(Entity, &CardEntity)>,
) {
if slide_dur.is_some_and(|d| d.slide_secs == 0.0) {
// Instant animation speed — skip the flip effect entirely.
events.clear();
return;
}
for CardFlippedEvent(flipped_card) in events.read() {
for (entity, marker) in &card_entities {
if marker.card == *flipped_card {
commands.entity(entity).insert(CardFlipAnim {
timer: 0.0,
phase: FlipPhase::ScalingDown,
});
break;
}
}
}
}
/// Advances `CardFlipAnim` each frame, modifying `Transform::scale.x`.
///
/// - Phase `ScalingDown`: lerps scale.x from 1.0 → 0.0 over `FLIP_HALF_SECS`.
/// - At the midpoint the phase switches to `ScalingUp`, scale.x resets to 0,
/// and a `CardFaceRevealedEvent` is fired so audio plays in sync with the reveal.
/// - Phase `ScalingUp`: lerps scale.x from 0.0 → 1.0 over `FLIP_HALF_SECS`.
/// - When complete the component is removed and scale.x is restored to 1.0.
pub(super) fn tick_flip_anim(
mut commands: Commands,
time: Res<Time>,
mut anims: Query<(Entity, &CardEntity, &mut Transform, &mut CardFlipAnim)>,
mut reveal_events: MessageWriter<CardFaceRevealedEvent>,
) {
let dt = time.delta_secs();
for (entity, card_entity, mut transform, mut anim) in &mut anims {
anim.timer += dt;
match anim.phase {
FlipPhase::ScalingDown => {
let t = (anim.timer / FLIP_HALF_SECS).min(1.0);
transform.scale.x = 1.0 - t;
if t >= 1.0 {
anim.phase = FlipPhase::ScalingUp;
anim.timer = 0.0;
transform.scale.x = 0.0;
// Fire the reveal event exactly once, at the phase transition,
// so the flip sound is synchronised with the visual face reveal.
reveal_events.write(CardFaceRevealedEvent(card_entity.card.clone()));
}
}
FlipPhase::ScalingUp => {
let t = (anim.timer / FLIP_HALF_SECS).min(1.0);
transform.scale.x = t;
if t >= 1.0 {
transform.scale.x = 1.0;
commands.entity(entity).remove::<CardFlipAnim>();
}
}
}
}
}
// ---------------------------------------------------------------------------
// Task #38 — Drag-elevation shadow
// ---------------------------------------------------------------------------
/// Maintains a single `ShadowEntity` while cards are being dragged.
///
/// - If a drag is active, spawns (or repositions) a semi-transparent dark
/// sprite behind the top dragged card.
/// - If no drag is active, despawns the shadow entity.
pub(super) fn update_drag_shadow(
mut commands: Commands,
drag: Res<DragState>,
layout: Option<Res<LayoutResource>>,
card_entities: Query<(&CardEntity, &Transform)>,
card_index: Res<CardEntityIndex>,
mut shadow: Local<Option<Entity>>,
) {
if drag.is_idle() {
// No drag in progress — remove shadow if it exists.
if let Some(e) = shadow.take() {
commands.entity(e).despawn();
}
return;
}
let Some(layout) = layout else { return };
let card_w = layout.0.card_size.x;
let card_h = layout.0.card_size.y;
// Find the world position of the first (top) dragged card.
let top_pos = drag.cards.first().and_then(|first_card| {
card_index
.get(first_card)
.and_then(|entity| card_entities.get(entity).ok())
.map(|(_, t)| t.translation)
});
let Some(top_pos) = top_pos else { return };
// Shadow is slightly larger, offset behind-and-below, at a z slightly
// below the dragged cards.
let shadow_pos = top_pos + Vec3::new(-4.0, 4.0, -1.0);
match *shadow {
Some(e) => {
// Reposition the existing shadow.
commands
.entity(e)
.insert(Transform::from_translation(shadow_pos));
}
None => {
// Spawn a new shadow sprite. Alpha tracks the per-card
// CARD_SHADOW_ALPHA_DRAG token so the Terminal palette's
// "no box-shadow" policy disables this stack shadow in
// lockstep with the per-card shadows. Re-enabling shadows
// is then a one-line change in `ui_theme`, not a hunt
// through plugin code.
let e = commands
.spawn((
ShadowEntity,
Sprite {
color: CARD_SHADOW_COLOR.with_alpha(CARD_SHADOW_ALPHA_DRAG),
custom_size: Some(Vec2::new(card_w + 8.0, card_h + 8.0)),
..default()
},
Transform::from_translation(shadow_pos),
Visibility::default(),
))
.id();
*shadow = Some(e);
}
}
}
/// Snaps every per-card [`CardShadow`] between its idle and lifted tunings
/// based on whether the parent [`CardEntity`] is currently in
/// [`DragState::cards`]. Runs every frame; the transition is an instant snap
/// (no lerp) — the existing shake / settle feedback already handles motion
/// at drag-end, so an additional shadow tween would compete with those cues.
///
/// The shadow size is rebuilt from the parent card's current `Sprite`
/// `custom_size` plus the appropriate padding, so the resize handler does
/// not need to pre-tune shadow sizes for the drag state — this system fixes
/// the geometry within one frame.
pub(super) fn update_card_shadows_on_drag(
drag: Res<DragState>,
cards: Query<(&CardEntity, &Sprite, &Children), Without<CardShadow>>,
mut shadows: Query<(&mut Sprite, &mut Transform), With<CardShadow>>,
) {
let dragged: HashSet<&Card> = drag.cards.iter().collect();
for (card_entity, card_sprite, children) in cards.iter() {
let is_dragged = dragged.contains(&card_entity.card);
let (offset, padding, alpha) = card_shadow_params(is_dragged);
let Some(card_size) = card_sprite.custom_size else {
continue;
};
for child in children.iter() {
let Ok((mut shadow_sprite, mut shadow_transform)) = shadows.get_mut(child) else {
continue;
};
shadow_sprite.color = CARD_SHADOW_COLOR.with_alpha(alpha);
shadow_sprite.custom_size = Some(card_size + padding);
shadow_transform.translation.x = offset.x;
shadow_transform.translation.y = offset.y;
shadow_transform.translation.z = CARD_SHADOW_LOCAL_Z;
}
}
}
// ---------------------------------------------------------------------------
// Task #28 — Hint highlight tick system
// ---------------------------------------------------------------------------
@@ -0,0 +1,273 @@
//! Hint and right-click highlights, plus cursor hit-testing helpers.
use super::*;
use bevy::color::Color;
use solitaire_core::Card;
use solitaire_core::game_state::GameState;
use crate::events::StateChangedEvent;
use crate::layout::{Layout, LayoutResource};
use crate::pause_plugin::PausedResource;
use crate::resources::{DragState, GameStateResource};
use crate::settings_plugin::SettingsResource;
use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker};
/// Counts down `HintHighlight::remaining` each frame. When it reaches zero,
/// removes both `HintHighlight` and `HintHighlightTimer` (if present) and
/// resets the card sprite to its normal face-up colour.
pub(super) fn tick_hint_highlight(
time: Res<Time>,
mut commands: Commands,
mut query: Query<(Entity, &mut HintHighlight, &mut Sprite, &CardEntity)>,
game: Res<GameStateResource>,
settings: Option<Res<SettingsResource>>,
card_images: Option<Res<CardImageSet>>,
) {
let back_idx = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
let use_images = card_images.is_some();
for (entity, mut hint, mut sprite, card_entity) in query.iter_mut() {
hint.remaining -= time.delta_secs();
if hint.remaining <= 0.0 {
// Restore the normal sprite colour.
// When image-based rendering is active, WHITE is the neutral tint;
// otherwise restore the solid colour appropriate to the card state.
sprite.color = if use_images {
Color::WHITE
} else {
let is_face_up = all_cards(&game.0)
.iter()
.find(|(c, _face_up)| *c == card_entity.card)
.is_some_and(|(_, face_up)| *face_up);
if is_face_up {
CARD_FACE_COLOUR
} else {
card_back_colour(back_idx)
}
};
commands
.entity(entity)
.remove::<HintHighlight>()
.remove::<HintHighlightTimer>();
}
}
}
// ---------------------------------------------------------------------------
// Task #46 — Right-click legal destination highlights
// ---------------------------------------------------------------------------
/// Lime tint applied to a `PileMarker` sprite when it is a legal
/// destination for the right-clicked card. Same RGB as the design-
/// system [`STATE_SUCCESS`] token at 60% alpha. Spelled as a literal
/// because `Alpha::with_alpha` is not yet a `const` trait method on
/// stable; the tracking test below pins the RGB to `STATE_SUCCESS`
/// so a palette swap can't drift the two apart silently.
pub(super) const RIGHT_CLICK_HIGHLIGHT_COLOUR: Color = Color::srgba(0.675, 0.761, 0.404, 0.6);
/// Counts down `RightClickHighlightTimer` each frame and clears the highlight
/// when the timer expires.
///
/// This is a fallback expiry: highlights also clear immediately on
/// `StateChangedEvent` (move made) or when the game is paused, whichever comes
/// first. The 1.5 s timer ensures highlights always disappear even if the
/// player takes no further action.
pub(super) fn tick_right_click_highlights(
mut commands: Commands,
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut highlights: Query<
(Entity, &mut RightClickHighlightTimer, &mut Sprite),
With<RightClickHighlight>,
>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
let dt = time.delta_secs();
for (entity, mut timer, mut sprite) in &mut highlights {
timer.0 -= dt;
if timer.0 <= 0.0 {
// Restore the pile marker to its default colour before removing
// the highlight marker component.
sprite.color = PILE_MARKER_DEFAULT_COLOUR;
commands
.entity(entity)
.remove::<RightClickHighlight>()
.remove::<RightClickHighlightTimer>();
}
}
}
/// Removes the `RightClickHighlight` marker from every highlighted pile and
/// resets its sprite colour to `PILE_MARKER_DEFAULT_COLOUR`.
///
/// Shared by the on-state-change and on-pause clear systems to avoid
/// duplicating the removal logic.
pub(super) fn clear_right_click_highlights(
commands: &mut Commands,
highlighted: &Query<Entity, With<RightClickHighlight>>,
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite)>,
) {
for entity in highlighted.iter() {
commands.entity(entity).remove::<RightClickHighlight>();
}
for (_entity, _, mut sprite) in pile_markers.iter_mut() {
if sprite.color == RIGHT_CLICK_HIGHLIGHT_COLOUR {
sprite.color = PILE_MARKER_DEFAULT_COLOUR;
}
}
}
/// Clears all right-click destination highlights whenever any game-state
/// mutation succeeds (`StateChangedEvent` fires).
///
/// This ensures stale highlights do not linger after a card is moved.
pub(super) fn clear_right_click_highlights_on_state_change(
mut events: MessageReader<StateChangedEvent>,
mut commands: Commands,
highlighted: Query<Entity, With<RightClickHighlight>>,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
) {
if events.read().next().is_none() {
return;
}
clear_right_click_highlights(&mut commands, &highlighted, &mut pile_markers);
}
/// Clears all right-click destination highlights when the game is paused
/// (`PausedResource` changes to `true`).
///
/// Prevents highlighted pile markers from remaining visible behind the pause
/// overlay.
pub(super) fn clear_right_click_highlights_on_pause(
paused: Option<Res<PausedResource>>,
mut commands: Commands,
highlighted: Query<Entity, With<RightClickHighlight>>,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
) {
let Some(paused) = paused else { return };
if paused.is_changed() && paused.0 {
clear_right_click_highlights(&mut commands, &highlighted, &mut pile_markers);
}
}
/// Handles right-click: highlights legal destination piles for the clicked card,
/// and clears highlights on any subsequent right- or left-click.
///
/// This system lives in `CardPlugin` to keep `InputPlugin` untouched.
#[allow(clippy::too_many_arguments)]
pub(super) fn handle_right_click(
buttons: Option<Res<ButtonInput<MouseButton>>>,
paused: Option<Res<PausedResource>>,
drag: Res<DragState>,
windows: Query<&Window, With<bevy::window::PrimaryWindow>>,
cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>,
game: Res<GameStateResource>,
mut commands: Commands,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
card_entities: Query<(Entity, &CardEntity, &Transform)>,
highlighted: Query<Entity, With<RightClickHighlight>>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
let Some(buttons) = buttons else { return };
let left_pressed = buttons.just_pressed(MouseButton::Left);
let right_pressed = buttons.just_pressed(MouseButton::Right);
// Clear existing highlights on any click.
if left_pressed || right_pressed {
for entity in &highlighted {
commands.entity(entity).remove::<RightClickHighlight>();
}
for (_entity, _, mut sprite) in &mut pile_markers {
if sprite.color == RIGHT_CLICK_HIGHLIGHT_COLOUR {
sprite.color = PILE_MARKER_DEFAULT_COLOUR;
}
}
}
// Only proceed for right-clicks while not dragging.
if !right_pressed || !drag.is_idle() {
return;
}
let Some(layout) = layout else { return };
// Convert cursor to world-space position.
let Some(world) = cursor_world_pos(&windows, &cameras) else {
return;
};
// Find the topmost face-up card under the cursor.
let Some(card) = find_top_card_at(world, &game.0, &layout.0, &card_entities) else {
return;
};
let Some(source_pile) = game.0.pile_containing_card(card.clone()) else {
return;
};
// Tint piles that legally accept the card.
for (entity, pile_marker, mut sprite) in &mut pile_markers {
let legal = game.0.can_move_cards(&source_pile, &pile_marker.0, 1);
if legal {
sprite.color = RIGHT_CLICK_HIGHLIGHT_COLOUR;
commands
.entity(entity)
.insert(RightClickHighlight)
.insert(RightClickHighlightTimer(1.5));
}
}
}
/// Converts cursor position to 2-D world coordinates.
pub(super) fn cursor_world_pos(
windows: &Query<&Window, With<bevy::window::PrimaryWindow>>,
cameras: &Query<(&Camera, &GlobalTransform)>,
) -> Option<Vec2> {
let window = windows.single().ok()?;
let cursor = window.cursor_position()?;
let (camera, camera_transform) = cameras.single().ok()?;
camera.viewport_to_world_2d(camera_transform, cursor).ok()
}
/// Returns the topmost face-up `Card` under `cursor` by checking axis-aligned
/// bounding rectangles of all card sprites, picking the highest Z.
pub(super) fn find_top_card_at(
cursor: Vec2,
game: &GameState,
layout: &Layout,
card_entities: &Query<(Entity, &CardEntity, &Transform)>,
) -> Option<Card> {
let half = layout.card_size / 2.0;
let mut best: Option<(f32, Card)> = None;
for (_, card_entity, transform) in card_entities.iter() {
let pos = transform.translation.truncate();
if cursor.x < pos.x - half.x
|| cursor.x > pos.x + half.x
|| cursor.y < pos.y - half.y
|| cursor.y > pos.y + half.y
{
continue;
}
let found = all_cards(game)
.into_iter()
.find(|(c, face_up)| *c == card_entity.card && *face_up);
if let Some((card, _)) = found {
let z = transform.translation.z;
if best.as_ref().is_none_or(|(bz, _)| z > *bz) {
best = Some((z, card));
}
}
}
best.map(|(_, card)| card)
}
// ---------------------------------------------------------------------------
// Task #28 — Stock-empty visual indicator
// ---------------------------------------------------------------------------
+206
View File
@@ -0,0 +1,206 @@
//! Card face labels: desktop text labels and Android corner labels.
use super::*;
use bevy::color::Color;
use bevy::sprite::Anchor;
use solitaire_core::{Card, Rank, Suit};
use crate::ui_theme::TEXT_PRIMARY_HC;
pub(super) fn label_for(card: &Card) -> String {
let rank = match card.rank() {
Rank::Ace => "A",
Rank::Two => "2",
Rank::Three => "3",
Rank::Four => "4",
Rank::Five => "5",
Rank::Six => "6",
Rank::Seven => "7",
Rank::Eight => "8",
Rank::Nine => "9",
Rank::Ten => "10",
Rank::Jack => "J",
Rank::Queen => "Q",
Rank::King => "K",
};
let suit = match card.suit() {
Suit::Clubs => "C",
Suit::Diamonds => "D",
Suit::Hearts => "H",
Suit::Spades => "S",
};
format!("{rank}{suit}")
}
/// Suit colour for the rank/suit overlay rendered atop the constant
/// fallback sprite (only fires under `MinimalPlugins` — production
/// renders the suit glyph baked into the PNG). 2-colour traditional
/// pairing — hearts + diamonds share the saturated red, clubs +
/// spades share the near-white. Two accessibility flags compose:
///
/// - `color_blind`: red-suit cards swap to `RED_SUIT_COLOUR_CBM`
/// (lime) — the "Settings toggle swaps red→lime" half of the
/// design system's colour-blind support. CBM is a hue-replacement
/// for red, so HC has no further effect on red when CBM is on
/// (the lime is itself a high-luminance colour).
/// - `high_contrast`: when CBM is off, red suits boost to
/// `RED_SUIT_COLOUR_HC` (`#ff6868`); black suits boost from
/// `#e8e8e8` (near-white) to `#f5f5f5` (`TEXT_PRIMARY_HC`).
///
/// The other half of CBM support (always-on filled-vs-outlined
/// glyph differentiation for ♥♠ vs ♦♣) is baked into the PNG art
/// and has no constant-fallback equivalent.
pub(super) fn text_colour(card: &Card, color_blind: bool, high_contrast: bool) -> Color {
if card.suit().is_red() {
if color_blind {
// CBM lime wins — the colour-blind swap replaces the
// red hue entirely, and the lime is already high-
// luminance, so an HC boost on top has nothing to do.
RED_SUIT_COLOUR_CBM
} else if high_contrast {
RED_SUIT_COLOUR_HC
} else {
RED_SUIT_COLOUR
}
} else if high_contrast {
TEXT_PRIMARY_HC
} else {
BLACK_SUIT_COLOUR
}
}
pub(super) fn label_visibility(face_up: bool) -> Visibility {
if face_up {
Visibility::Inherited
} else {
Visibility::Hidden
}
}
/// Rank+suit string for the readability overlay on touch HUD layouts.
/// Uses Unicode suit glyphs (♠♥♦♣ — U+2660U+2666, covered by FiraMono).
pub(super) fn mobile_label_for(card: &Card) -> String {
let rank = match card.rank() {
Rank::Ace => "A",
Rank::Two => "2",
Rank::Three => "3",
Rank::Four => "4",
Rank::Five => "5",
Rank::Six => "6",
Rank::Seven => "7",
Rank::Eight => "8",
Rank::Nine => "9",
Rank::Ten => "10",
Rank::Jack => "J",
Rank::Queen => "Q",
Rank::King => "K",
};
let suit = match card.suit() {
Suit::Clubs => "",
Suit::Diamonds => "",
Suit::Hearts => "",
Suit::Spades => "",
};
format!("{rank}{suit}")
}
/// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on
/// face-up cards. The background sprite covers the card art's own small
/// corner text so only the large overlay is visible.
/// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on
/// face-up cards using FiraMono (passed via `font_handle`) so that the
/// suit Unicode glyphs U+2660U+2666 render correctly. Without an explicit
/// font handle Bevy falls back to its built-in face which does not include
/// those glyphs, causing a coloured missing-glyph rectangle to appear in
/// the text colour — the root cause of the "red square on face-down cards"
/// visual bug (the box bleeds through near the card edge at z=0.02).
pub(super) fn add_android_corner_label(
parent: &mut ChildSpawnerCommands,
card: &Card,
face_up: bool,
card_size: Vec2,
color_blind: bool,
high_contrast: bool,
font_handle: Option<&Handle<Font>>,
) {
if !face_up {
return;
}
let font_size = card_size.x * FONT_SIZE_FRAC_MOBILE;
let inset = 3.0_f32;
// Background covers ~3 monospace chars wide × 1 line tall.
// FiraMono char width ≈ 0.6 × font_size; 2.0× gives room for "10♠"
// (3 chars = 1.8× font_size) plus a small margin.
let bg_w = font_size * 2.0;
let bg_h = font_size * 1.25;
// Background covers the PNG's baked-in small corner text (top-left).
// Classic PNG cards have a white face, so the background must be white too.
// (CARD_FACE_COLOUR is the Terminal theme's dark face colour — wrong here.)
parent.spawn((
AndroidCornerBg,
Sprite {
color: Color::WHITE,
custom_size: Some(Vec2::new(bg_w, bg_h)),
..default()
},
Transform::from_xyz(
-card_size.x / 2.0 + inset + bg_w / 2.0,
card_size.y / 2.0 - inset - bg_h / 2.0,
0.015,
),
));
// Cover the matching rotated baked-in text at the bottom-right corner.
parent.spawn((
AndroidCornerBg,
Sprite {
color: Color::WHITE,
custom_size: Some(Vec2::new(bg_w, bg_h)),
..default()
},
Transform::from_xyz(
card_size.x / 2.0 - inset - bg_w / 2.0,
-card_size.y / 2.0 + inset + bg_h / 2.0,
0.015,
),
));
// Large rank+suit text drawn on top of the background. FiraMono must be
// wired here explicitly — the suit glyphs (U+2660U+2666) are not in
// Bevy's built-in font and render as a coloured rectangle without it.
//
// Classic PNG cards have a white face: red suits stay the same saturated
// red, but black suits must use a dark colour (CARD_FACE_COLOUR ≈ #1a1a1a)
// rather than the near-white BLACK_SUIT_COLOUR designed for the dark
// Terminal theme background.
let text_col = if card.suit().is_red() {
if color_blind {
RED_SUIT_COLOUR_CBM
} else if high_contrast {
RED_SUIT_COLOUR_HC
} else {
RED_SUIT_COLOUR
}
} else {
CARD_FACE_COLOUR
};
let label_text = mobile_label_for(card);
parent.spawn((
AndroidCornerLabel(label_text.clone()),
CardLabel,
Text2d::new(label_text),
TextFont {
font: font_handle.cloned().unwrap_or_default(),
font_size,
..default()
},
TextColor(text_col),
Anchor::TOP_LEFT,
Transform::from_xyz(-card_size.x / 2.0 + inset, card_size.y / 2.0 - inset, 0.02),
));
}
// ---------------------------------------------------------------------------
// Task #34 — Card-flip animation systems
// ---------------------------------------------------------------------------
+346
View File
@@ -0,0 +1,346 @@
//! Resize handling: window-resize snapping, in-place card resizing,
//! and tableau fan spread.
use super::*;
use std::collections::HashMap;
use bevy::window::WindowResized;
use solitaire_core::Card;
use solitaire_core::game_state::GameState;
use crate::animation_plugin::CardAnim;
use crate::events::StateChangedEvent;
use crate::font_plugin::FontResource;
use crate::layout::{Layout, LayoutResource};
use crate::resources::GameStateResource;
use crate::table_plugin::PileMarker;
use crate::ui_theme::{CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_PADDING_DRAG, CARD_SHADOW_PADDING_IDLE};
/// Coalesces every `WindowResized` event arriving this frame into the latest
/// pending size on [`ResizeThrottle`].
///
/// `WindowResized` fires per pixel of resize drag, so a fast corner drag can
/// emit many events per frame. Reading `.last()` keeps only the final size —
/// every frame's snap target is the most recent window size, never a stale
/// one. Pending stays set across frames until the throttled applier consumes
/// it; that's how we still flush the final "release" position when the user
/// stops dragging.
pub(super) fn collect_resize_events(
mut events: MessageReader<WindowResized>,
mut throttle: ResMut<ResizeThrottle>,
) {
if let Some(ev) = events.read().last() {
throttle.pending = Some(Vec2::new(ev.width, ev.height));
}
}
/// Snaps every card sprite to its target position, size, and (in the
/// fallback Text2d label path) font size when the window is resized.
///
/// **In-place mutation only.** Resize is the hot path — events fire per
/// pixel of drag, so this system cannot afford the despawn/respawn churn
/// `update_card_entity` does. We mutate `Sprite.custom_size`, `Transform`,
/// and child `TextFont.font_size` directly, leaving the card image handle,
/// suit/rank, and `CardLabel` entity untouched. Cards keep their identity
/// across resizes; only their size and position change. The full repaint
/// path lives in [`update_card_entity`] and is still used by every non-resize
/// caller (deals, moves, flips, settings toggles).
///
/// **Throttled to ~20 Hz.** [`ResizeThrottle::pending`] is consumed at most
/// once per [`RESIZE_THROTTLE_SECS`]. When events stop arriving, the next
/// tick past the throttle window flushes the final size and clears
/// `pending`, so the steady-state always matches the user's release size.
///
/// **Cancels in-flight slides.** Any `CardAnim` is removed so a mid-slide
/// tween is not retargeted relative to the previous card-size's position.
///
/// The "↺" stock-empty label's `font_size` is derived from
/// `layout.card_size.x`, so this system also reapplies the stock indicator —
/// otherwise the label would not rescale on resize.
///
/// Scheduled after [`collect_resize_events`] (which itself runs after
/// `LayoutSystem::UpdateOnResize`) so `LayoutResource` reflects the latest
/// window size before we read it.
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
pub(super) fn snap_cards_on_window_resize(
mut commands: Commands,
time: Res<Time>,
mut throttle: ResMut<ResizeThrottle>,
game: Option<Res<GameStateResource>>,
layout: Option<Res<LayoutResource>>,
card_images: Option<Res<CardImageSet>>,
font_res: Option<Res<FontResource>>,
entities: Query<
(Entity, &CardEntity, &mut Sprite, &mut Transform),
(
Without<CardLabel>,
Without<CardShadow>,
Without<CardBackFrame>,
),
>,
label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
shadow_query: Query<
&mut Sprite,
(
With<CardShadow>,
Without<CardEntity>,
Without<PileMarker>,
Without<CardBackFrame>,
),
>,
frame_query: Query<
&mut Sprite,
(
With<CardBackFrame>,
Without<CardEntity>,
Without<CardShadow>,
Without<PileMarker>,
),
>,
mut pile_markers: Query<
(Entity, &PileMarker, &mut Sprite),
(
Without<CardEntity>,
Without<CardShadow>,
Without<CardBackFrame>,
),
>,
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
) {
if throttle.pending.is_none() {
return;
}
let now = time.elapsed_secs();
if !should_apply_resize(now, throttle.last_applied_secs) {
return;
}
let Some(game) = game else {
// Nothing to apply — clear pending so we don't busy-loop.
throttle.pending = None;
return;
};
let Some(layout) = layout else {
throttle.pending = None;
return;
};
resize_cards_in_place(
&mut commands,
&game.0,
&layout.0,
card_images.as_deref(),
entities,
label_query,
shadow_query,
frame_query,
);
let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
apply_stock_empty_indicator(
&mut commands,
&game.0,
&mut pile_markers,
&label_children,
&layout.0,
font,
);
throttle.last_applied_secs = now;
throttle.pending = None;
}
/// In-place "size-only" sibling of [`sync_cards`]: walks every existing card
/// entity, updates `Sprite.custom_size` and the snap-`Transform` to match the
/// fresh layout, and (in fallback solid-colour mode) also updates the child
/// `TextFont.font_size` of any `CardLabel`. No despawning, no `Sprite`
/// replacement, no children rebuild — that's the entire point of this path.
///
/// Called only from the resize handler. Game-state changes (deals, moves,
/// flips, settings toggles) still flow through [`sync_cards`] /
/// [`update_card_entity`], which handle add/remove/repaint correctly.
///
/// Any in-flight `CardAnim` slide is removed so a mid-tween card is not
/// retargeted relative to the previous card-size's position.
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub(super) fn resize_cards_in_place(
commands: &mut Commands,
game: &GameState,
layout: &Layout,
card_images: Option<&CardImageSet>,
mut entities: Query<
(Entity, &CardEntity, &mut Sprite, &mut Transform),
(
Without<CardLabel>,
Without<CardShadow>,
Without<CardBackFrame>,
),
>,
mut label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
mut shadow_query: Query<
&mut Sprite,
(
With<CardShadow>,
Without<CardEntity>,
Without<PileMarker>,
Without<CardBackFrame>,
),
>,
mut frame_query: Query<
&mut Sprite,
(
With<CardBackFrame>,
Without<CardEntity>,
Without<CardShadow>,
Without<PileMarker>,
),
>,
) {
let positions = card_positions(game, layout);
let pos_by_id: HashMap<Card, (Vec2, f32)> = positions
.into_iter()
.map(|((c, _face_up), p, z)| (c, (p, z)))
.collect();
for (entity, marker, mut sprite, mut transform) in entities.iter_mut() {
let Some(&(pos, z)) = pos_by_id.get(&marker.card) else {
continue;
};
sprite.custom_size = Some(layout.card_size);
transform.translation.x = pos.x;
transform.translation.y = pos.y;
transform.translation.z = z;
// Cancel any in-flight slide so it doesn't retarget from a stale
// mid-animation position computed against the previous card size.
commands.entity(entity).remove::<CardAnim>();
}
// Resize every per-card shadow halo to match the new card size. Both
// idle and drag states scale with the card body, so we preserve the
// *current* padding (idle vs drag) by keeping the alpha as-is and only
// recomputing the geometry. The drag-tracking system runs every frame
// and will retune offset / alpha / padding-mode within one frame if the
// drag state diverges from the resized geometry.
let idle_padding = CARD_SHADOW_PADDING_IDLE;
let drag_padding = CARD_SHADOW_PADDING_DRAG;
for mut shadow_sprite in shadow_query.iter_mut() {
// Choose padding based on the shadow's current alpha — preserves
// a lifted shadow's larger halo across resize without needing to
// plumb DragState through the resize handler.
let alpha = shadow_sprite.color.alpha();
let padding = if alpha >= CARD_SHADOW_ALPHA_DRAG - 0.001 {
drag_padding
} else {
idle_padding
};
shadow_sprite.custom_size = Some(layout.card_size + padding);
}
// Only the solid-colour fallback path uses CardLabel/Text2d overlays;
// when PNG faces are loaded the rank/suit are baked into the image and
// there is nothing to resize on the label side.
if card_images.is_none() {
let new_font_size = layout.card_size.x * FONT_SIZE_FRAC;
for mut font in label_query.iter_mut() {
font.font_size = new_font_size;
}
}
// Resize every face-down border frame to match the new card size.
let frame_size = layout.card_size + Vec2::splat(CARD_BACK_FRAME_PADDING);
for mut frame_sprite in frame_query.iter_mut() {
frame_sprite.custom_size = Some(frame_size);
}
}
/// Updates font size and top-left anchor transform of every
/// [`AndroidCornerLabel`] entity when `LayoutResource` changes (orientation
/// change or any window resize). The full despawn/respawn path in
/// `update_card_entity` already handles game-state changes; this system
/// covers the resize-only path where children are mutated in place.
pub(super) fn resize_android_corner_labels(
layout: Res<LayoutResource>,
card_images: Option<Res<CardImageSet>>,
mut text_query: Query<(
&AndroidCornerLabel,
&mut Text2d,
&mut TextFont,
&mut Transform,
)>,
mut bg_query: Query<(&mut Sprite, &mut Transform), AndroidCornerBgFilter>,
) {
if !layout.is_changed() || card_images.is_none() {
return;
}
let font_size = layout.0.card_size.x * FONT_SIZE_FRAC_MOBILE;
let inset = 3.0_f32;
let bg_w = font_size * 2.0;
let bg_h = font_size * 1.25;
let text_x = -layout.0.card_size.x / 2.0 + inset;
let text_y = layout.0.card_size.y / 2.0 - inset;
for (label, mut text2d, mut font, mut transform) in text_query.iter_mut() {
text2d.0 = label.0.clone();
font.font_size = font_size;
transform.translation.x = text_x;
transform.translation.y = text_y;
}
for (mut sprite, mut transform) in bg_query.iter_mut() {
sprite.custom_size = Some(Vec2::new(bg_w, bg_h));
transform.translation.x = text_x + bg_w / 2.0;
transform.translation.y = text_y - bg_h / 2.0;
}
}
/// Adjusts `LayoutResource.tableau_fan_frac` (and the face-down companion) so
/// the deepest tableau column fills the available vertical space at every stage
/// of play. Runs after every `StateChangedEvent`.
///
/// Depth is measured across *all* cards in a column, weighting each face-down
/// card by the fixed face-down/face-up step ratio. Counting the face-down
/// portion — not just the face-up tail — is what fills the lower screen on a
/// fresh deal (the deepest column is then six face-down cards under one face-up
/// one): the earlier face-up-only depth was 1, so the fan never spread and the
/// bottom half of a near-square viewport (e.g. an unfolded foldable) sat empty.
///
/// Deeper columns drive the fraction down so everything still fits the window;
/// [`crate::layout::TABLEAU_FAN_FRAC`] floors it to the desktop feel and
/// [`MAX_DYNAMIC_FAN_FRAC`] caps it so a near-empty column doesn't fling its few
/// cards far apart.
pub(super) fn update_tableau_fan_frac(
mut events: MessageReader<StateChangedEvent>,
game: Option<Res<GameStateResource>>,
mut layout: Option<ResMut<LayoutResource>>,
) {
if events.read().next().is_none() {
return;
}
let Some(game) = game else {
return;
};
let Some(layout) = layout.as_mut() else {
return;
};
crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0);
}
/// PostStartup sibling of [`update_tableau_fan_frac`]. The initial deal is
/// inserted directly as `GameStateResource` at startup without a
/// `StateChangedEvent`, so the event-driven system never fires for it. This
/// runs once, before [`sync_cards_startup`] renders, so the very first board
/// (cold start) already fills the viewport — otherwise a fresh deal on a tall /
/// near-square screen (e.g. an unfolded foldable) renders with the unspread fan
/// and a large empty band below the tableau until the first move.
pub(super) fn fill_tableau_fan_on_startup(
game: Option<Res<GameStateResource>>,
mut layout: Option<ResMut<LayoutResource>>,
) {
let Some(game) = game else {
return;
};
let Some(layout) = layout.as_mut() else {
return;
};
crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0);
}
+612
View File
@@ -0,0 +1,612 @@
//! PNG-based card rendering.
//!
//! Card entities are synced with [`GameStateResource`] on every
//! [`StateChangedEvent`]: missing cards are spawned, present cards are
//! repositioned/updated in place, and stale cards are despawned.
//!
//! When [`CardImageSet`] is available, each face-up card renders its own
//! 120×168 px `Handle<Image>` chosen from the 52 per-card PNGs loaded from
//! `assets/cards/faces/{rank}_{suit}.png`. A solid-colour `Sprite` with a
//! `Text2d` rank+suit overlay is used as a fallback when `CardImageSet` is
//! absent (e.g. in tests running under `MinimalPlugins`).
use std::collections::HashMap;
use bevy::color::Color;
use bevy::prelude::*;
use solitaire_core::Card;
use solitaire_core::{KlondikePile, Tableau};
use crate::card_animation::CardAnimation;
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
use crate::game_plugin::GameMutation;
use crate::layout::{Layout, LayoutSystem};
mod anim;
mod highlights;
mod labels;
mod layout;
mod stock;
mod sync;
use anim::*;
use highlights::*;
use labels::*;
use layout::*;
use stock::*;
use sync::*;
use crate::resources::GameStateResource;
use crate::settings_plugin::SettingsChangedEvent;
use crate::ui_theme::{
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_ALPHA_IDLE, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z,
CARD_SHADOW_OFFSET_DRAG, CARD_SHADOW_OFFSET_IDLE, CARD_SHADOW_PADDING_DRAG,
CARD_SHADOW_PADDING_IDLE,
};
/// Per-card vertical step for face-down tableau cards, as a fraction of
/// card height. Smaller than [`crate::layout::TABLEAU_FAN_FRAC`] because face-down cards
/// don't need their full body shown — only the back-pattern strip is
/// visible. Public so `input_plugin` can mirror the exact sprite layout
/// when hit-testing tableau columns; any drift between this and the
/// renderer creates a visible offset between the card face and where
/// clicks land.
///
/// Matches `layout::TABLEAU_FACEDOWN_FAN_FRAC` (0.14). Both constants must
/// stay in sync; the layout constant drives the adaptive LayoutResource value
/// used at runtime, while this one is the minimum floor used by
/// `update_tableau_fan_frac` when computing proportional updates.
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.14;
/// Fraction of card height used as a tiny offset between stacked cards in
/// non-tableau piles, so stacking is visible. Public so other plugins
/// (e.g. input_plugin's drag-rejection tween) can compute the resting
/// `Transform.translation.z` for a card at a given stack index without
/// drifting from the value used by [`card_positions`].
// Must exceed the highest child local-z of any card entity (0.02 for the
// Android corner label) so every card's sprite covers all children of the
// card below it. Raising from 0.003 → 0.025 fixes corner labels on
// foundation piles bleeding through when a 2 sits on an Ace.
pub const STACK_FAN_FRAC: f32 = 0.025;
/// Per-card horizontal fan step for the Draw-Three waste, in logical pixels.
///
/// Derived from the actual tableau column spacing (`Tableau2.x Tableau1.x`)
/// rather than a fixed fraction of card width, so the fan scales with the
/// platform's `H_GAP_DIVISOR` (desktop ≈ 1.25×cw spacing, Android ≈ 1.03×cw).
/// Public so `input_plugin` can hit-test the fanned waste cards at the exact
/// x-offsets the renderer uses; any drift makes a click on the top fanned card
/// land on the card beneath it.
pub fn waste_fan_step(layout: &Layout) -> f32 {
tableau_col_step(layout) * 0.224
}
/// Horizontal distance between adjacent tableau columns (`Tableau2.x
/// Tableau1.x`), in logical pixels. The face-down stock is rendered one column
/// step left of the waste, and the Draw-Three waste fan ([`waste_fan_step`]) is
/// a fraction of it. Public so hit-testing mirrors the renderer exactly.
pub fn tableau_col_step(layout: &Layout) -> f32 {
let t1 = layout
.pile_positions
.get(&KlondikePile::Tableau(Tableau::Tableau1))
.copied()
.unwrap_or_default();
let t2 = layout
.pile_positions
.get(&KlondikePile::Tableau(Tableau::Tableau2))
.copied()
.unwrap_or_default();
(t2.x - t1.x).abs()
}
/// Font size as a fraction of card width.
const FONT_SIZE_FRAC: f32 = 0.28;
/// Font-size fraction for the large-print readability overlay on touch HUD layouts.
/// Spawned on top of PNG face cards to make the rank+suit legible at phone
/// scale, where the baked-in PNG corner text is only ~10 px physical.
const FONT_SIZE_FRAC_MOBILE: f32 = 0.35;
/// Card-face background — Terminal `#1a1a1a` (BG_ELEVATED).
pub const CARD_FACE_COLOUR: Color = Color::srgb(0.102, 0.102, 0.102);
/// Suit colour for hearts + diamonds — saturated red `#e35353`.
/// 2-colour traditional pairing (the "Microsoft Solitaire on dark
/// mode" feel) replacing the brief 4-colour-deck experiment that
/// shipped between v0.21.0 and this commit. Brighter and more
/// saturated than the v0.21.0 pink `#fb9fb1` so the cards read as
/// a "real solitaire deck" rather than a Terminal-pastel theme.
/// Visually distinct from `ACCENT_PRIMARY` (`#a54242` brick red,
/// darker) so chrome and suit don't read as the same hue.
pub const RED_SUIT_COLOUR: Color = Color::srgb(0.890, 0.325, 0.325);
/// High-contrast variant of [`RED_SUIT_COLOUR`] — `#ff6868`. Lifted
/// luminance for the Settings → Accessibility → High-contrast mode
/// toggle. Pre-2-colour-revert this was `#ff8aa0` (pink-salmon)
/// matching the v0.21.0 pink default; rebumped to a brighter red
/// so it reads as "more chromatic" than the new saturated default,
/// not "less saturated." Independent of `RED_SUIT_COLOUR_CBM`
/// (lime) — high-contrast is *additive* over the default colour
/// palette; CBM is a *replacement* of red with a hue-distinct
/// alternative. The two modes can stack; CBM wins when both are on
/// because the CBM lime is itself a high-contrast colour.
pub const RED_SUIT_COLOUR_HC: Color = Color::srgb(1.000, 0.408, 0.408);
/// Suit colour for spades + clubs — near-white `#e8e8e8`. Brighter
/// than `TEXT_PRIMARY` (`#d0d0d0`, foreground gray) so the
/// "black suit" reads as a distinct, chromatic-neutral counterpart
/// to the new saturated red, not as "the same gray as body text."
/// `TEXT_PRIMARY_HC` (`#f5f5f5`) is still brighter for the
/// high-contrast boost path.
pub const BLACK_SUIT_COLOUR: Color = Color::srgb(0.910, 0.910, 0.910);
/// Canonical outer index of `s` in [`CardImageSet::faces`].
///
/// Derived from the upstream `card_game::Suit` discriminants (0..=3 in
/// `Suit::SUITS` order), so every reader and writer of `faces` computes
/// the same layout from the same source. Three hand-rolled copies of this
/// mapping once lived in card_plugin and theme/plugin and were one
/// reorder away from drawing the wrong art.
pub(crate) const fn suit_index(s: solitaire_core::Suit) -> usize {
s as usize
}
/// Canonical inner index of `r` in [`CardImageSet::faces`] — upstream
/// `card_game::Rank` discriminants are 1..=13 in `Rank::RANKS` order.
pub(crate) const fn rank_index(r: solitaire_core::Rank) -> usize {
r as usize - 1
}
/// Pre-loaded [`Handle<Image>`]s for card face and back PNG textures.
///
/// Loaded once at startup by [`load_card_images`]. When this resource is
/// present, card sprites use the PNG artwork; otherwise they fall back to
/// solid-colour sprites (used in tests with `MinimalPlugins`).
#[derive(Resource)]
pub struct CardImageSet {
/// Per-card face images indexed by `[suit][rank]`.
///
/// Layout is pinned to the upstream declaration order — index with
/// [`suit_index`] / [`rank_index`], never a hand-rolled match.
/// Suit order: `Suit::SUITS` (Spades=0, Hearts=1, Clubs=2, Diamonds=3).
/// Rank order: `Rank::RANKS` (Ace=0 … King=12).
pub faces: [[Handle<Image>; 13]; 4],
/// One handle per unlockable card-back design (indices 04). These
/// correspond to the legacy `assets/cards/backs/back_N.png` art, indexed
/// by `Settings::selected_card_back`. Used as a fallback when the active
/// theme does not provide its own back (see [`Self::theme_back`]).
pub backs: [Handle<Image>; 5],
/// Back image supplied by the currently-active card theme, if any.
///
/// Populated by `theme::plugin::apply_theme_to_card_image_set` whenever
/// a `CardTheme` finishes loading. The face-down render path in
/// [`card_sprite`] prefers this handle over the legacy `backs[]` array,
/// so a theme switch swaps both faces *and* the back without the player
/// needing to touch the legacy `selected_card_back` picker. `None` means
/// the active theme did not declare a back asset (or no theme has loaded
/// yet); in that case [`card_sprite`] falls back to the legacy array.
pub theme_back: Option<Handle<Image>>,
}
/// Suit-colour swap for red-suit cards in colour-blind mode — Terminal
/// `#acc267` (lime). Replaces `RED_SUIT_COLOUR` (pink) when CBM is on,
/// providing a hue-distinct alternative that survives the most common
/// red/green deficiencies. Pre-Terminal this was a *face tint*; the new
/// design moves CBM differentiation into the suit glyph colour itself
/// and keeps the face uniformly `CARD_FACE_COLOUR` regardless of CBM.
///
/// The CBM swap is lime (not the `ACCENT_PRIMARY` brick-red) because
/// the primary accent is itself in the red family — using it for
/// "the not-red CBM alternative" would defeat the purpose. Lime is
/// the next-best non-red base16-eighties accent; deuteranopia and
/// protanopia readers see it as visibly distinct from pink.
const RED_SUIT_COLOUR_CBM: Color = Color::srgb(0.675, 0.761, 0.404);
/// Returns the fallback card-back colour for the given unlocked card-back
/// index. Production renders backs from PNG artwork; this fallback only
/// fires under `MinimalPlugins` (tests). Mirrors the 5 accent colours
/// from `card_face_svg::BACK_ACCENTS` so the test-environment back lives
/// in the same hue family as the on-disk PNG art for that index.
fn card_back_colour(selected_card_back: usize) -> Color {
match selected_card_back {
0 => Color::srgb(0.647, 0.259, 0.259), // #a54242 brick red (Terminal canonical, ACCENT_PRIMARY)
1 => Color::srgb(0.675, 0.761, 0.404), // #acc267 lime
2 => Color::srgb(0.882, 0.639, 0.933), // #e1a3ee lavender
3 => Color::srgb(0.984, 0.624, 0.694), // #fb9fb1 pink
_ => Color::srgb(0.867, 0.698, 0.435), // #ddb26f gold (4+)
}
}
/// Marker component linking a Bevy entity to its `solitaire_core::Card`.
#[derive(Component, Debug, Clone)]
pub struct CardEntity {
pub card: Card,
}
/// Cached signature of the inputs that determine a card entity's *child*
/// visuals (drop-shadow, border frame, and the rank/suit label / large-print
/// corner overlay). Stored on each card so [`update_card_entity`] can skip the
/// expensive `despawn_related::<Children>()` + child respawn when nothing about
/// the appearance changed — the common case during a move, where only the
/// card's position changes. Without this guard every `StateChangedEvent`
/// rebuilds all 52 cards' children (≈250 entity spawn/despawns plus 52 `Text2d`
/// glyph re-layouts) in a single frame, which stutters the slide animation on
/// high-resolution devices.
///
/// The children depend only on these four inputs; the card identity is fixed
/// per entity, and the face/back *image* is handled by the always-refreshed
/// `Sprite` (a cheap handle swap), so theme/card-back changes need no child
/// rebuild.
#[derive(Component, Clone, Copy, PartialEq)]
struct CardChildrenKey {
face_up: bool,
card_size: Vec2,
color_blind: bool,
high_contrast: bool,
}
/// Query data read by the card-sync systems for each live card entity:
/// its id, card identity, current transform, any in-flight curve animation,
/// and its cached child-appearance key. Factored into an alias to keep the
/// system signatures readable (and satisfy clippy's `type_complexity`).
type CardSyncData = (
Entity,
&'static CardEntity,
&'static Transform,
Option<&'static CardAnimation>,
Option<&'static CardChildrenKey>,
);
/// Render-side index mapping each live board card to its [`CardEntity`].
///
/// Maintained exclusively by [`rebuild_card_entity_index`] in `PostUpdate`,
/// after the card-sync authority ([`sync_cards_on_change`]) and the resize
/// re-snap have flushed their spawn/despawn `Commands`. Consumers treat it as
/// read-only and must still call `Query::get(entity)` for components beyond the
/// `Entity` id (the map yields only the id).
///
/// Keyed by `Card`, which is unique across all live board entities in
/// single-deck Klondike. Transient entities (drag shadow, labels) carry no
/// `CardEntity` component, so the rebuild — which scans `&CardEntity` — never
/// records them.
#[derive(Resource, Debug, Default)]
pub struct CardEntityIndex(pub HashMap<Card, Entity>);
impl CardEntityIndex {
/// Resolve a card to its live entity, if one is currently spawned.
#[inline]
pub fn get(&self, card: &Card) -> Option<Entity> {
self.0.get(card).copied()
}
}
/// Marker for the text child inside a card entity.
#[derive(Component, Debug)]
pub struct CardLabel;
/// Marker for the large-print rank+suit corner overlay used by touch HUD layouts.
///
/// Spawned on top of PNG face cards (face-up only) at font size
/// [`FONT_SIZE_FRAC_MOBILE`] so the rank and suit character are
/// readable at phone scale. Only exists when `CardImageSet` is present
/// (the fallback solid-colour path uses a plain `CardLabel` instead).
#[derive(Component, Debug, Clone)]
struct AndroidCornerLabel(pub String);
/// Solid-colour background sprite behind [`AndroidCornerLabel`].
///
/// Covers the card art's own small corner rank/suit text so only the
/// large overlay is visible. Sized at [`FONT_SIZE_FRAC_MOBILE`]-derived
/// dimensions and coloured [`CARD_FACE_COLOUR`] to match the card face.
#[derive(Component, Debug, Clone, Copy)]
struct AndroidCornerBg;
type AndroidCornerBgFilter = (With<AndroidCornerBg>, Without<AndroidCornerLabel>);
/// Marker component indicating the card is currently highlighted as a hint.
/// `remaining` counts down in real seconds; the highlight is removed when it
/// reaches zero and the card sprite colour is restored to its normal value.
#[derive(Component, Debug, Clone)]
pub struct HintHighlight {
/// Seconds remaining before the highlight is cleared.
pub remaining: f32,
}
/// Countdown (seconds) until the `HintHighlight` on a card entity is removed.
///
/// Inserted alongside `HintHighlight` by the hint-visual system. When the timer
/// reaches zero both `HintHighlight` and `HintHighlightTimer` are removed from
/// the entity and the sprite colour is restored.
#[derive(Component, Debug, Clone)]
pub struct HintHighlightTimer(pub f32);
/// Marker on a `PileMarker` entity that is highlighted because the right-clicked
/// card can legally be placed there.
#[derive(Component, Debug)]
pub struct RightClickHighlight;
/// Countdown (seconds) until this right-click destination highlight despawns.
///
/// Inserted alongside `RightClickHighlight` so that highlights auto-clear after
/// 1.5 s even if the player does not make a move or click again. The existing
/// clear-on-state-change and clear-on-pause logic still fires early when
/// appropriate.
#[derive(Component, Debug, Clone)]
pub struct RightClickHighlightTimer(pub f32);
/// Marker placed on the child `Text2d` entity that shows "↺" on the stock pile
/// marker when the stock pile is empty.
#[derive(Component, Debug)]
pub struct StockEmptyLabel;
/// Marker on the chip-background sprite of the stock-pile remaining-count
/// badge.
///
/// The badge is spawned as a *top-level* world entity (not parented to the
/// stock [`PileMarker`]) and its `Transform` is recomputed each frame from
/// `LayoutResource` so it tracks the stock pile through window resizes.
/// The chip sits in the bottom-right corner of the stock pile and is hidden
/// while the stock is empty — the existing `↺` overlay
/// ([`StockEmptyLabel`]) covers the recycle hint instead, so the two
/// indicators never render simultaneously.
#[derive(Component, Debug)]
pub struct StockCountBadge;
/// Marker on the `Text2d` child of [`StockCountBadge`] showing the numeric
/// count of cards remaining in the stock pile.
///
/// Update systems query this component to write the new count in place rather
/// than despawning and respawning the text entity each tick.
#[derive(Component, Debug)]
pub struct StockCountBadgeText;
// ---------------------------------------------------------------------------
// Task #34 — Card-flip animation
// ---------------------------------------------------------------------------
/// Phase of the two-stage flip animation.
#[derive(Debug, Clone, PartialEq)]
pub enum FlipPhase {
/// Scale X from 1.0 → 0.0 (hiding the back face).
ScalingDown,
/// Scale X from 0.0 → 1.0 (revealing the front face).
ScalingUp,
}
/// Drives a 2-phase "card flip" animation on `CardEntity` entities.
///
/// The animation squashes X to 0, swaps the sprite to the face-up colour,
/// then expands X back to 1. Total duration is `2 × FLIP_HALF_SECS`.
#[derive(Component, Debug, Clone)]
pub struct CardFlipAnim {
/// Seconds elapsed in the current phase.
pub timer: f32,
/// Which half of the flip we are in.
pub phase: FlipPhase,
}
/// Duration of each half of the flip animation (scale-down or scale-up).
const FLIP_HALF_SECS: f32 = 0.08;
// ---------------------------------------------------------------------------
// Task #38 — Drag-elevation shadow
// ---------------------------------------------------------------------------
/// Marker component for the semi-transparent shadow sprite shown while dragging.
#[derive(Component, Debug)]
pub struct ShadowEntity;
/// Marker component for the per-card drop-shadow child sprite.
///
/// Every `CardEntity` owns exactly one `CardShadow` child whose `Sprite` is a
/// neutral-black halo painted slightly down-and-right of the card. Idle state
/// uses [`CARD_SHADOW_OFFSET_IDLE`] / [`CARD_SHADOW_ALPHA_IDLE`]; while the
/// parent card is being dragged the shadow is pushed to the deeper
/// [`CARD_SHADOW_OFFSET_DRAG`] / [`CARD_SHADOW_ALPHA_DRAG`] values so the
/// stack reads as "lifted" off the felt.
#[derive(Component, Debug)]
pub struct CardShadow;
/// Marker on the thin contrasting border sprite spawned behind face-down cards.
///
/// Face-down cards use `back_0.png` which is near-black (`#1a1a1a`). On the
/// dark-green felt the edges are nearly invisible. This child sprite — slightly
/// larger than the card, rendered at local z=-0.01 so it peeks out as a thin
/// frame — gives every face-down card a visible perimeter.
#[derive(Component, Debug)]
pub struct CardBackFrame;
/// Fill colour for the face-down card border frame. Light-medium gray so it
/// reads as a clear "edge" without competing with the suit colours on face-up
/// cards. Brightened from `0.38` to `0.48` (≈ #7a7a7a) after a Pixel_7 smoke
/// test showed face-down `back_0.png` (≈ #1a1a1a) was nearly invisible against
/// the very dark `#151515` felt — the old gray was too close to the back fill
/// to define a crisp perimeter.
const CARD_BACK_FRAME_COLOR: Color = Color::srgb(0.48, 0.48, 0.48);
/// Extra width/height (in world units) added to each side of the card to form
/// the visible border. Widened from `3.0` to `6.0` so the frame peeks out as a
/// clearly readable perimeter at phone density (420 dpi) rather than a hairline.
const CARD_BACK_FRAME_PADDING: f32 = 6.0;
/// Returns the `(offset, padding, alpha)` triple used to paint a per-card
/// shadow given whether its parent card is currently part of the dragged
/// stack. Pulled out as a pure helper so the shadow tuning can be unit-tested
/// without spinning up a Bevy app.
///
/// `is_dragged = false` → resting `(IDLE, IDLE, IDLE)`
/// `is_dragged = true` → lifted `(DRAG, DRAG, DRAG)`
pub fn card_shadow_params(is_dragged: bool) -> (Vec2, Vec2, f32) {
if is_dragged {
(
CARD_SHADOW_OFFSET_DRAG,
CARD_SHADOW_PADDING_DRAG,
CARD_SHADOW_ALPHA_DRAG,
)
} else {
(
CARD_SHADOW_OFFSET_IDLE,
CARD_SHADOW_PADDING_IDLE,
CARD_SHADOW_ALPHA_IDLE,
)
}
}
/// Builds the `Sprite` used for a per-card shadow at the resting state. The
/// alpha and size both use the idle tokens; `update_card_shadows_on_drag`
/// retunes them at runtime when the parent card joins / leaves the dragged
/// stack.
fn card_shadow_sprite(card_size: Vec2) -> Sprite {
let (_offset, padding, alpha) = card_shadow_params(false);
Sprite {
color: CARD_SHADOW_COLOR.with_alpha(alpha),
custom_size: Some(card_size + padding),
..default()
}
}
/// Builds the `Transform` used for a per-card shadow at the resting state.
/// Local — it is parented to the card entity, so positions are relative.
fn card_shadow_transform() -> Transform {
let (offset, _padding, _alpha) = card_shadow_params(false);
Transform::from_xyz(offset.x, offset.y, CARD_SHADOW_LOCAL_Z)
}
/// Spawns a single `CardShadow` child under the given card entity builder.
/// Extracted so `spawn_card_entity` and `update_card_entity` can share the
/// exact same shadow recipe — we never want one path to drift from the other.
fn add_card_shadow_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
parent.spawn((
CardShadow,
card_shadow_sprite(card_size),
card_shadow_transform(),
Visibility::default(),
));
}
/// Spawns a `CardBackFrame` child behind a card entity to give every card a
/// thin perimeter against the dark felt, regardless of face state.
fn add_card_back_frame_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
parent.spawn((
CardBackFrame,
Sprite {
color: CARD_BACK_FRAME_COLOR,
custom_size: Some(card_size + Vec2::splat(CARD_BACK_FRAME_PADDING)),
..default()
},
Transform::from_xyz(0.0, 0.0, -0.01),
Visibility::default(),
));
}
/// Throttle interval for resize-driven card snap work, in seconds.
///
/// `WindowResized` fires once per pixel of drag, so a fast corner-drag can
/// produce dozens of events per frame. Re-running the per-card snap logic
/// (52 cards × sprite/transform/font_size touches) for every event is the
/// dominant cost of resize lag. We coalesce pending work and apply it at most
/// once per [`RESIZE_THROTTLE_SECS`] (~20 Hz). The user still sees updates
/// during a sustained drag, and the layout always catches up to the final
/// size when the drag stops because the pending size is held until applied.
const RESIZE_THROTTLE_SECS: f32 = 0.05;
/// Holds the latest pending window size from `WindowResized` events plus a
/// timestamp for the last applied snap, so the resize-snap work can be
/// rate-limited to ~20 Hz during sustained drags.
#[derive(Resource, Debug, Default)]
pub struct ResizeThrottle {
/// Latest unapplied window size from `WindowResized`. `None` when there is
/// nothing to apply.
pub pending: Option<Vec2>,
/// `Time::elapsed_secs()` value at the moment of the most recent applied
/// snap. `0.0` until the first apply.
pub last_applied_secs: f32,
}
/// Pure helper used by the throttled resize-snap system: returns `true` when
/// a pending resize should be flushed given the current `now_secs` and the
/// last-applied timestamp. Throttle interval is [`RESIZE_THROTTLE_SECS`].
///
/// Extracted so the rate-limit logic can be unit-tested without spinning up
/// a full Bevy app.
fn should_apply_resize(now_secs: f32, last_applied_secs: f32) -> bool {
(now_secs - last_applied_secs) >= RESIZE_THROTTLE_SECS
}
/// Renders cards by reading `GameStateResource` on `StateChangedEvent`.
pub struct CardPlugin;
/// System set for everything that paints the board: card sprites, pile
/// markers, shadows, highlights, badges. Members mutate `Sprite` /
/// `Transform` on board entities and run as a deterministic chain (see the
/// registration in [`CardPlugin`]'s `build`); table-plugin marker painters
/// order themselves after this set. UI-domain systems that touch `Sprite`/
/// `Transform` on non-board entities (HUD text pulses, modal cards) declare
/// `.ambiguous_with(BoardVisuals)` instead — the entity domains are
/// disjoint by design (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct BoardVisuals;
impl Plugin for CardPlugin {
fn build(&self, app: &mut App) {
// PostStartup ensures TablePlugin's Startup system has inserted
// LayoutResource before we try to read it.
//
// `handle_right_click` reads `ButtonInput<MouseButton>`. Under
// `MinimalPlugins` (tests) this resource is absent by default, so we
// ensure it exists here. Under `DefaultPlugins` the call is a no-op.
app.init_resource::<ButtonInput<MouseButton>>()
.init_resource::<ResizeThrottle>()
.init_resource::<CardEntityIndex>()
.add_message::<SettingsChangedEvent>()
.add_message::<CardFlippedEvent>()
.add_message::<CardFaceRevealedEvent>()
.add_systems(Startup, load_card_images)
.add_systems(
PostStartup,
(
// Fill the tableau fan before the first render so the
// cold-start deal already uses the full viewport height.
fill_tableau_fan_on_startup.before(sync_cards_startup),
sync_cards_startup,
update_stock_empty_indicator_startup,
),
)
// Layout recompute (UpdateOnResize) always precedes board
// painting, and the painters run as ONE deterministic chain in
// data-flow order: layout refinement → card authority → anims →
// shadows → highlights → indicators → resize snapping → labels.
// Every painter mutates card/marker Sprite+Transform, so without
// the chain each pair is a scheduler ambiguity (#143). All
// members are cheap and mostly change-gated; sequential
// execution is not a cost that matters here.
.configure_sets(Update, LayoutSystem::UpdateOnResize.before(BoardVisuals))
.add_systems(
Update,
(
update_tableau_fan_frac,
resync_cards_on_settings_change,
sync_cards_on_change,
start_flip_anim,
tick_flip_anim,
update_drag_shadow,
update_card_shadows_on_drag,
handle_right_click,
tick_right_click_highlights,
clear_right_click_highlights_on_state_change,
clear_right_click_highlights_on_pause,
tick_hint_highlight,
update_stock_empty_indicator,
update_stock_count_badge.run_if(resource_changed::<GameStateResource>),
collect_resize_events,
snap_cards_on_window_resize,
resize_android_corner_labels,
)
.chain()
.in_set(BoardVisuals)
.after(GameMutation),
);
app.add_systems(PostUpdate, rebuild_card_entity_index);
}
}
#[cfg(test)]
mod tests;
+286
View File
@@ -0,0 +1,286 @@
//! Stock-pile indicators: the empty-stock recycle hint and count badge.
use super::*;
use bevy::color::Color;
use solitaire_core::KlondikePile;
use solitaire_core::game_state::GameState;
use crate::events::StateChangedEvent;
use crate::font_plugin::FontResource;
use crate::layout::{Layout, LayoutResource};
use crate::resources::GameStateResource;
use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker};
use crate::ui_theme::{STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY, TYPE_BODY, Z_STOCK_BADGE};
/// Sprite colour applied to the stock `PileMarker` when the stock pile is empty,
/// to signal to the player that there are no more cards to draw. Pure white
/// at 0.4 alpha — a deliberate brightness-boost over the default marker so
/// the "empty" state is more visible, not less. Not derived from a palette
/// token: this is a sprite tint, not chrome colour.
const STOCK_EMPTY_DIM_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.4);
/// Sprite colour applied to the stock `PileMarker` when cards remain in
/// stock. Aliased to [`PILE_MARKER_DEFAULT_COLOUR`] so it tracks the rest
/// of the engine's idle pile-marker tint automatically.
const STOCK_NORMAL_COLOUR: Color = PILE_MARKER_DEFAULT_COLOUR;
/// Shared logic for updating the stock pile marker's dim state and "↺" label.
///
/// If the stock pile is empty the marker sprite is dimmed to
/// `STOCK_EMPTY_DIM_COLOUR` and a child `Text2d` with `StockEmptyLabel` is
/// spawned (if not already present). When the stock is non-empty the marker is
/// restored to `STOCK_NORMAL_COLOUR` and any `StockEmptyLabel` children are
/// despawned.
pub(super) fn apply_stock_empty_indicator<F: bevy::ecs::query::QueryFilter>(
commands: &mut Commands,
game: &GameState,
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite), F>,
label_children: &Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
layout: &Layout,
font: Handle<Font>,
) {
let stock_empty = game.stock_cards().is_empty();
for (entity, pile_marker, mut sprite) in pile_markers.iter_mut() {
if pile_marker.0 != KlondikePile::Stock {
continue;
}
if stock_empty {
// Dim the marker sprite.
sprite.color = STOCK_EMPTY_DIM_COLOUR;
// Spawn the "↺" label only if one does not already exist.
let already_has_label = label_children
.iter()
.any(|(_, parent)| parent.parent() == entity);
if !already_has_label {
let font_size = layout.card_size.x * 0.4;
commands.entity(entity).with_children(|b| {
b.spawn((
StockEmptyLabel,
Text2d::new(""),
TextFont {
font: font.clone(),
font_size,
..default()
},
TextColor(TEXT_PRIMARY.with_alpha(0.7)),
Transform::from_xyz(0.0, 0.0, 0.1),
));
});
}
} else {
// Restore normal brightness.
sprite.color = STOCK_NORMAL_COLOUR;
// Despawn any existing "↺" label children.
for (label_entity, parent) in label_children.iter() {
if parent.parent() == entity {
commands.entity(label_entity).despawn();
}
}
}
}
}
/// Runs at `PostStartup` to apply the stock-empty indicator for the initial
/// game state (before any `StateChangedEvent` fires).
pub(super) fn update_stock_empty_indicator_startup(
mut commands: Commands,
game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>,
font_res: Option<Res<FontResource>>,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
) {
let Some(layout) = layout else { return };
let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
apply_stock_empty_indicator(
&mut commands,
&game.0,
&mut pile_markers,
&label_children,
&layout.0,
font,
);
}
/// Runs each `Update` tick when a `StateChangedEvent` arrives, keeping the
/// stock pile marker dim state and "↺" label in sync with the current stock.
pub(super) fn update_stock_empty_indicator(
mut events: MessageReader<StateChangedEvent>,
mut commands: Commands,
game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>,
font_res: Option<Res<FontResource>>,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
) {
if events.read().next().is_none() {
return;
}
let Some(layout) = layout else { return };
let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
apply_stock_empty_indicator(
&mut commands,
&game.0,
&mut pile_markers,
&label_children,
&layout.0,
font,
);
}
// ---------------------------------------------------------------------------
// Stock-pile remaining-count badge
//
// Shows a small "N" chip pinned to the bottom-right corner of the stock pile so
// the player can see how many cards remain before the next recycle. The
// existing `StockEmptyLabel` (`↺` overlay) covers the empty-stock case, so
// the badge hides itself when the stock has zero cards — the two indicators
// never render at the same time.
// ---------------------------------------------------------------------------
/// Inset (in pixels) from the bottom-right corner of the stock pile sprite to
/// the centre of the count badge. Anchoring to the bottom-right keeps the chip
/// clear of the rank/suit pip in the card's top-left corner. Both components
/// move the centre *inward* from that corner: `x` is subtracted from the right
/// edge, `y` is added to the bottom edge. The `x` magnitude must satisfy
/// `x >= STOCK_BADGE_SIZE.x / 2` so the badge right edge stays inside the stock
/// pile and never overlaps the adjacent waste pile — critical on Android where
/// `H_GAP_DIVISOR = 32` gives an inter-pile gap of only ~4 px.
const STOCK_BADGE_INSET: Vec2 = Vec2::new(20.0, 8.0);
/// Width / height of the badge background sprite, in world pixels. Sized so
/// a 2-digit count (max "24") fits comfortably with `TYPE_BODY` (14 pt) text.
const STOCK_BADGE_SIZE: Vec2 = Vec2::new(34.0, 20.0);
/// Returns the count of cards currently in the stock pile.
///
/// Pure helper extracted so the count source is identical between the spawn
/// system, the update system, and the unit tests.
pub(super) fn stock_card_count(game: &GameState) -> usize {
game.stock_cards().len()
}
/// Returns the world-space `Vec3` for the centre of the stock-count badge,
/// given the current `Layout`. The badge sits at the bottom-right corner of
/// the stock pile sprite, inset by [`STOCK_BADGE_INSET`], so it stays clear of
/// the rank/suit pip in the card's top-left corner.
pub(super) fn stock_badge_translation(layout: &Layout) -> Vec3 {
// Empty layouts don't contain a Stock entry — fall back to origin so
// the badge stays in a deterministic spot until the layout is filled.
let pile_pos = layout
.pile_positions
.get(&KlondikePile::Stock)
.copied()
.unwrap_or(Vec2::ZERO);
let half = layout.card_size * 0.5;
// Anchor to the bottom-right corner, then move the centre inward.
let x = pile_pos.x + half.x - STOCK_BADGE_INSET.x;
let y = pile_pos.y - half.y + STOCK_BADGE_INSET.y;
Vec3::new(x, y, Z_STOCK_BADGE)
}
/// Spawns the stock-count badge entity (background sprite + child text)
/// into the world. Called once, when the badge does not yet exist.
pub(super) fn spawn_stock_count_badge(
commands: &mut Commands,
layout: &Layout,
font: Option<&Handle<Font>>,
count: usize,
) {
let translation = stock_badge_translation(layout);
let visibility = if count == 0 {
Visibility::Hidden
} else {
Visibility::Inherited
};
let text_font = TextFont {
font: font.cloned().unwrap_or_default(),
font_size: TYPE_BODY,
..default()
};
commands
.spawn((
StockCountBadge,
Sprite {
color: STOCK_BADGE_BG,
custom_size: Some(STOCK_BADGE_SIZE),
..default()
},
Transform::from_translation(translation),
visibility,
))
.with_children(|b| {
b.spawn((
StockCountBadgeText,
Text2d::new(format!("{count}")),
text_font,
TextColor(STOCK_BADGE_FG),
// Slightly above the chip background so the digits aren't
// occluded by the sprite they sit on.
Transform::from_xyz(0.0, 0.0, 0.1),
));
});
}
/// Spawns the stock-pile remaining-count badge if it does not yet exist,
/// and otherwise updates its text and visibility in place.
///
/// Visibility rule: hidden when the stock is empty (the existing `↺`
/// `StockEmptyLabel` overlay covers that state), shown when one or more
/// cards remain.
///
/// Position is recomputed from `LayoutResource` every tick so the badge
/// follows the stock pile across `WindowResized` layout updates without
/// needing a dedicated resize handler.
#[allow(clippy::too_many_arguments)]
pub(super) fn update_stock_count_badge(
mut commands: Commands,
game: Option<Res<GameStateResource>>,
layout: Option<Res<LayoutResource>>,
font: Option<Res<FontResource>>,
mut badges: Query<(Entity, &mut Transform, &mut Visibility), With<StockCountBadge>>,
children: Query<&Children, With<StockCountBadge>>,
mut texts: Query<&mut Text2d, With<StockCountBadgeText>>,
) {
let Some(game) = game else { return };
let Some(layout) = layout else { return };
let count = stock_card_count(&game.0);
let translation = stock_badge_translation(&layout.0);
let target_visibility = if count == 0 {
Visibility::Hidden
} else {
Visibility::Inherited
};
if badges.is_empty() {
spawn_stock_count_badge(&mut commands, &layout.0, font.as_ref().map(|f| &f.0), count);
return;
}
for (entity, mut transform, mut visibility) in badges.iter_mut() {
transform.translation = translation;
if *visibility != target_visibility {
*visibility = target_visibility;
}
// Update the child text to reflect the latest count. The text node
// is created at spawn time, so under normal operation we always
// have exactly one child here.
if let Ok(badge_children) = children.get(entity) {
for child in badge_children.iter() {
if let Ok(mut text) = texts.get_mut(child) {
let new = format!("{count}");
if text.0 != new {
text.0 = new;
}
}
}
}
}
}
+702
View File
@@ -0,0 +1,702 @@
//! Card asset loading and entity lifecycle: the sync systems that
//! spawn, update, and position card entities from `GameStateResource`.
use super::*;
use std::collections::{HashMap, HashSet};
use bevy::color::Color;
use solitaire_core::{Card, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration};
use crate::card_animation::CardAnimation;
use crate::events::StateChangedEvent;
use crate::font_plugin::FontResource;
use crate::layout::{Layout, LayoutResource};
use crate::platform::USE_TOUCH_UI_LAYOUT;
use crate::resources::GameStateResource;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
/// Rebuild the [`CardEntityIndex`] from the live `CardEntity` set.
///
/// Runs in `PostUpdate` so that all spawn/despawn `Commands` issued by
/// [`sync_cards_on_change`] and [`snap_cards_on_window_resize`] in `Update`
/// have been flushed at the `Update -> PostUpdate` apply-deferred boundary.
/// Rebuilding from scratch (rather than incrementally patching at every
/// spawn/despawn site — waste cards churn on every draw) keeps a single writer
/// and makes a stale entry structurally impossible.
///
/// Gated to changed frames only: `Changed<CardEntity>` fires the frame a card
/// is spawned, `RemovedComponents<CardEntity>` the frame one is despawned. The
/// `card` field is write-once (never mutated in place), so card-reposition
/// frames don't trip `Changed` and correctly skip the O(52) rebuild.
pub(super) fn rebuild_card_entity_index(
mut index: ResMut<CardEntityIndex>,
cards: Query<(Entity, &CardEntity)>,
changed: Query<(), Changed<CardEntity>>,
removed: RemovedComponents<CardEntity>,
) {
if changed.is_empty() && removed.is_empty() {
return;
}
let map = &mut index.0;
map.clear();
for (entity, ce) in &cards {
map.insert(ce.card.clone(), entity);
}
}
/// Returns the relative asset path for a card face PNG.
///
/// The path format is `cards/faces/classic/{RANK}{SUIT}.png`, e.g. `QS.png`
/// for the Queen of Spades. Both `load_card_images` and the unit tests use
/// this function so the filename formula is tested in isolation from the
/// asset-loading machinery.
///
/// Note: this function verifies only the **code-side mapping**. If the PNG
/// file at the returned path contains wrong artwork (e.g. `QS.png` has a
/// diamond watermark baked in), that is an **asset content bug** and must be
/// fixed by replacing the file — no code change can correct it.
pub(super) fn card_face_asset_path(rank: Rank, suit: Suit) -> String {
const SUIT_CHARS: [&str; 4] = ["C", "D", "H", "S"];
const RANK_STRS: [&str; 13] = [
"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K",
];
let suit_idx = match suit {
Suit::Clubs => 0,
Suit::Diamonds => 1,
Suit::Hearts => 2,
Suit::Spades => 3,
};
let rank_idx = match rank {
Rank::Ace => 0,
Rank::Two => 1,
Rank::Three => 2,
Rank::Four => 3,
Rank::Five => 4,
Rank::Six => 5,
Rank::Seven => 6,
Rank::Eight => 7,
Rank::Nine => 8,
Rank::Ten => 9,
Rank::Jack => 10,
Rank::Queen => 11,
Rank::King => 12,
};
format!(
"cards/faces/classic/{}{}.png",
RANK_STRS[rank_idx], SUIT_CHARS[suit_idx]
)
}
/// Loads card face and back PNGs at startup via [`AssetServer`] and inserts
/// [`CardImageSet`].
///
/// Faces: `assets/cards/faces/{RANK}{SUIT}.png` (e.g. `AC.png`, `10H.png`)
/// Backs: `assets/cards/backs/back_{0..4}.png`
///
/// Under `MinimalPlugins` (tests) `AssetServer` is absent, so the system
/// returns without inserting `CardImageSet` and the plugin falls back to
/// solid-colour sprites.
pub(super) fn load_card_images(asset_server: Option<Res<AssetServer>>, mut commands: Commands) {
let Some(asset_server) = asset_server else {
return;
};
// faces[suit_index(s)][rank_index(r)] — see the canonical helpers in
// card_plugin::mod; building from SUITS/RANKS order matches them.
let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|si| {
std::array::from_fn(|ri| {
asset_server.load(card_face_asset_path(Rank::RANKS[ri], Suit::SUITS[si]))
})
});
let backs =
std::array::from_fn(|i| asset_server.load(format!("cards/backs/classic/back_{i}.png")));
commands.insert_resource(CardImageSet {
faces,
backs,
// Populated by the theme plugin once a `CardTheme` finishes loading.
// Until then the legacy back fallback (`backs[selected_card_back]`)
// is used.
theme_back: None,
});
}
/// Builds the [`Sprite`] for a card, using PNG artwork when [`CardImageSet`] is
/// available and falling back to a solid-colour sprite in tests.
pub(super) fn card_sprite(
card: &Card,
face_up: bool,
card_size: Vec2,
back_colour: Color,
card_images: Option<&CardImageSet>,
selected_back: usize,
) -> Sprite {
if let Some(set) = card_images {
let image = if face_up {
let suit_idx = suit_index(card.suit());
let rank_idx = rank_index(card.rank());
set.faces[suit_idx][rank_idx].clone()
} else if let Some(theme_back) = &set.theme_back {
// Active theme provides its own back — always wins over the
// legacy `selected_card_back` picker, so a theme switch swaps
// faces *and* the back. The picker is treated as informational
// only while a theme back is active (see settings_plugin).
theme_back.clone()
} else {
let idx = selected_back.min(set.backs.len() - 1);
set.backs[idx].clone()
};
Sprite {
image,
color: Color::WHITE,
custom_size: Some(card_size),
..default()
}
} else {
// Terminal aesthetic: face background is uniformly CARD_FACE_COLOUR
// regardless of colour-blind mode (CBM differentiation now lives in
// the suit glyph colour, applied by `text_colour`, not the face
// background). Pre-Terminal this branch dispatched through a
// separate `face_colour(card, color_blind)` helper.
let body_colour = if face_up {
CARD_FACE_COLOUR
} else {
back_colour
};
Sprite {
color: body_colour,
custom_size: Some(card_size),
..default()
}
}
}
/// When card-back selection changes in Settings, re-render all cards so the
/// new back colour is applied immediately (without waiting for a state change).
pub(super) fn resync_cards_on_settings_change(
mut setting_events: MessageReader<SettingsChangedEvent>,
mut state_events: MessageWriter<StateChangedEvent>,
) {
if setting_events.read().next().is_some() {
state_events.write(StateChangedEvent);
}
}
/// Render the initial deal. Runs in `PostStartup`, so all `Startup` systems
/// (including `TablePlugin::setup_table` which inserts `LayoutResource`)
/// have already completed.
#[allow(clippy::too_many_arguments)]
pub(super) fn sync_cards_startup(
commands: Commands,
game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>,
slide_dur: Option<Res<EffectiveSlideDuration>>,
settings: Option<Res<SettingsResource>>,
entities: Query<CardSyncData>,
card_images: Option<Res<CardImageSet>>,
font_res: Option<Res<FontResource>>,
) {
if let Some(layout) = layout {
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
let back_colour = card_back_colour(selected_back);
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
let font_handle = font_res.as_ref().map(|r| &r.0);
sync_cards(
commands,
&game.0,
&layout.0,
slide_secs,
back_colour,
color_blind,
high_contrast,
&entities,
card_images.as_deref(),
selected_back,
font_handle,
);
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn sync_cards_on_change(
mut events: MessageReader<StateChangedEvent>,
commands: Commands,
game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>,
slide_dur: Option<Res<EffectiveSlideDuration>>,
settings: Option<Res<SettingsResource>>,
entities: Query<CardSyncData>,
card_images: Option<Res<CardImageSet>>,
font_res: Option<Res<FontResource>>,
) {
if events.read().next().is_none() {
return;
}
if let Some(layout) = layout {
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
let back_colour = card_back_colour(selected_back);
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
let font_handle = font_res.as_ref().map(|r| &r.0);
sync_cards(
commands,
&game.0,
&layout.0,
slide_secs,
back_colour,
color_blind,
high_contrast,
&entities,
card_images.as_deref(),
selected_back,
font_handle,
);
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn sync_cards(
mut commands: Commands,
game: &GameState,
layout: &Layout,
slide_secs: f32,
back_colour: Color,
color_blind: bool,
high_contrast: bool,
entities: &Query<CardSyncData>,
card_images: Option<&CardImageSet>,
selected_back: usize,
font_handle: Option<&Handle<Font>>,
) {
let positions = card_positions(game, layout);
// The waste buffer card exists only to keep its entity alive while the new
// top card's slide animation plays — it must never be visible to the player.
// Without this, the buffer sits at waste_base uncovered during the animation
// and its rank/suit peek behind the incoming card.
let waste_buffer_id: Option<Card> = {
let visible = match game.draw_mode() {
DrawStockConfig::DrawOne => 1_usize,
DrawStockConfig::DrawThree => 3_usize,
};
let waste_cards = game.waste_cards();
(waste_cards.len() > visible)
.then_some(waste_cards)
.and_then(|w| w.get(w.len().saturating_sub(visible + 1)).cloned())
.map(|(c, _face_up)| c)
};
// Map Card -> (Entity, current_translation, anim_end) for in-place
// updates. `anim_end` is `Some(end_xy)` when a curve-based `CardAnimation`
// is currently driving the card (e.g. a drag-rejection return tween).
//
// In the position loop below we compare `anim_end` against the new game-
// state target position to decide whether to honour or cancel the tween:
// • end ≈ target → animation is still heading to the right place; let
// it finish (skip the snap/slide path).
// • end ≠ target → the game state has changed (e.g. a new game started
// while the win-cascade was mid-flight); cancel the
// stale `CardAnimation` and apply the new position.
let mut existing: HashMap<Card, (Entity, Vec3, Option<Vec2>, Option<CardChildrenKey>)> =
HashMap::new();
for (entity, marker, transform, anim, children_key) in entities.iter() {
existing.insert(
marker.card.clone(),
(
entity,
transform.translation,
anim.map(|a| a.end),
children_key.copied(),
),
);
}
let live_ids: HashSet<Card> = positions.iter().map(|(c, _, _)| c.0.clone()).collect();
// Despawn any entity whose card is no longer tracked.
for (card, (entity, _, _, _)) in &existing {
if !live_ids.contains(card) {
commands.entity(*entity).despawn();
}
}
// For each card in the current state: spawn or update its entity, then
// apply visibility. The waste buffer card is hidden so it cannot peek
// behind the incoming top card during the draw slide animation.
for ((card, face_up), position, z) in positions {
let entity = match existing.get(&card) {
Some(&(entity, cur, anim_end, children_key)) => {
// If a CardAnimation is in flight, check whether its destination
// still matches the game-state target. If the game moved the card
// elsewhere (e.g. new game started during a win-cascade scatter),
// cancel the stale tween so the card snaps/slides to its new home.
let has_anim = match anim_end {
Some(end_xy) if (end_xy - position).length() > 2.0 => {
commands.entity(entity).remove::<CardAnimation>();
false
}
Some(_) => true,
None => false,
};
update_card_entity(
&mut commands,
entity,
&card,
face_up,
position,
z,
layout,
slide_secs,
back_colour,
color_blind,
high_contrast,
cur,
has_anim,
children_key,
card_images,
selected_back,
font_handle,
);
entity
}
None => spawn_card_entity(
&mut commands,
&card,
face_up,
position,
z,
layout,
back_colour,
color_blind,
high_contrast,
card_images,
selected_back,
font_handle,
),
};
let visibility = if waste_buffer_id.as_ref() == Some(&card) {
Visibility::Hidden
} else {
Visibility::Inherited
};
commands.entity(entity).insert(visibility);
}
}
/// Returns an ordered vec of ((card, face_up), position, z) for every card in the game.
pub(super) fn card_positions(game: &GameState, layout: &Layout) -> Vec<((Card, bool), Vec2, f32)> {
let mut out: Vec<((Card, bool), Vec2, f32)> = Vec::with_capacity(52);
let piles = [
(KlondikePile::Stock, true),
(KlondikePile::Stock, false),
(KlondikePile::Foundation(Foundation::Foundation1), false),
(KlondikePile::Foundation(Foundation::Foundation2), false),
(KlondikePile::Foundation(Foundation::Foundation3), false),
(KlondikePile::Foundation(Foundation::Foundation4), false),
(KlondikePile::Tableau(Tableau::Tableau1), false),
(KlondikePile::Tableau(Tableau::Tableau2), false),
(KlondikePile::Tableau(Tableau::Tableau3), false),
(KlondikePile::Tableau(Tableau::Tableau4), false),
(KlondikePile::Tableau(Tableau::Tableau5), false),
(KlondikePile::Tableau(Tableau::Tableau6), false),
(KlondikePile::Tableau(Tableau::Tableau7), false),
];
// Draw-Three waste fan step, proportional to the column spacing so it scales
// with the platform's H_GAP_DIVISOR. Shared with input_plugin's hit-test via
// `waste_fan_step` so the two never drift (a drift puts the top fanned card's
// click target on the card beneath it).
let waste_fan_step = waste_fan_step(layout);
for (pile_type, is_stock_area) in piles {
let Some(mut base) = layout.pile_positions.get(&pile_type).copied() else {
continue;
};
if matches!(pile_type, KlondikePile::Stock) && is_stock_area {
base.x -= tableau_col_step(layout);
}
let is_tableau = matches!(pile_type, KlondikePile::Tableau(_));
let is_waste = matches!(pile_type, KlondikePile::Stock) && !is_stock_area;
let cards = if matches!(pile_type, KlondikePile::Stock) {
if is_stock_area {
game.stock_cards()
} else {
game.waste_cards()
}
} else {
game.pile(pile_type)
};
// Tableau uses a two-speed fan: face-down cards are packed tighter
// than face-up cards so the visible (playable) portion stands out.
// Non-tableau piles stack with a negligible offset.
//
// Waste pile: only the top N cards are rendered to prevent bleed-through
// while new cards animate in from the stock. Draw-One shows 1; Draw-Three
// shows up to 3 fanned in X (matching the standard Klondike presentation).
let render_start = if is_waste {
let visible = match game.draw_mode() {
DrawStockConfig::DrawOne => 1_usize,
DrawStockConfig::DrawThree => 3_usize,
};
// Render one extra card so that the card sliding off the waste
// during a draw animation is still present in the world at z=0
// (hidden under the stack) rather than vanishing mid-tween.
cards.len().saturating_sub(visible + 1)
} else {
0
};
let mut y_offset = 0.0_f32;
let rendered_len = cards[render_start..].len();
for (slot, (card, face_up)) in cards[render_start..].iter().enumerate() {
let x_offset = if is_waste && matches!(game.draw_mode(), DrawStockConfig::DrawThree) {
// When len > visible, slot 0 is a hidden buffer card kept at
// x=0 to prevent a flash during the draw tween. When len ≤
// visible (small pile), every card is visible and should fan
// normally — no card is hidden, so the shift is 0.
let visible = 3_usize;
let hidden = rendered_len.saturating_sub(visible);
slot.saturating_sub(hidden) as f32 * waste_fan_step
} else {
0.0
};
let pos = Vec2::new(base.x + x_offset, base.y + y_offset);
let z = 1.0 + (slot as f32) * STACK_FAN_FRAC;
out.push(((card.clone(), *face_up), pos, z));
if is_tableau {
let step = if *face_up {
layout.tableau_fan_frac
} else {
layout.tableau_facedown_fan_frac
};
y_offset -= layout.card_size.y * step;
}
}
}
out
}
pub(super) fn all_cards(game: &GameState) -> Vec<(Card, bool)> {
let mut cards: Vec<(Card, bool)> = Vec::with_capacity(52);
cards.extend(game.stock_cards());
cards.extend(game.waste_cards());
for foundation in solitaire_core::FOUNDATIONS {
cards.extend(game.pile(KlondikePile::Foundation(foundation)));
}
for tableau in solitaire_core::TABLEAUS {
cards.extend(game.pile(KlondikePile::Tableau(tableau)));
}
cards
}
#[allow(clippy::too_many_arguments)]
pub(super) fn spawn_card_entity(
commands: &mut Commands,
card: &Card,
face_up: bool,
pos: Vec2,
z: f32,
layout: &Layout,
back_colour: Color,
color_blind: bool,
high_contrast: bool,
card_images: Option<&CardImageSet>,
selected_back: usize,
font_handle: Option<&Handle<Font>>,
) -> Entity {
let sprite = card_sprite(
card,
face_up,
layout.card_size,
back_colour,
card_images,
selected_back,
);
let mut entity = commands.spawn((
CardEntity { card: card.clone() },
sprite,
Transform::from_xyz(pos.x, pos.y, z),
Visibility::default(),
));
let entity_id = entity.id();
// Every card gets a subtle drop-shadow child so the play surface reads
// as physical instead of flat. Spawned in idle state; the drag-tracking
// system retunes its offset / alpha when this card joins the dragged
// stack.
entity.with_children(|b| {
add_card_shadow_child(b, layout.card_size);
});
// Every card gets a thin border frame so it reads as a distinct
// rectangle against the dark felt, regardless of face state.
entity.with_children(|b| {
add_card_back_frame_child(b, layout.card_size);
});
// When PNG faces are loaded the rank/suit are baked into the image.
// Only spawn the Text2d overlay in the solid-colour fallback (tests).
// On Android we additionally spawn a large-print corner label even in
// image mode so the rank/suit are legible at phone scale.
if card_images.is_none() {
entity.with_children(|b| {
b.spawn((
CardLabel,
Text2d::new(label_for(card)),
TextFont {
font_size: layout.card_size.x * FONT_SIZE_FRAC,
..default()
},
TextColor(text_colour(card, color_blind, high_contrast)),
Transform::from_xyz(0.0, 0.0, 0.01),
label_visibility(face_up),
));
});
}
if USE_TOUCH_UI_LAYOUT && card_images.is_some() {
entity.with_children(|b| {
add_android_corner_label(
b,
card,
face_up,
layout.card_size,
color_blind,
high_contrast,
font_handle,
);
});
}
// Record the appearance signature so subsequent `update_card_entity` calls
// can skip rebuilding these children until one of the inputs changes.
entity.insert(CardChildrenKey {
face_up,
card_size: layout.card_size,
color_blind,
high_contrast,
});
entity_id
}
#[allow(clippy::too_many_arguments)]
pub(super) fn update_card_entity(
commands: &mut Commands,
entity: Entity,
card: &Card,
face_up: bool,
pos: Vec2,
z: f32,
layout: &Layout,
slide_secs: f32,
back_colour: Color,
color_blind: bool,
high_contrast: bool,
cur: Vec3,
has_card_animation: bool,
existing_children_key: Option<CardChildrenKey>,
card_images: Option<&CardImageSet>,
selected_back: usize,
font_handle: Option<&Handle<Font>>,
) {
let target = Vec3::new(pos.x, pos.y, z);
// Always refresh the visual appearance.
commands.entity(entity).insert(card_sprite(
card,
face_up,
layout.card_size,
back_colour,
card_images,
selected_back,
));
// Skip the snap/slide path entirely when a curve-based `CardAnimation`
// is driving this card (e.g. the drag-rejection return tween). Writing
// `Transform` here would race that animation each frame and cause a
// visible jump. The animation system snaps the final position itself
// when it completes.
if !has_card_animation {
// Slide to the new position when it differs meaningfully; snap otherwise.
if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
// Lift the card immediately on the first frame of the animation so
// it never appears behind a card that is already resting at the
// destination slot. `advance_card_anims` will maintain this lift
// throughout the tween and snap to `target` (without lift) on
// completion.
let start = Vec3::new(cur.x, cur.y, z + CARD_ANIM_Z_LIFT);
commands
.entity(entity)
.insert(Transform::from_translation(start))
.insert(CardAnim {
start,
target,
elapsed: 0.0,
duration: slide_secs,
delay: 0.0,
});
} else {
commands
.entity(entity)
.remove::<CardAnim>()
.insert(Transform::from_xyz(pos.x, pos.y, z));
}
}
// Rebuild the card's child visuals (drop-shadow, border frame, and the
// rank/suit label / large-print corner overlay) only when an input that
// affects them actually changed. The child set depends solely on
// `CardChildrenKey`; the face/back image is carried by the always-refreshed
// `Sprite` above, so theme/card-back swaps need no child rebuild. Skipping
// this on a position-only move avoids despawning and respawning the child
// entities (incl. a `Text2d` glyph re-layout) for all 52 cards on every
// `StateChangedEvent` — the spike that stuttered the slide animation on
// high-resolution devices.
let new_children_key = CardChildrenKey {
face_up,
card_size: layout.card_size,
color_blind,
high_contrast,
};
if existing_children_key != Some(new_children_key) {
commands.entity(entity).despawn_related::<Children>();
commands.entity(entity).with_children(|b| {
add_card_shadow_child(b, layout.card_size);
});
commands.entity(entity).with_children(|b| {
add_card_back_frame_child(b, layout.card_size);
});
if card_images.is_none() {
commands.entity(entity).with_children(|b| {
b.spawn((
CardLabel,
Text2d::new(label_for(card)),
TextFont {
font_size: layout.card_size.x * FONT_SIZE_FRAC,
..default()
},
TextColor(text_colour(card, color_blind, high_contrast)),
Transform::from_xyz(0.0, 0.0, 0.01),
label_visibility(face_up),
));
});
}
if USE_TOUCH_UI_LAYOUT && card_images.is_some() {
commands.entity(entity).with_children(|b| {
add_android_corner_label(
b,
card,
face_up,
layout.card_size,
color_blind,
high_contrast,
font_handle,
);
});
}
commands.entity(entity).insert(new_children_key);
}
}
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -18,13 +18,15 @@ use crate::{
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,
SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin,
StatsPlugin, SyncProvider, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
WinSummaryPlugin,
};
#[cfg(not(target_arch = "wasm32"))]
use crate::{
AnalyticsPlugin, AudioPlugin, AvatarPlugin, LeaderboardPlugin, SyncPlugin, SyncSetupPlugin,
ThemeStorePlugin,
};
/// Groups all Ferrous Solitaire gameplay plugins.
@@ -92,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())
@@ -108,6 +111,7 @@ 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)
@@ -126,6 +130,7 @@ impl Plugin for CoreGamePlugin {
.add_plugins(AudioPlugin)
.add_plugins(SyncPlugin::new(sync_provider))
.add_plugins(SyncSetupPlugin)
.add_plugins(ThemeStorePlugin)
.add_plugins(AnalyticsPlugin)
.add_plugins(LeaderboardPlugin);
}
+8 -8
View File
@@ -35,8 +35,8 @@
use bevy::prelude::*;
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
use solitaire_core::Card;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::card_plugin::RightClickHighlight;
use crate::layout::{Layout, LayoutResource};
@@ -437,7 +437,8 @@ fn tableau_or_stack_pos(
base.x,
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
)
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree {
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree
{
let pile_len = game.waste_cards().len();
let visible_start = pile_len.saturating_sub(3);
let slot = index.saturating_sub(visible_start) as f32;
@@ -581,7 +582,10 @@ mod tests {
use crate::layout::compute_layout;
use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
use solitaire_core::{
DrawStockConfig,
game_state::{GameMode, GameState},
};
/// Builds an `App` with `MinimalPlugins` and the overlay system
/// registered, plus the resources the system needs. Callers
@@ -630,11 +634,7 @@ mod tests {
// — same colour family, illegal. Tableau(2) must NOT be
// highlighted.
let mut game = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Classic);
set_tableau_top(
&mut game,
2,
Card::new(Deck::Deck1, Suit::Clubs, Rank::Six),
);
set_tableau_top(&mut game, 2, Card::new(Deck::Deck1, Suit::Clubs, Rank::Six));
let dragged = Card::new(Deck::Deck1, Suit::Spades, Rank::Five);
let mut app = overlay_test_app(game);
+4 -1
View File
@@ -54,7 +54,10 @@ impl DifficultyIndexResource {
DifficultyLevel::Hard => &mut self.hard,
DifficultyLevel::Expert => &mut self.expert,
DifficultyLevel::Grandmaster => &mut self.grandmaster,
DifficultyLevel::Random => unreachable!("Random has no catalog"),
// Random has no catalog today, so seeds_for() already returned
// None above; if it ever gains one, a time seed is still the
// right answer for "Random" — never a reachable panic.
DifficultyLevel::Random => return seed_from_system_time(),
};
let seed = catalog[*cursor % catalog.len()];
*cursor = cursor.wrapping_add(1);
+17 -20
View File
@@ -2,10 +2,9 @@
use bevy::prelude::Message;
use solitaire_core::KlondikePile;
use solitaire_core::{Card, Suit};
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`.
@@ -134,6 +133,12 @@ pub struct ManualSyncRequestEvent;
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct SyncConfigureRequestEvent;
/// Request to open the in-game theme-store modal. Fired by the
/// "Browse theme store" button in the Settings cosmetic section;
/// handled by `theme_store_plugin`.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ThemeStoreOpenRequestEvent;
/// Request to disconnect from the current sync backend, clear stored
/// credentials, and reset to `SyncBackend::Local`. Fired by the "Disconnect"
/// button in the Settings sync section.
@@ -153,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`.
@@ -231,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".
@@ -283,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.
///
+26 -2
View File
@@ -276,10 +276,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 +362,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 +593,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.
@@ -852,7 +870,10 @@ mod tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(FeedbackAnimPlugin);
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
app.insert_resource(GameStateResource(GameState::new(
1,
DrawStockConfig::DrawOne,
)));
app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true,
..Settings::default()
@@ -906,7 +927,10 @@ mod tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(FeedbackAnimPlugin);
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
app.insert_resource(GameStateResource(GameState::new(
1,
DrawStockConfig::DrawOne,
)));
app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true,
..Settings::default()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+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"
);
}
}
+47 -3
View File
@@ -24,7 +24,8 @@ use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::events::{
InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
StartTimeAttackRequestEvent, StartZenRequestEvent, ToggleProfileRequestEvent,
StartTimeAttackRequestEvent, StartZenRequestEvent, ToggleHomeRequestEvent,
ToggleProfileRequestEvent,
};
use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource;
@@ -264,6 +265,7 @@ impl Plugin for HomePlugin {
.add_message::<StartPlayBySeedRequestEvent>()
.add_message::<StartDifficultyRequestEvent>()
.add_message::<InfoToastEvent>()
.add_message::<ToggleHomeRequestEvent>()
.add_message::<ToggleProfileRequestEvent>()
.add_message::<SettingsChangedEvent>()
// Defensively register MouseWheel so `scroll_home_panel`
@@ -371,6 +373,7 @@ fn spawn_home_on_launch(
fn toggle_home_screen(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleHomeRequestEvent>,
progress: Option<Res<ProgressResource>>,
stats: Option<Res<StatsResource>>,
settings: Option<Res<SettingsResource>>,
@@ -380,7 +383,8 @@ fn toggle_home_screen(
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
diff_expanded: Res<DifficultyExpanded>,
) {
if !keys.just_pressed(KeyCode::KeyM) {
let button_clicked = requests.read().count() > 0;
if !keys.just_pressed(KeyCode::KeyM) && !button_clicked {
return;
}
if let Ok(entity) = screens.single() {
@@ -432,7 +436,9 @@ fn build_home_context<'a>(
zen_best: stats.map_or(0, |s| s.0.zen_best_score),
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
daily_today,
draw_mode: settings.map(|s| s.0.draw_mode).unwrap_or(DrawStockConfig::DrawOne),
draw_mode: settings
.map(|s| s.0.draw_mode)
.unwrap_or(DrawStockConfig::DrawOne),
font_res,
difficulty_expanded,
last_difficulty: settings.and_then(|s| s.0.last_difficulty),
@@ -1619,6 +1625,44 @@ mod tests {
);
}
/// The HUD Menu popover's "Home" row fires
/// `ToggleHomeRequestEvent`; it must open Home exactly like the
/// `M` accelerator (Phase C: the popover's Play section replaces
/// the old Modes row).
#[test]
fn toggle_home_event_opens_home_screen() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<Messages<ToggleHomeRequestEvent>>()
.write(ToggleHomeRequestEvent);
app.update();
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
1,
"ToggleHomeRequestEvent must open the Home modal"
);
// A second request toggles it closed, matching the M key.
app.world_mut()
.resource_mut::<Messages<ToggleHomeRequestEvent>>()
.write(ToggleHomeRequestEvent);
app.update();
app.update();
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
0,
"second ToggleHomeRequestEvent must close the Home modal"
);
}
#[test]
fn pressing_m_twice_closes_home_screen() {
let mut app = headless_app();
File diff suppressed because it is too large Load Diff
+431
View File
@@ -0,0 +1,431 @@
//! HUD feedback effects: action-bar fades, score pulses and floaters,
//! and streak flourishes.
use super::*;
/// Auto-fade state for the action button bar. The bar fades out when
/// the cursor is in the play area (below the HUD band) and back in when
/// the cursor approaches the top of the window — same UX as a video
/// player's auto-hide controls. Buttons remain fully interactive when
/// visible; when faded out they're geometrically out of cursor reach
/// (hover requires the cursor to be on a button), so no extra
/// pointer-events guard is needed.
#[derive(Resource, Debug, Clone, Copy)]
pub struct HudActionFade {
/// Currently displayed alpha. Lerped toward `target` each frame.
pub alpha: f32,
/// Where `alpha` is heading — 0.0 (faded out) or 1.0 (visible).
pub target: f32,
}
impl Default for HudActionFade {
fn default() -> Self {
// Start visible so the player sees the controls on first launch
// before they've moved the cursor anywhere.
Self {
alpha: 1.0,
target: 1.0,
}
}
}
/// How many pixels from the bottom edge the cursor must be to reveal the bar.
/// Set slightly taller than `HUD_BAND_HEIGHT` so the bar fades in as the
/// cursor approaches, not only when it crosses into the band itself.
#[cfg(not(target_os = "android"))]
const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0;
/// Lerp rate for fading (per second). 6.0 ≈ 167 ms for a full
/// transition — fast enough to feel responsive without flashing on
/// brief cursor wanders into the reveal zone.
#[cfg(not(target_os = "android"))]
const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;
/// Updates the fade state from cursor position. Sets `target = 1.0` if
/// the cursor is in the reveal zone (bottom of window) or off-screen
/// (player is using keyboard); `0.0` otherwise. Lerps `alpha` toward
/// `target` at a fixed rate so the visual transition is smooth across
/// variable framerates.
#[cfg(not(target_os = "android"))]
pub(super) fn update_action_fade(
windows: Query<&Window>,
time: Res<Time>,
mut fade: ResMut<HudActionFade>,
) {
let Ok(window) = windows.single() else {
return;
};
let height = window.resolution.height();
fade.target = match window.cursor_position() {
Some(pos) if pos.y >= height - ACTION_FADE_REVEAL_PX => 1.0,
Some(_) => 0.0,
// Off-window cursor: assume keyboard navigation and keep the
// bar visible so Tab cycling doesn't lead to invisible focus.
None => 1.0,
};
let dt = time.delta_secs();
let max_step = ACTION_FADE_RATE_PER_SEC * dt;
let diff = fade.target - fade.alpha;
fade.alpha = (fade.alpha + diff.clamp(-max_step, max_step)).clamp(0.0, 1.0);
}
/// Applies the current fade alpha to every action button's
/// `BackgroundColor` and to its child label / hotkey-chip text. Runs in
/// `Last` (after `paint_action_buttons`) so a hover-state change in the
/// same frame doesn't override the fade with an opaque idle / hover
/// colour.
#[cfg(not(target_os = "android"))]
#[allow(clippy::type_complexity)]
pub(super) fn apply_action_fade(
fade: Res<HudActionFade>,
// Excludes `PopoverRow` so the auto-fade only applies to the
// top-level action bar buttons. Popover rows live inside an
// explicitly-opened dropdown panel and need to stay visible
// regardless of the bar's fade state — without the exclusion
// the rows fade to invisible while the popover container stays
// visible, leaving a solid background block with no readable
// content.
mut buttons: Query<
(&Children, &mut BackgroundColor),
(With<ActionButton>, Without<PopoverRow>),
>,
mut text_q: Query<&mut TextColor>,
) {
for (children, mut bg) in &mut buttons {
let mut c = bg.0;
c.set_alpha(fade.alpha);
bg.0 = c;
for child in children.iter() {
if let Ok(mut tc) = text_q.get_mut(child) {
let mut cc = tc.0;
cc.set_alpha(fade.alpha);
tc.0 = cc;
}
}
}
}
/// Visual feedback for every action button — paints idle / hover / pressed
/// states by mutating `BackgroundColor` whenever the interaction state
/// changes. One query covers all action buttons via the shared
/// `ActionButton` marker.
#[allow(clippy::type_complexity)]
pub(super) fn paint_action_buttons(
mut buttons: Query<
(&Interaction, &mut BackgroundColor),
(With<ActionButton>, Changed<Interaction>),
>,
) {
for (interaction, mut bg) in &mut buttons {
bg.0 = match interaction {
Interaction::Pressed => ACTION_BTN_PRESSED,
Interaction::Hovered => ACTION_BTN_HOVER,
Interaction::None => ACTION_BTN_IDLE,
};
}
}
/// Triangular 1.0 → 1.1 → 1.0 curve used by the score pulse. Pure
/// function so the test suite can assert on the curve directly
/// without spinning up a Bevy app.
///
/// The brief proposed `if t < 0.5 { 1.0 + 0.2*t } else { 1.2 - 0.2*(t-0.5) }`,
/// but that yields a discontinuity at t=0.5 (jumps from 1.1 → 1.2) and
/// ends at 1.1 instead of 1.0. The corrected form below preserves the
/// intent ("1.0 → 1.1 → 1.0 over the duration") with a continuous
/// triangle peaking at 1.1.
pub(super) fn score_pulse_scale(t: f32) -> f32 {
let clamped = t.clamp(0.0, 1.0);
if clamped < 0.5 {
1.0 + 0.2 * clamped
} else {
1.1 - 0.2 * (clamped - 0.5)
}
}
/// Vertical pixels the floating "+N" drifts up over its lifetime.
const FLOATER_DRIFT_PX: f32 = 40.0;
/// Diffs the current `GameStateResource.score` against
/// [`PreviousScore`]. On a positive delta:
///
/// - Inserts (or refreshes) a [`ScorePulse`] on every [`HudScore`] entity
/// so the readout pulses 1.0 → 1.1 → 1.0.
/// - When the delta is ≥ [`SCORE_FLOATER_THRESHOLD`], spawns a floating
/// "+N" UI text in `ACCENT_PRIMARY` anchored just below the score
/// readout (see the doc comment on [`ScoreFloater`] for why this is a
/// UI Node rather than a `Text2d`).
pub(super) fn detect_score_change(
game: Res<GameStateResource>,
settings: Option<Res<SettingsResource>>,
mut prev: ResMut<PreviousScore>,
font_res: Option<Res<FontResource>>,
score_q: Query<Entity, With<HudScore>>,
mut commands: Commands,
) {
let current = game.0.score();
let delta = current - prev.0;
prev.0 = current;
if delta <= 0 {
return;
}
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
if reduce_motion {
return;
}
let speed = settings
.as_ref()
.map(|s| s.0.animation_speed)
.unwrap_or_default();
let pulse_secs = scaled_duration(MOTION_SCORE_PULSE_SECS, speed);
let floater_secs = scaled_duration(MOTION_SCORE_PULSE_SECS * 2.0, speed);
// Refresh ScorePulse on every score readout entity (in practice
// there's exactly one, but iterating is cheaper than asserting).
for entity in &score_q {
commands.entity(entity).insert(ScorePulse {
elapsed: 0.0,
duration: pulse_secs,
});
}
if delta < SCORE_FLOATER_THRESHOLD {
return;
}
let font = TextFont {
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_BODY_LG,
..default()
};
// Spawned as an absolutely-positioned UI Node so the floater rides
// the same screen-coordinate system as the score readout. Using a
// `Text2d` here would require translating UI layout coordinates to
// world space every frame; a UI node piggybacks on the same
// anchoring `update_hud` already uses for the score and stays
// testable under `MinimalPlugins`.
commands.spawn((
ScoreFloater {
elapsed: 0.0,
duration: floater_secs,
},
Node {
position_type: PositionType::Absolute,
// Anchored next to the HUD column; matches the
// `spawn_hud` left/top offsets so the floater appears
// overlaid on the score line and drifts up from there.
left: VAL_SPACE_3,
top: Val::Px(0.0),
..default()
},
ZIndex(Z_HUD_TOP),
Text::new(format!("+{delta}")),
font,
TextColor(ACCENT_PRIMARY),
));
}
/// Advances every [`ScorePulse`], scaling its entity's `Transform`
/// using [`score_pulse_scale`]. Removes the component once
/// `elapsed >= duration` (or immediately under
/// [`AnimSpeed::Instant`](solitaire_data::AnimSpeed) where duration is
/// 0) and pins the scale back to 1.0 so no float drift survives.
pub(super) fn advance_score_pulse(
time: Res<Time>,
mut commands: Commands,
mut q: Query<(Entity, &mut ScorePulse, &mut Transform)>,
) {
let dt = time.delta_secs();
for (entity, mut pulse, mut transform) in &mut q {
let t = if pulse.duration <= 0.0 {
1.0
} else {
pulse.elapsed += dt;
(pulse.elapsed / pulse.duration).clamp(0.0, 1.0)
};
let scale = score_pulse_scale(t);
transform.scale = Vec3::new(scale, scale, 1.0);
if t >= 1.0 {
transform.scale = Vec3::ONE;
commands.entity(entity).remove::<ScorePulse>();
}
}
}
/// Advances every [`ScoreFloater`]: drifts the node upward by up to
/// [`FLOATER_DRIFT_PX`] and fades the text colour to transparent over
/// its lifetime. Despawns the entity once `elapsed >= duration`.
pub(super) fn advance_score_floater(
time: Res<Time>,
mut commands: Commands,
mut nodes: Query<(Entity, &mut ScoreFloater, &mut Node, &mut TextColor)>,
) {
let dt = time.delta_secs();
for (entity, mut floater, mut node, mut color) in &mut nodes {
let t = if floater.duration <= 0.0 {
1.0
} else {
floater.elapsed += dt;
(floater.elapsed / floater.duration).clamp(0.0, 1.0)
};
// Drift upward: top decreases as t grows. Starting top=0 keeps
// the floater on the score line; ending at -FLOATER_DRIFT_PX
// pulls it up off the readout.
node.top = Val::Px(-FLOATER_DRIFT_PX * t);
// Linear fade: ACCENT_PRIMARY at t=0 → fully transparent at t=1.
let mut c = ACCENT_PRIMARY;
c.set_alpha(1.0 - t);
color.0 = c;
if t >= 1.0 {
commands.entity(entity).despawn();
}
}
}
// ---------------------------------------------------------------------------
// Streak-milestone flourish
//
// Per the 2026-04-30 UX overhaul plan, the foundation flourish is the per-suit
// completion celebration; the streak flourish is its lifetime equivalent —
// when the player's `win_streak_current` crosses 3, 5, or 10, the HUD score
// readout pulses larger than a normal score-change pulse and tints magenta
// (`ACCENT_SECONDARY`) before snapping back to its resting state.
//
// Why the score readout: there is no always-on streak number on the HUD
// today (the readout lives in the Stats overlay), and the score is the
// most prominent always-visible HUD figure. The accompanying `InfoToastEvent`
// fired by `stats_plugin` carries the explicit "Win streak: N!" text so a
// player who isn't watching the score still sees the celebration land.
// ---------------------------------------------------------------------------
/// Pure helper for unit tests — returns the per-frame scale factor for
/// the streak flourish at `elapsed_secs` over `duration_secs`.
///
/// Triangular curve, mirroring [`foundation_flourish_scale`](crate::feedback_anim_plugin::foundation_flourish_scale):
/// at `t = 0.0` returns `1.0`, at `t = 0.5` returns
/// [`STREAK_FLOURISH_PEAK_SCALE`], at `t = 1.0` returns `1.0`.
/// Out-of-range values are clamped so the score readout never freezes
/// at a non-1.0 scale on the frame after the flourish ends.
///
/// Returns `1.0` whenever `duration_secs <= 0.0` so callers running
/// under `AnimSpeed::Instant` (zeroed durations) skip the flourish
/// without dividing by zero.
pub fn streak_flourish_scale(elapsed_secs: f32, duration_secs: f32) -> f32 {
if duration_secs <= 0.0 {
return 1.0;
}
let t = (elapsed_secs / duration_secs).clamp(0.0, 1.0);
let peak = STREAK_FLOURISH_PEAK_SCALE;
if t < 0.5 {
// Climb from 1.0 at t=0 to peak at t=0.5.
1.0 + (peak - 1.0) * (t / 0.5)
} else {
// Descend from peak at t=0.5 back to 1.0 at t=1.0.
peak - (peak - 1.0) * ((t - 0.5) / 0.5)
}
}
/// Inserts a [`StreakFlourish`] on every [`HudScore`] entity when a
/// [`WinStreakMilestoneEvent`] fires. Captures the readout's current
/// `TextColor` so `advance_streak_flourish` can restore it when the
/// timer expires; reuses any existing flourish's `original_color` so
/// re-entering the system mid-flourish doesn't snapshot the magenta
/// tint as the new "original".
///
/// Removes any concurrent [`ScorePulse`] from the same entity so the
/// flourish takes over the scale slot cleanly — score pulses last
/// 250 ms, the flourish 600 ms, and the streak crossing always
/// coincides with a positive score delta, so the flourish is the
/// louder of the two celebrations.
pub(super) fn start_streak_flourish(
mut events: MessageReader<WinStreakMilestoneEvent>,
settings: Option<Res<SettingsResource>>,
score_q: Query<(Entity, &TextColor, Option<&StreakFlourish>), With<HudScore>>,
mut commands: Commands,
) {
let Some(latest) = events.read().last() else {
return;
};
if settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode) {
return;
}
let speed = settings
.as_ref()
.map(|s| s.0.animation_speed)
.unwrap_or_default();
let duration = scaled_duration(MOTION_STREAK_FLOURISH_SECS, speed);
for (entity, color, existing) in &score_q {
let original_color = existing.map_or(color.0, |f| f.original_color);
commands
.entity(entity)
.remove::<ScorePulse>()
.insert(StreakFlourish {
streak: latest.streak,
elapsed: 0.0,
duration,
original_color,
});
}
}
/// Advances every [`StreakFlourish`], scaling its entity's `Transform`
/// using [`streak_flourish_scale`] and lerping the `TextColor` toward
/// [`ACCENT_SECONDARY`] for the first half then back to the captured
/// `original_color`. Removes the component once `elapsed >= duration`
/// (or immediately under [`AnimSpeed::Instant`](solitaire_data::AnimSpeed)
/// where duration is 0) and pins the scale back to 1.0 / restores the
/// original colour so no half-state is ever shown.
///
/// Filtered with `Without<ScorePulse>` so the streak flourish never
/// races a score pulse for the same `Transform.scale` slot —
/// `start_streak_flourish` strips any concurrent `ScorePulse` from the
/// score entity before this system runs, so the filter is purely a
/// belt-and-braces invariant.
pub(super) fn advance_streak_flourish(
time: Res<Time>,
mut commands: Commands,
mut q: Query<
(Entity, &mut StreakFlourish, &mut Transform, &mut TextColor),
Without<ScorePulse>,
>,
) {
let dt = time.delta_secs();
for (entity, mut anim, mut transform, mut color) in &mut q {
let t = if anim.duration <= 0.0 {
1.0
} else {
anim.elapsed += dt;
(anim.elapsed / anim.duration).clamp(0.0, 1.0)
};
let scale = streak_flourish_scale(anim.elapsed, anim.duration);
transform.scale = Vec3::new(scale, scale, 1.0);
// Tint mix: full magenta at t=0..=0.5, fades back to the
// original colour over t=0.5..=1.0.
let mix = if t < 0.5 { 1.0 } else { 1.0 - (t - 0.5) / 0.5 };
color.0 = lerp_text_color(anim.original_color, ACCENT_SECONDARY, mix);
if t >= 1.0 {
transform.scale = Vec3::ONE;
color.0 = anim.original_color;
commands.entity(entity).remove::<StreakFlourish>();
}
}
}
/// sRGB-space linear interpolation between two `Color`s — small local
/// helper so `advance_streak_flourish` stays readable. sRGB-space
/// lerping is fine for a brief decorative tint (a perceptually-uniform
/// space would be overkill).
pub(super) fn lerp_text_color(from: Color, to: Color, t: f32) -> Color {
let from = from.to_srgba();
let to = to.to_srgba();
let t = t.clamp(0.0, 1.0);
Color::srgba(
from.red + (to.red - from.red) * t,
from.green + (to.green - from.green) * t,
from.blue + (to.blue - from.blue) * t,
from.alpha + (to.alpha - from.alpha) * t,
)
}
@@ -0,0 +1,644 @@
//! HUD interaction: action-button handlers, Modes/Menu popovers, and
//! the chrome tap-to-toggle gesture.
use super::*;
/// `Changed<Interaction>` filter ensures we only react on the frame the
/// interaction state transitions, avoiding repeat events while the button
/// is held down. Each click handler fires the corresponding request event,
/// which `pause_plugin` / `help_plugin` / `game_plugin` consume alongside
/// their existing keyboard handlers.
pub(super) fn handle_new_game_button(
interaction_query: Query<&Interaction, (With<NewGameButton>, Changed<Interaction>)>,
mut new_game: MessageWriter<NewGameRequestEvent>,
) {
for interaction in &interaction_query {
if *interaction == Interaction::Pressed {
new_game.write(NewGameRequestEvent::default());
}
}
}
pub(super) fn handle_undo_button(
interaction_query: Query<&Interaction, (With<UndoButton>, Changed<Interaction>)>,
mut undo: MessageWriter<UndoRequestEvent>,
) {
for interaction in &interaction_query {
if *interaction == Interaction::Pressed {
undo.write(UndoRequestEvent);
}
}
}
pub(super) fn handle_pause_button(
interaction_query: Query<&Interaction, (With<PauseButton>, Changed<Interaction>)>,
mut pause: MessageWriter<PauseRequestEvent>,
) {
for interaction in &interaction_query {
if *interaction == Interaction::Pressed {
pause.write(PauseRequestEvent);
}
}
}
pub(super) fn handle_help_button(
interaction_query: Query<&Interaction, (With<HelpButton>, Changed<Interaction>)>,
mut help: MessageWriter<HelpRequestEvent>,
) {
for interaction in &interaction_query {
if *interaction == Interaction::Pressed {
help.write(HelpRequestEvent);
}
}
}
pub(super) fn handle_hint_button(
interaction_query: Query<&Interaction, (With<HintButton>, Changed<Interaction>)>,
paused: Option<Res<PausedResource>>,
game: Option<Res<GameStateResource>>,
solver_config: Option<Res<crate::input_plugin::HintSolverConfig>>,
mut pending_hint: Option<ResMut<crate::pending_hint::PendingHintTask>>,
mut info_toast: MessageWriter<InfoToastEvent>,
) {
for interaction in &interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
if paused.as_ref().is_some_and(|p| p.0) {
return;
}
let Some(ref g) = game else { return };
if g.0.is_won() {
info_toast.write(InfoToastEvent(HINT_WON_MSG.to_string()));
return;
}
if let (Some(cfg), Some(hint)) = (solver_config.as_ref(), pending_hint.as_mut()) {
hint.spawn(g.0.clone(), cfg.moves_budget, cfg.states_budget);
}
}
}
/// Toggles the [`ModesPopover`]: spawns it on first click, despawns it on
/// second click. Mode rows are populated per the player's current level so
/// only unlocked options appear.
pub(super) fn handle_modes_button(
interaction_query: Query<&Interaction, (With<ModesButton>, Changed<Interaction>)>,
popovers: Query<Entity, With<ModesPopover>>,
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
progress: Option<Res<ProgressResource>>,
daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>,
mut commands: Commands,
) {
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
if !pressed {
return;
}
if let Ok(entity) = popovers.single() {
commands.entity(entity).despawn();
for e in &backdrops {
commands.entity(e).despawn();
}
} else {
spawn_modes_popover(
&mut commands,
progress.as_deref(),
daily.as_deref(),
font_res.as_deref(),
);
}
}
/// Spawns the modes popover anchored just below the action bar's right
/// edge. Always includes Classic; includes Daily Challenge when a daily
/// resource is loaded; includes Zen / Challenge / Time Attack once the
/// player reaches the challenge unlock level.
pub(super) fn spawn_modes_popover(
commands: &mut Commands,
progress: Option<&ProgressResource>,
daily: Option<&DailyChallengeResource>,
font_res: Option<&FontResource>,
) {
let level = progress.map_or(0, |p| p.0.level);
let font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: 15.0,
..default()
};
// Each row carries a tooltip alongside its label so hover reveals
// a one-line description of what the mode does — mirroring the
// tooltips on the action-bar buttons that opened this popover.
let mut rows: Vec<(ModeOption, &'static str, &'static str)> = vec![(
ModeOption::Classic,
"Classic",
"Standard Klondike. Score, timer, and full progression.",
)];
if daily.is_some() {
rows.push((
ModeOption::DailyChallenge,
"Daily Challenge",
"Today's seeded deal. Same for every player worldwide.",
));
}
if level >= CHALLENGE_UNLOCK_LEVEL {
rows.push((
ModeOption::Zen,
"Zen",
"No timer, no score, no penalties. Just play.",
));
rows.push((
ModeOption::Challenge,
"Challenge",
"Hand-picked hard seeds. No undo allowed.",
));
rows.push((
ModeOption::TimeAttack,
"Time Attack",
"Win as many games as you can in ten minutes.",
));
}
// Popover opens upward from just above the bottom action bar.
// Use a platform-aware offset that clears the bar height + safe-area
// gesture zone on Android, and the flat bar height on desktop.
let popover_bottom = Val::Px(ACTION_POPOVER_BOTTOM_PX);
commands
.spawn((
ModesPopover,
HudPopoverOpen,
Node {
position_type: PositionType::Absolute,
right: VAL_SPACE_3,
bottom: popover_bottom,
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_1,
padding: UiRect::all(VAL_SPACE_2),
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
..default()
},
BackgroundColor(BG_ELEVATED),
ZIndex(Z_HUD_POPOVER),
))
.with_children(|panel| {
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)));
});
}
});
// Fullscreen transparent backdrop at Z_HUD_POPOVER_BACKDROP (below the
// popover at Z_HUD_POPOVER) so tapping outside light-dismisses it.
commands.spawn((
ModesPopoverBackdrop,
Button,
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::NONE),
ZIndex(Z_HUD_POPOVER_BACKDROP),
));
}
/// Dispatches the click on a popover row to the matching request event,
/// then despawns the popover.
///
/// Classic uses [`NewGameRequestEvent`] directly; the other modes use
/// their `Start*RequestEvent` so the existing keyboard handler runs
/// (level gates, `TimeAttackResource` setup, daily seed lookup, etc.) —
/// the popover stays a thin entry point and never duplicates that logic.
#[allow(clippy::too_many_arguments)]
pub(super) fn handle_mode_option_click(
interaction_query: Query<(&Interaction, &ModeOption), Changed<Interaction>>,
popovers: Query<Entity, With<ModesPopover>>,
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut zen: MessageWriter<StartZenRequestEvent>,
mut challenge: MessageWriter<StartChallengeRequestEvent>,
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
mut commands: Commands,
) {
let mut clicked_any = false;
for (interaction, option) in &interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
clicked_any = true;
match option {
ModeOption::Classic => {
new_game.write(NewGameRequestEvent::default());
}
ModeOption::DailyChallenge => {
daily.write(StartDailyChallengeRequestEvent);
}
ModeOption::Zen => {
zen.write(StartZenRequestEvent);
}
ModeOption::Challenge => {
challenge.write(StartChallengeRequestEvent);
}
ModeOption::TimeAttack => {
time_attack.write(StartTimeAttackRequestEvent);
}
}
}
if clicked_any && let Ok(entity) = popovers.single() {
commands.entity(entity).despawn();
for e in &backdrops {
commands.entity(e).despawn();
}
}
}
/// Toggles the [`MenuPopover`]: spawns it on first click, despawns it on
/// second click. The popover lists the five overlays previously only
/// reachable via the S / A / P / O / L hotkeys.
pub(super) fn handle_menu_button(
interaction_query: Query<&Interaction, (With<MenuButton>, Changed<Interaction>)>,
popovers: Query<Entity, With<MenuPopover>>,
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
scrims: Query<(), With<ModalScrim>>,
font_res: Option<Res<FontResource>>,
mut commands: Commands,
) {
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
if !pressed {
return;
}
if let Ok(entity) = popovers.single() {
commands.entity(entity).despawn();
for e in &backdrops {
commands.entity(e).despawn();
}
} else if scrims.is_empty() {
spawn_menu_popover(&mut commands, font_res.as_deref());
}
}
/// Spawns the menu popover anchored just below the action bar, with one
/// row per overlay. Each row dispatches its corresponding
/// `Toggle*RequestEvent` so the existing toggle handler runs (and the
/// HUD never duplicates spawn / despawn / fetch logic).
pub(super) fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>) {
let font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: 15.0,
..default()
};
// 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] = [
(
"Play",
&[(
MenuOption::Home,
"Home",
"Pick a mode, continue, or start a new game.",
)],
),
(
"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.",
),
],
),
(
"Community",
&[(
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.",
),
],
),
];
// Same upward-opening placement as ModesPopover.
let popover_bottom = Val::Px(ACTION_POPOVER_BOTTOM_PX);
commands
.spawn((
MenuPopover,
HudPopoverOpen,
Node {
position_type: PositionType::Absolute,
right: VAL_SPACE_3,
bottom: popover_bottom,
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_1,
padding: UiRect::all(VAL_SPACE_2),
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
..default()
},
BackgroundColor(BG_ELEVATED),
ZIndex(Z_HUD_POPOVER),
))
.with_children(|panel| {
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(Node {
padding: UiRect::axes(VAL_SPACE_3, Val::Px(2.0)),
..default()
})
.with_children(|b| {
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)));
});
}
}
});
// Transparent fullscreen backdrop behind the popover — tapping anywhere
// outside the panel light-dismisses it via handle_menu_backdrop_click.
commands.spawn((
MenuPopoverBackdrop,
Button,
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::NONE),
ZIndex(Z_HUD_POPOVER_BACKDROP),
));
}
/// Dispatches the click on a menu row to the matching toggle event,
/// then despawns the popover.
#[allow(clippy::too_many_arguments)]
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>,
mut commands: Commands,
) {
let mut clicked_any = 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::Stats => {
stats.write(ToggleStatsRequestEvent);
}
MenuOption::Achievements => {
achievements.write(ToggleAchievementsRequestEvent);
}
MenuOption::Profile => {
profile.write(ToggleProfileRequestEvent);
}
MenuOption::Settings => {
settings.write(ToggleSettingsRequestEvent);
}
MenuOption::Leaderboard => {
leaderboard.write(ToggleLeaderboardRequestEvent);
}
}
}
if clicked_any && let Ok(entity) = popovers.single() {
commands.entity(entity).despawn();
for e in &backdrops {
commands.entity(e).despawn();
}
}
}
/// Despawns the [`ModesPopover`] and its backdrop when Escape / Android back
/// is pressed while the popover is open. Runs so `PausePlugin`'s guard (which
/// checks [`HudPopoverOpen`]) sees an empty world and stays idle.
pub(super) fn close_modes_popover_on_escape(
keys: Res<ButtonInput<KeyCode>>,
popovers: Query<Entity, With<ModesPopover>>,
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
mut commands: Commands,
) {
if !keys.just_pressed(KeyCode::Escape) || popovers.is_empty() {
return;
}
for e in popovers.iter().chain(backdrops.iter()) {
commands.entity(e).despawn();
}
}
/// Despawns the [`MenuPopover`] and its backdrop when Escape / Android back
/// is pressed while the popover is open.
pub(super) fn close_menu_popover_on_escape(
keys: Res<ButtonInput<KeyCode>>,
popovers: Query<Entity, With<MenuPopover>>,
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
mut commands: Commands,
) {
if !keys.just_pressed(KeyCode::Escape) || popovers.is_empty() {
return;
}
for e in popovers.iter().chain(backdrops.iter()) {
commands.entity(e).despawn();
}
}
/// Despawns the [`ModesPopover`] and its backdrop when the player taps
/// anywhere outside the panel.
pub(super) fn handle_modes_backdrop_click(
interaction_query: Query<&Interaction, (With<ModesPopoverBackdrop>, Changed<Interaction>)>,
popovers: Query<Entity, With<ModesPopover>>,
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
mut commands: Commands,
) {
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
if !pressed {
return;
}
for e in popovers.iter().chain(backdrops.iter()) {
commands.entity(e).despawn();
}
}
/// Despawns the [`MenuPopover`] and its backdrop when the player taps
/// anywhere outside the panel (i.e. the transparent backdrop is pressed).
pub(super) fn handle_menu_backdrop_click(
interaction_query: Query<&Interaction, (With<MenuPopoverBackdrop>, Changed<Interaction>)>,
popovers: Query<Entity, With<MenuPopover>>,
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
mut commands: Commands,
) {
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
if !pressed {
return;
}
for e in popovers.iter().chain(backdrops.iter()) {
commands.entity(e).despawn();
}
}
#[cfg(target_os = "android")]
#[allow(clippy::too_many_arguments)]
pub(super) fn toggle_hud_on_tap(
mut touch_events: MessageReader<TouchInput>,
drag: Res<DragState>,
scrims: Query<(), With<ModalScrim>>,
paused: Option<Res<PausedResource>>,
mut tracker: ResMut<HudTapTracker>,
mut hud_vis: ResMut<HudVisibility>,
buttons: Query<&Interaction, With<ActionButton>>,
mut game_consumed: ResMut<GameInputConsumedResource>,
) {
use bevy::input::touch::TouchPhase;
if !scrims.is_empty() || paused.is_some_and(|p| p.0) {
// Drain buffered events so they don't replay in the frame after
// the scrim despawns, which would trigger a spurious visibility
// toggle as the resume/close button tap's Started+Ended pair
// replays in the now-scrim-free frame.
for _ in touch_events.read() {}
tracker.start_pos = None;
tracker.started_on_button = false;
game_consumed.0 = false;
return;
}
for event in touch_events.read() {
match event.phase {
TouchPhase::Started => {
tracker.start_pos = Some(event.position);
// Record whether the finger-down landed on a button so
// the finger-up doesn't double-fire (toggle bar + press
// button at the same time).
tracker.started_on_button = buttons.iter().any(|i| *i != Interaction::None);
}
TouchPhase::Ended if drag.is_idle() => {
// Also treat taps where game logic consumed the touch (e.g.
// drawing from stock) as "on button" so they don't toggle
// the HUD. The flag is set on TouchPhase::Started by the
// input system that consumed the tap and must be cleared here
// regardless of whether we toggle.
let on_button = tracker.started_on_button || game_consumed.0;
game_consumed.0 = false;
if let Some(start) = tracker.start_pos.take()
&& !on_button
&& (event.position - start).length() < HUD_TAP_SLOP_PX
{
*hud_vis = match *hud_vis {
HudVisibility::Visible => HudVisibility::Hidden,
HudVisibility::Hidden => HudVisibility::Visible,
};
}
tracker.started_on_button = false;
}
// Moved: don't clear start_pos — Android fires Moved for normal
// tap jitter, and the distance check at Ended already rejects
// real drags. Clearing here would silently swallow tap toggles.
TouchPhase::Canceled => {
tracker.start_pos = None;
tracker.started_on_button = false;
game_consumed.0 = false;
}
_ => {}
}
}
}
+616
View File
@@ -0,0 +1,616 @@
//! Persistent in-game HUD: score, move count, elapsed time, mode badge,
//! daily-challenge constraint, and undo count.
//!
//! The HUD spawns once at startup and lives for the app's lifetime. Text is
//! refreshed whenever `GameStateResource` changes (which happens on every move
//! and every elapsed-time tick), so score, moves, and timer all stay current
//! without a separate tick system.
use bevy::prelude::*;
use bevy::window::WindowResized;
mod fx;
mod interaction;
mod spawn;
mod updates;
pub use fx::*;
use interaction::*;
use spawn::*;
use updates::*;
// On wasm32 AvatarPlugin is gated out; define a placeholder type so the
// Option<Res<AvatarResource>> parameters below compile without changes.
// The resource is never inserted on wasm, so every call resolves to None.
#[cfg(target_arch = "wasm32")]
#[derive(bevy::prelude::Resource)]
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,
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleHomeRequestEvent,
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent,
};
use crate::font_plugin::FontResource;
use crate::game_plugin::{GameMutation, NewGameRequestWriters};
#[cfg(target_os = "android")]
use crate::input_plugin::TouchDragSet;
use crate::layout::HUD_BAND_HEIGHT;
use crate::layout::LayoutSystem;
use crate::pause_plugin::PausedResource;
use crate::platform::{SHOW_KEYBOARD_ACCELERATORS, USE_TOUCH_UI_LAYOUT};
use crate::progress_plugin::ProgressResource;
use crate::resources::GameStateResource;
#[cfg(target_os = "android")]
use crate::resources::{DragState, GameInputConsumedResource};
use crate::safe_area::{SafeAreaAnchoredBottom, SafeAreaAnchoredTop};
use crate::selection_plugin::SelectionState;
use crate::settings_plugin::SettingsResource;
use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_focus::{FocusGroup, Focusable};
use crate::ui_modal::ModalScrim;
use crate::ui_theme::SPACE_2;
use crate::ui_theme::UiTextFx;
use crate::ui_theme::{
ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, BG_ELEVATED_PRESSED,
BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
MOTION_STREAK_FLOURISH_SECS, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS,
STATE_WARNING, STREAK_FLOURISH_PEAK_SCALE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
scaled_duration,
};
use crate::ui_tooltip::Tooltip;
use solitaire_data::SyncBackend;
/// Marker on the score text node.
#[derive(Component, Debug)]
pub struct HudScore;
/// Marker on the move-count text node.
#[derive(Component, Debug)]
pub struct HudMoves;
/// Marker on the elapsed-time text node.
#[derive(Component, Debug)]
pub struct HudTime;
/// Marker on the mode badge text node.
#[derive(Component, Debug)]
pub struct HudMode;
/// Marker on the daily-challenge constraint text node.
///
/// Displays the active goal (time limit or score target) when a daily challenge
/// is in progress. Empty string when no challenge is active or the game is won.
#[derive(Component, Debug)]
pub struct HudChallenge;
/// Marker on the "won this deal before" indicator text node.
///
/// Displays `"✓ Won before"` when the current deal's seed + draw_mode +
/// mode triple matches one of the entries in `ReplayHistoryResource`.
/// Empty string otherwise (including won games — the score readout
/// already conveys the win on the active deal). Only meaningful for
/// Classic / Zen / Challenge — daily-challenge and time-attack seeds
/// are filtered out implicitly because their replay entries always
/// carry a different mode tag.
#[derive(Component, Debug)]
pub struct HudWonPreviously;
/// Marker on the undo-count text node.
///
/// Shows how many undos have been used this game. Displayed in amber when
/// `undo_count > 0` because using undo blocks the no-undo achievement.
#[derive(Component, Debug)]
pub struct HudUndos;
/// Marker on the auto-complete badge text node.
///
/// Displays `"AUTO"` in green while `AutoCompleteState.active` is true;
/// empty string otherwise.
#[derive(Component, Debug)]
pub struct HudAutoComplete;
/// Marker on the stock-recycle counter text node.
///
/// Displays `"Recycles: N"` whenever `recycle_count > 0`, regardless of draw
/// mode, so the player can track stock recycling in both Draw-One and
/// Draw-Three (relevant to the `comeback` achievement). Hidden (empty string)
/// until the first recycle occurs.
#[derive(Component, Debug)]
pub struct HudRecycles;
/// Marker on the draw-cycle indicator text node.
///
/// Only shown in Draw-Three mode. Displays `"Cycle: N/3"` where N is the
/// number of cards that will be drawn on the next stock click
/// (`min(stock_len, 3)`). Shows `"Cycle: 0/3"` when the stock is empty
/// (recycle available). Hidden (empty string) in Draw-One mode or after the
/// game is won.
#[derive(Component, Debug)]
pub struct HudDrawCycle;
/// Marker on the keyboard-selection indicator text node.
///
/// Displays `"▶ {pile_name}"` while a pile is selected via Tab, or an empty
/// string when no pile is selected. Uses a light-yellow colour so it stands
/// out from the other white HUD items.
#[derive(Component, Debug)]
pub struct HudSelection;
/// Marker on the HUD band background node (the translucent band behind buttons).
#[derive(Component, Debug)]
pub struct HudBand;
/// Marker on the HUD score/info column root node.
#[derive(Component, Debug)]
pub struct HudColumn;
/// Marker on the action button bar root node.
#[derive(Component, Debug)]
pub struct HudActionBar;
/// Set wrapping the chained HUD button/popover interaction systems. Other
/// keyboard consumers order themselves around it (e.g.
/// [`crate::ui_focus::FocusKeys`] runs after) so input-consumption order is
/// deterministic (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct HudButtons;
/// Marker on the text node inside each touch-layout action-bar button.
/// Used by `resize_action_bar_labels` to update font size on window resize.
#[derive(Component, Debug)]
struct ActionButtonLabel;
/// Marker on the circular profile-picture button anchored to the
/// top-right of the HUD band. Pressing it opens the Profile overlay.
/// Shows the server avatar image when loaded; falls back to the player's
/// initial on a filled disc when no image is available.
#[derive(Component, Debug)]
pub struct HudAvatar;
/// Controls whether the in-game HUD (band, score column, action buttons) is
/// visible. Toggled on Android by tapping empty board space; always `Visible`
/// on desktop. Resets to `Visible` whenever a modal opens.
#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HudVisibility {
#[default]
Visible,
Hidden,
}
#[cfg(target_os = "android")]
#[derive(Resource, Debug, Default)]
struct HudTapTracker {
start_pos: Option<Vec2>,
/// Set `true` when the finger-down hit an action button so the
/// finger-up never toggles bar visibility.
started_on_button: bool,
}
#[cfg(target_os = "android")]
const HUD_TAP_SLOP_PX: f32 = 25.0;
/// Drives the score-readout pulse: scales the [`HudScore`] text from
/// 1.0 → 1.1 → 1.0 over [`MOTION_SCORE_PULSE_SECS`] (scaled by
/// [`AnimSpeed`](solitaire_data::AnimSpeed)). Inserted on the score
/// entity whenever the score increases; removed once `elapsed >=
/// duration`.
#[derive(Component, Debug, Clone, Copy)]
pub struct ScorePulse {
/// Seconds elapsed since the pulse started.
pub elapsed: f32,
/// Total duration. Zero under `AnimSpeed::Instant` — the system
/// snaps the scale back to 1.0 on first tick so no half-state
/// is ever shown.
pub duration: f32,
}
/// Marker on a transient floating "+N" text spawned next to the score
/// readout when the score jumps by [`SCORE_FLOATER_THRESHOLD`] or more.
/// Drifts upward and fades out over `MOTION_SCORE_PULSE_SECS * 2`,
/// then despawns. Kept rare/meaningful by the threshold gate.
#[derive(Component, Debug, Clone, Copy)]
pub struct ScoreFloater {
/// Seconds elapsed since the floater spawned.
pub elapsed: f32,
/// Total lifetime. Zero under `AnimSpeed::Instant` — the system
/// despawns it on first tick.
pub duration: f32,
}
/// Drives the streak-milestone flourish: scales the [`HudScore`] text
/// from `1.0 → STREAK_FLOURISH_PEAK_SCALE → 1.0` over
/// [`MOTION_STREAK_FLOURISH_SECS`] (scaled by
/// [`AnimSpeed`](solitaire_data::AnimSpeed)) and tints it
/// [`ACCENT_SECONDARY`] for the same window before restoring the
/// original colour.
///
/// The streak readout currently lives in the Stats overlay (press
/// `S`) — there is no always-on HUD streak counter — so the flourish
/// piggybacks on the score readout, which is the most prominent
/// always-visible HUD number. Mirrors the `FoundationFlourish`
/// pattern: triangular scale curve, fixed duration, restores state
/// when the timer expires.
///
/// Inserted on `HudScore` entities by `start_streak_flourish` when a
/// `WinStreakMilestoneEvent` fires; removed once `elapsed >=
/// duration` so the readout returns to its rest state for the next
/// frame's transform sync.
///
/// Coexists with [`ScorePulse`]: the streak flourish lives on a
/// dedicated marker so a streak-crossing win that also ticks the
/// score (every win does) doesn't have the two animations stomp on
/// each other's `Transform.scale` writes — the streak flourish runs
/// in a `Without<ScorePulse>` query so only the loudest of the two
/// celebrations is active at a time.
#[derive(Component, Debug, Clone, Copy)]
pub struct StreakFlourish {
/// The streak milestone that triggered this flourish (3, 5, 10).
/// Carried for diagnostic logging only — the visual is identical
/// for every threshold so play-testing can decide later whether
/// to differentiate.
pub streak: u32,
/// Seconds elapsed since the flourish began.
pub elapsed: f32,
/// Total animation length in seconds. Zero under
/// [`AnimSpeed::Instant`](solitaire_data::AnimSpeed) — the system
/// snaps the scale back to 1.0 on the first tick so no half-state
/// is ever shown.
pub duration: f32,
/// The score readout's colour before the flourish began —
/// restored when the timer expires so the readout returns to its
/// resting `TEXT_PRIMARY` (or whatever it was) tint.
pub original_color: Color,
}
/// Tracks the score from the previous frame so the HUD can detect
/// changes without a `ScoreChangedEvent`. The plugin wires this to the
/// pulse + floater systems on every `Update`.
#[derive(Resource, Debug, Default, Clone, Copy)]
pub struct PreviousScore(pub i32);
/// Score increase (in points) below which no floating "+N" is spawned.
/// 50 keeps the feedback for foundation drops and tableau-to-foundation
/// promotions; single-card placements (which can earn as little as +5)
/// stay quiet so the floater feels like a reward instead of noise.
pub const SCORE_FLOATER_THRESHOLD: i32 = 50;
/// Marker shared by every clickable HUD action button so a single
/// `paint_action_buttons` system can recolour them on hover/press without
/// each button needing its own paint handler.
#[derive(Component, Debug)]
pub struct ActionButton;
/// Marker on rows inside a popover panel ([`ModesPopover`] or
/// [`MenuPopover`]). Popover rows already carry `ActionButton` so the
/// hover/press paint path applies to them, but the auto-fade applied
/// to the top-level action bar must NOT also fade these rows — the
/// popover only renders when the player has explicitly opened it, so
/// its content should always be at full opacity. `apply_action_fade`
/// excludes entities with this marker via `Without<PopoverRow>`.
#[derive(Component, Debug)]
pub struct PopoverRow;
/// Marker on the "New Game" action button anchored top-right of the play
/// area. Click fires [`NewGameRequestEvent`]; the existing
/// `ConfirmNewGameScreen` modal handles confirmation when a game is in
/// progress.
#[derive(Component, Debug)]
pub struct NewGameButton;
/// Marker on the "Undo" action button. Click fires [`UndoRequestEvent`],
/// mirroring the `U` keyboard accelerator.
#[derive(Component, Debug)]
pub struct UndoButton;
/// Marker on the "Pause" action button. Click fires [`PauseRequestEvent`],
/// mirroring the `Esc` keyboard accelerator. The pause overlay's own resume
/// affordance dismisses it from the paused state.
#[derive(Component, Debug)]
pub struct PauseButton;
/// Marker on the "Help" action button. Click fires [`HelpRequestEvent`],
/// mirroring the `F1` keyboard accelerator.
#[derive(Component, Debug)]
pub struct HelpButton;
/// Marker on the "Hint" action button. Click spawns an async solver task
/// (same as the `H` keyboard accelerator) and highlights the suggested card.
#[derive(Component, Debug)]
pub struct HintButton;
/// 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";
#[cfg(target_os = "android")]
const ACTION_BAR_LABELS: [&str; 7] = [
"Menu",
"Undo",
"Pause",
"Help",
ANDROID_HINT_LABEL,
"Mode",
"New",
];
#[cfg(not(target_os = "android"))]
const ACTION_BAR_LABELS: [&str; 7] = [
"Menu \u{2193}",
"Undo",
"Pause",
"Help",
"Hint",
"Modes \u{2193}",
"New Game",
];
#[cfg(target_os = "android")]
const ACTION_BAR_COLUMN_GAP: Val = Val::Px(4.0);
#[cfg(not(target_os = "android"))]
const ACTION_BAR_COLUMN_GAP: Val = VAL_SPACE_2;
#[cfg(target_os = "android")]
const ACTION_POPOVER_BOTTOM_PX: f32 = 200.0;
#[cfg(not(target_os = "android"))]
const ACTION_POPOVER_BOTTOM_PX: f32 = 80.0;
#[cfg(target_os = "android")]
const HINT_WON_MSG: &str = "Game won! Tap New Game to play again";
#[cfg(not(target_os = "android"))]
const HINT_WON_MSG: &str = "Game won! Press N for a new game";
/// Marker on the "Modes" action button. Click toggles the [`ModesPopover`]
/// (a small dropdown panel) below the action bar. Each popover row starts
/// the corresponding game mode.
#[derive(Component, Debug)]
pub struct ModesButton;
/// Marker on the dropdown panel that opens below the [`ModesButton`].
/// Spawned on first click, despawned on second click or on mode select.
#[derive(Component, Debug)]
pub struct ModesPopover;
/// One row inside the [`ModesPopover`]. The variant carries which event
/// the click handler should fire — Classic uses `NewGameRequestEvent`
/// directly, the others go through their `Start*RequestEvent` so the
/// existing keyboard handler's level gate / resource setup runs.
#[derive(Component, Debug, Clone, Copy)]
pub enum ModeOption {
Classic,
DailyChallenge,
Zen,
Challenge,
TimeAttack,
}
/// Marker on the "Menu" action button. Click toggles the [`MenuPopover`]
/// 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;
/// Marker on the dropdown panel that opens below the [`MenuButton`].
#[derive(Component, Debug)]
pub struct MenuPopover;
/// Shared marker placed on both [`MenuPopover`] and [`ModesPopover`] entities
/// while they are open. External systems (e.g. `PausePlugin`) query this to
/// determine whether a HUD popover is currently visible without importing the
/// individual popover types.
#[derive(Component, Debug)]
pub struct HudPopoverOpen;
/// Fullscreen transparent backdrop spawned behind the [`MenuPopover`].
/// Pressing it (tap anywhere outside the popover) light-dismisses the menu.
#[derive(Component, Debug)]
struct MenuPopoverBackdrop;
/// Fullscreen transparent backdrop spawned behind the [`ModesPopover`].
/// Pressing it (tap anywhere outside the popover) light-dismisses it.
#[derive(Component, Debug)]
struct ModesPopoverBackdrop;
/// One row inside the [`MenuPopover`]. The variant selects which
/// `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 {
Home,
Profile,
Stats,
Achievements,
Leaderboard,
Settings,
Help,
}
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
/// Mirrors `ui_theme::Z_HUD` and is duplicated here only so the hud module
/// can use it as a `const` without a non-const expression in `ZIndex(...)`.
const Z_HUD: i32 = crate::ui_theme::Z_HUD;
const Z_HUD_POPOVER_BACKDROP: i32 = crate::ui_theme::Z_HUD_POPOVER_BACKDROP;
const Z_HUD_POPOVER: i32 = crate::ui_theme::Z_HUD_POPOVER;
const Z_HUD_TOP: i32 = crate::ui_theme::Z_HUD_TOP;
/// Idle / hover / pressed colours shared by every action button. Aliased
/// to the theme tokens so the HUD picks up palette changes for free.
const ACTION_BTN_IDLE: Color = BG_ELEVATED;
const ACTION_BTN_HOVER: Color = BG_ELEVATED_HI;
const ACTION_BTN_PRESSED: Color = BG_ELEVATED_PRESSED;
/// Renders the in-game HUD: score counter, move counter, elapsed timer, draw-mode indicator, and the auto-complete badge that lights up when the game is solvable without further input.
pub struct HudPlugin;
impl Plugin for HudPlugin {
fn build(&self, app: &mut App) {
// The click handlers write to messages registered elsewhere by their
// owning plugins (`GamePlugin`, `PausePlugin`, `HelpPlugin`,
// `challenge_plugin`, `daily_challenge_plugin`, `time_attack_plugin`,
// `input_plugin`). Re-register defensively so the HUD plugin works in
// isolation under `MinimalPlugins` (tests). `add_message` is
// idempotent.
app.add_message::<NewGameRequestEvent>()
.add_message::<UndoRequestEvent>()
.add_message::<PauseRequestEvent>()
.add_message::<HelpRequestEvent>()
.add_message::<StartZenRequestEvent>()
.add_message::<StartChallengeRequestEvent>()
.add_message::<StartTimeAttackRequestEvent>()
.add_message::<StartDailyChallengeRequestEvent>()
.add_message::<ToggleStatsRequestEvent>()
.add_message::<ToggleHomeRequestEvent>()
.add_message::<ToggleAchievementsRequestEvent>()
.add_message::<ToggleProfileRequestEvent>()
.add_message::<ToggleSettingsRequestEvent>()
.add_message::<ToggleLeaderboardRequestEvent>()
.add_message::<WinStreakMilestoneEvent>()
.init_resource::<PreviousScore>()
.init_resource::<HudActionFade>()
.init_resource::<HudVisibility>()
// Escape-close handlers for popovers read this; init defensively
// so HudPlugin works under MinimalPlugins in tests.
.init_resource::<ButtonInput<KeyCode>>()
// WindowResized is registered by table_plugin; re-register
// defensively so the HUD plugin works standalone in tests.
.add_message::<WindowResized>()
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons, spawn_hud_avatar))
// HUD text updaters run as one deterministic chain (they write
// disjoint Text nodes, but Bevy can't prove it); update_hud also
// reads AutoCompleteState, so the chain sits after the
// auto-complete detect/drive chain (#143).
.add_systems(
Update,
(
update_hud,
update_selection_hud.run_if(
resource_exists_and_changed::<SelectionState>
.or(resource_exists_and_changed::<GameStateResource>),
),
update_won_previously,
)
.chain()
.after(GameMutation)
.after(crate::auto_complete_plugin::AutoComplete)
.in_set(UiTextFx)
.ambiguous_with(UiTextFx),
)
// HUD chrome visibility: modal-restore writes HudVisibility, the
// applier consumes it, and the layout recompute reads it — a
// fixed chain instead of three racing systems (#143).
.add_systems(
Update,
(restore_hud_on_modal, apply_hud_visibility)
.chain()
.before(LayoutSystem::UpdateOnResize),
)
.add_systems(
Update,
(
update_hud_avatar.after(crate::settings_plugin::SettingsMutation),
handle_avatar_button.ambiguous_with(HudButtons),
),
)
.add_systems(
Update,
announce_auto_complete
.after(GameMutation)
.after(crate::auto_complete_plugin::AutoComplete)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
)
// Typography rescale touches HUD TextFont only, but orders after
// the board painters that resize card/label text (#143).
.add_systems(
Update,
update_hud_typography.after(crate::card_plugin::BoardVisuals),
)
.add_systems(
Update,
(
detect_score_change,
advance_score_pulse,
advance_score_floater,
)
.chain()
.after(GameMutation)
.in_set(UiTextFx)
.ambiguous_with(UiTextFx)
.ambiguous_with(crate::card_plugin::BoardVisuals),
)
.add_systems(
Update,
(start_streak_flourish, advance_streak_flourish)
.chain()
.after(GameMutation)
.in_set(UiTextFx)
.ambiguous_with(UiTextFx)
.ambiguous_with(crate::card_plugin::BoardVisuals),
)
.add_systems(
Update,
(
handle_new_game_button
.in_set(NewGameRequestWriters)
.ambiguous_with(NewGameRequestWriters),
handle_undo_button
.in_set(crate::game_plugin::UndoRequestWriters)
.ambiguous_with(crate::game_plugin::UndoRequestWriters)
.before(GameMutation),
handle_pause_button,
handle_help_button,
handle_hint_button
.after(GameMutation)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
handle_modes_button,
handle_mode_option_click
.in_set(NewGameRequestWriters)
.ambiguous_with(NewGameRequestWriters),
handle_modes_backdrop_click,
close_modes_popover_on_escape,
handle_menu_button,
handle_menu_option_click,
handle_menu_backdrop_click,
close_menu_popover_on_escape,
paint_action_buttons,
)
.chain()
.in_set(HudButtons)
.before(crate::ui_focus::FocusKeys),
)
// Fade lives in `Last` so it always overrides whatever the
// hover/paint pass set on `BackgroundColor` this frame.
// Otherwise on a hover-state change (`Changed<Interaction>`),
// `paint_action_buttons` would clobber the alpha back to 1.0
// mid-fade and produce a visible blip.
;
// Desktop-only: cursor-proximity fade. On Android the bar
// visibility is toggled explicitly; cursor_position() returning
// Some(touch_pos) during a tap would otherwise fade the bar out.
#[cfg(not(target_os = "android"))]
app.add_systems(Last, (update_action_fade, apply_action_fade).chain());
#[cfg(target_os = "android")]
{
app.init_resource::<HudTapTracker>()
.add_message::<TouchInput>()
.add_systems(
Update,
toggle_hud_on_tap
.after(TouchDragSet::AfterStartDrag)
.in_set(TouchDragSet::BeforeEndDrag),
);
app.add_systems(
Update,
resize_action_bar_labels
.run_if(resource_exists_and_changed::<crate::layout::LayoutResource>),
);
}
}
}
#[cfg(test)]
mod tests;
+552
View File
@@ -0,0 +1,552 @@
//! HUD construction: band, columns, avatar, and action-bar spawning.
use super::*;
#[cfg(not(target_arch = "wasm32"))]
use crate::avatar_plugin::AvatarResource;
/// Spawns the invisible HUD band that reserves vertical space at the top of
/// the screen so the card layout (computed by `layout::compute_layout` using
/// `HUD_BAND_HEIGHT`) aligns correctly below the score readouts.
///
/// The entity carries no `BackgroundColor` — the green felt shows through.
/// A slim grey background is handled by each content section individually
/// (the bottom action bar has its own `BG_HUD_BAND` background).
pub(super) fn spawn_hud_band(mut commands: Commands) {
const BASE_TOP: f32 = 0.0;
commands.spawn((
Node {
position_type: PositionType::Absolute,
top: Val::Px(BASE_TOP),
left: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Px(HUD_BAND_HEIGHT),
..default()
},
ZIndex(Z_HUD - 1),
SafeAreaAnchoredTop { base_top: BASE_TOP },
HudBand,
));
}
/// Spawns the in-game HUD as a 4-tier vertical column anchored to the
/// top-left of the play area.
///
/// Tiers (top to bottom):
/// 1. **Primary** — Score (display weight) · Moves · Timer.
/// Always visible during gameplay.
/// 2. **Mode context** — Mode badge · Daily-challenge constraint ·
/// Draw-cycle indicator. Each cell is empty when not relevant; the
/// row collapses visually when all cells are empty.
/// 3. **Penalty / bonus** — Undos · Recycles · Auto-complete badge.
/// Both penalty counters share `STATE_WARNING` (the audit found
/// they were inconsistent: Undos amber, Recycles white).
/// 4. **Selection** — keyboard-driven pile selector chip.
///
/// The audit identified the original single-row layout (10 readouts in
/// one horizontal flex row, 5+ colour families competing) as the
/// player's #1 complaint. This restructure groups by purpose, lets
/// transient items disappear cleanly, and uses the typography scale to
/// make Score the visual protagonist.
pub(super) fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
let font_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
let font_score = TextFont {
font: font_handle.clone(),
font_size: TYPE_HEADLINE,
..default()
};
let font_lg = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY_LG,
..default()
};
let font_body = TextFont {
font: font_handle,
font_size: TYPE_BODY,
..default()
};
let row_node = || Node {
flex_direction: FlexDirection::Row,
column_gap: VAL_SPACE_3,
// On a narrow viewport the four tier rows (Score/Moves/Timer,
// Mode/Challenge/Draw-cycle/Won-previously, Undos/Recycles/
// Auto-complete, selection chip) can collectively be wider than
// the available space and overflow into the action-button column
// on the right. `flex_wrap: Wrap` lets each tier soft-wrap onto
// a second line; on a desktop window the rows stay single-line
// because the parent column has no width cap and the row never
// exceeds the natural line width.
flex_wrap: FlexWrap::Wrap,
row_gap: VAL_SPACE_1,
align_items: AlignItems::Baseline,
..default()
};
commands
.spawn((
Node {
position_type: PositionType::Absolute,
left: VAL_SPACE_3,
top: Val::Px(SPACE_2),
flex_direction: FlexDirection::Column,
// Cap the column at 50% of viewport so on narrow
// (mobile) widths the inner tier rows have a bounded
// width to wrap against, and the column can't bleed
// into the right-anchored action button row (also
// capped at 50%). On desktop 50% of 1920 = 960 px,
// wider than any tier row's natural width, so the
// visible layout is unaffected.
max_width: Val::Percent(50.0),
row_gap: VAL_SPACE_1,
..default()
},
ZIndex(Z_HUD),
SafeAreaAnchoredTop { base_top: SPACE_2 },
HudColumn,
))
.with_children(|hud| {
// Tier 1 — primary readouts. Score is the protagonist (HEADLINE);
// Moves and Timer are supporting context (BODY_LG, secondary tone).
hud.spawn(row_node()).with_children(|t1| {
t1.spawn((
HudScore,
Tooltip::new("Points earned this game. Hidden in Zen mode."),
Text::new("Score: 0"),
font_score.clone(),
TextColor(TEXT_PRIMARY),
));
t1.spawn((
HudMoves,
Tooltip::new("Moves you've made this game. Counts placements and stock draws."),
Text::new("Moves: 0"),
font_lg.clone(),
TextColor(TEXT_SECONDARY),
));
t1.spawn((
HudTime,
Tooltip::new("Time on this game. Counts down in Time Attack."),
Text::new("0:00"),
font_lg.clone(),
TextColor(TEXT_SECONDARY),
));
});
// Tier 2 — mode context. Each cell is empty until update_hud
// populates it (and clears it when no longer relevant), so the
// row collapses when nothing in this tier applies.
hud.spawn(row_node()).with_children(|t2| {
t2.spawn((
HudMode,
Tooltip::new("Active game mode. Click Modes to switch."),
Text::new(""),
font_body.clone(),
TextColor(ACCENT_PRIMARY),
));
t2.spawn((
HudChallenge,
Tooltip::new("Today's daily challenge target. Beat it for bonus XP."),
Text::new(""),
font_body.clone(),
TextColor(STATE_INFO),
));
t2.spawn((
HudDrawCycle,
Tooltip::new("Cards drawn on the next stock click in Draw-Three."),
Text::new(""),
font_body.clone(),
TextColor(STATE_INFO),
));
t2.spawn((
HudWonPreviously,
Tooltip::new("You've won this deal before. Same seed in your replay history."),
Text::new(""),
font_body.clone(),
TextColor(STATE_SUCCESS),
));
});
// Tier 3 — penalty / bonus. Undos and Recycles share the
// warning hue so they read as the same category ("you took a
// penalty"); the auto-complete badge stays success-green.
hud.spawn(row_node()).with_children(|t3| {
t3.spawn((
HudUndos,
Tooltip::new("Undos used this game. Any undo blocks the No Undo achievement."),
Text::new(""),
font_body.clone(),
TextColor(STATE_WARNING),
));
t3.spawn((
HudRecycles,
Tooltip::new(
"Times you've recycled the stock. Three or more unlocks Comeback.",
),
Text::new(""),
font_body.clone(),
TextColor(STATE_WARNING),
));
t3.spawn((
HudAutoComplete,
Tooltip::new("Board is solvable from here. Press Enter to auto-finish."),
Text::new(""),
font_body.clone(),
TextColor(STATE_SUCCESS),
));
});
// Tier 4 — selection chip. Stays in HUD for now; a future
// pass can reposition it next to the selected pile.
hud.spawn(row_node()).with_children(|t4| {
t4.spawn((
HudSelection,
Tooltip::new("Pile selected with Tab. Use arrows or Enter to act."),
Text::new(""),
font_body,
TextColor(ACCENT_SECONDARY),
));
});
});
}
/// Spawns the circular avatar / initials button anchored to the top-right
/// of the HUD band. Initial content is seeded from whatever resources are
/// available at startup; `update_hud_avatar` replaces the children whenever
/// `AvatarResource` or `SettingsResource` later changes.
pub(super) fn spawn_hud_avatar(
font_res: Option<Res<FontResource>>,
avatar: Option<Res<AvatarResource>>,
settings: Option<Res<SettingsResource>>,
mut commands: Commands,
) {
const SIZE: f32 = 32.0;
let id = commands
.spawn((
HudAvatar,
Button,
Tooltip::new("Your profile — tap to open."),
Node {
position_type: PositionType::Absolute,
top: Val::Px(SPACE_2),
right: VAL_SPACE_3,
width: Val::Px(SIZE),
height: Val::Px(SIZE),
border_radius: BorderRadius::all(Val::Px(SIZE / 2.0)),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
},
BackgroundColor(ACCENT_PRIMARY),
ZIndex(Z_HUD),
SafeAreaAnchoredTop { base_top: SPACE_2 },
))
.id();
spawn_avatar_child(
&mut commands,
id,
avatar.as_deref(),
settings.as_deref(),
font_res.as_deref(),
);
}
/// Re-spawns the avatar circle content (image or initials) whenever either
/// [`AvatarResource`] or [`SettingsResource`] changes — covers both the
/// image arriving after download and the username changing after login.
pub(super) fn update_hud_avatar(
avatar: Option<Res<AvatarResource>>,
settings: Option<Res<SettingsResource>>,
font_res: Option<Res<FontResource>>,
q: Query<Entity, With<HudAvatar>>,
mut commands: Commands,
) {
let avatar_changed = avatar.as_ref().is_some_and(|r| r.is_changed());
let settings_changed = settings.as_ref().is_some_and(|r| r.is_changed());
if !avatar_changed && !settings_changed {
return;
}
let Ok(entity) = q.single() else {
return;
};
commands.entity(entity).despawn_related::<Children>();
spawn_avatar_child(
&mut commands,
entity,
avatar.as_deref(),
settings.as_deref(),
font_res.as_deref(),
);
}
/// Populates the avatar container with either the downloaded image or an
/// initials fallback disc. Called from both the startup spawn and the
/// reactive update system so the rendering logic lives in one place.
pub(super) fn spawn_avatar_child(
commands: &mut Commands,
parent: Entity,
avatar: Option<&AvatarResource>,
settings: Option<&SettingsResource>,
font_res: Option<&FontResource>,
) {
const SIZE: f32 = 32.0;
if let Some(handle) = avatar.and_then(|a| a.0.clone()) {
// Logged-in with a downloaded avatar: keep the accent disc behind it.
commands
.entity(parent)
.insert(BackgroundColor(ACCENT_PRIMARY));
// Image fills the circle container; border_radius clips it to a disc.
commands.entity(parent).with_children(|b| {
b.spawn((
ImageNode::new(handle),
Node {
width: Val::Px(SIZE),
height: Val::Px(SIZE),
border_radius: BorderRadius::all(Val::Px(SIZE / 2.0)),
..default()
},
));
});
} else {
let initial = settings
.and_then(|s| match &s.0.sync_backend {
SyncBackend::SolitaireServer { username, .. } => username.chars().next(),
SyncBackend::Local => None,
})
.and_then(|c| c.to_uppercase().next())
.unwrap_or('?');
// Real initial (logged in) keeps the red accent disc; the '?'
// unauthenticated fallback uses a neutral grey so it reads as a
// "tap to log in" affordance rather than an error.
let disc_bg = if initial == '?' {
BG_ELEVATED_HI
} else {
ACCENT_PRIMARY
};
commands.entity(parent).insert(BackgroundColor(disc_bg));
commands.entity(parent).with_children(|b| {
b.spawn((
Text::new(initial.to_string()),
TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: 14.0,
..default()
},
TextColor(TEXT_PRIMARY),
));
});
}
}
/// Opens the Profile overlay when the avatar button is pressed.
pub(super) fn handle_avatar_button(
interaction_query: Query<&Interaction, (With<HudAvatar>, Changed<Interaction>)>,
mut toggle_profile: MessageWriter<ToggleProfileRequestEvent>,
) {
for interaction in &interaction_query {
if *interaction == Interaction::Pressed {
toggle_profile.write(ToggleProfileRequestEvent);
}
}
}
/// Spawns the action button bar anchored to the top-right of the window.
/// Each child is a clickable button mirroring a keyboard accelerator —
/// per the UI-first principle (CLAUDE.md / ARCHITECTURE.md §1) the buttons
/// are the primary entry point and the hotkeys are optional.
///
/// Order (left → right): Undo, Pause, Help, New Game. New Game is rightmost
/// because it's the most consequential action; the destructive button sits
/// on its own visual edge.
pub(super) fn spawn_action_buttons(
font_res: Option<Res<FontResource>>,
windows: Query<&Window>,
mut commands: Commands,
) {
let action_font_size =
action_bar_font_size(windows.iter().next().map_or(900.0, |win| win.width()));
let font = TextFont {
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
font_size: action_font_size,
..default()
};
// Bottom bar: full-width, centered, sits above the gesture-navigation zone.
// `SafeAreaAnchoredBottom` applies the correct logical-pixel inset once
// Android reports it (frames 1-3); initial value is 0.0.
commands
.spawn((
Node {
position_type: PositionType::Absolute,
bottom: Val::Px(0.0),
left: Val::Px(0.0),
width: Val::Percent(100.0),
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
justify_content: JustifyContent::Center,
column_gap: ACTION_BAR_COLUMN_GAP,
row_gap: VAL_SPACE_2,
align_items: AlignItems::Center,
padding: UiRect {
left: VAL_SPACE_3,
right: VAL_SPACE_3,
top: VAL_SPACE_2,
bottom: VAL_SPACE_2,
},
..default()
},
BackgroundColor(BG_HUD_BAND),
ZIndex(Z_HUD),
SafeAreaAnchoredBottom { base_bottom: 0.0 },
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,
);
});
}
/// 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.
///
/// `order` is the button's index inside the action bar (0 for the
/// leftmost). It propagates into the [`Focusable`] this function inserts
/// so Phase 2's keyboard focus ring cycles the HUD in visual order.
///
/// `tooltip` is the hover-reveal caption attached via [`Tooltip`]. Every
/// action button ships with one — there is no opt-out — because each button
/// represents a player-triggered action and benefits from a one-line
/// reminder of what it does.
#[allow(clippy::too_many_arguments)]
pub(super) fn spawn_action_button<M: Component>(
row: &mut ChildSpawnerCommands,
marker: M,
label: &str,
hotkey: Option<&'static str>,
tooltip: &'static str,
font: &TextFont,
order: i32,
text_color: Color,
) {
// Hotkey hint chips ("U", "Esc", "F1", "N") are meaningless on a
// touch device — the button itself is the affordance — and they
// visibly clutter the narrow-viewport action row. The chevrons on
// Menu/Modes remain because they indicate dropdown behaviour.
let hotkey = if SHOW_KEYBOARD_ACCELERATORS {
hotkey
} else {
None
};
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,
Button,
Tooltip::new(tooltip),
Focusable {
group: FocusGroup::Hud,
order,
},
Node {
padding: pad,
min_width: min_w,
min_height: min_h,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
column_gap: VAL_SPACE_2,
..default()
},
BackgroundColor(ACTION_BTN_IDLE),
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|b| {
spawn_action_button_label(b, label, font, text_color);
if let Some(key) = hotkey {
// Hotkey hint rendered as a dim caption next to the label —
// keeps the keyboard accelerator discoverable without
// hijacking the button's primary affordance.
b.spawn((Text::new(key), hotkey_font, TextColor(TEXT_SECONDARY)));
}
});
}
+901
View File
@@ -0,0 +1,901 @@
use super::*;
use crate::auto_complete_plugin::AutoCompleteState;
use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin;
use chrono::Local;
use solitaire_core::{DrawStockConfig, game_state::GameState};
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(HudPlugin);
app.update();
app
}
#[test]
fn hud_plugin_registers_without_panic() {
let _app = headless_app();
}
#[test]
fn update_hud_runs_after_game_mutation_without_panic() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(42, DrawStockConfig::DrawOne);
app.update();
}
fn read_hud_text<M: Component>(app: &mut App) -> String {
app.world_mut()
.query_filtered::<&Text, With<M>>()
.iter(app.world())
.next()
.map(|t| t.0.clone())
.unwrap_or_default()
}
#[test]
fn score_reflects_game_state() {
let mut app = headless_app();
let score = app
.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_score(20);
app.update();
assert_eq!(
read_hud_text::<HudScore>(&mut app),
format!("Score: {score}")
);
}
#[test]
fn moves_reflects_game_state() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.set_test_move_count(42);
app.update();
assert_eq!(read_hud_text::<HudMoves>(&mut app), "Moves: 42");
}
#[test]
fn draw_three_mode_shows_draw_3_badge() {
use solitaire_core::game_state::GameMode;
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(42, DrawStockConfig::DrawThree, GameMode::Classic);
app.update();
assert_eq!(read_hud_text::<HudMode>(&mut app), "Draw 3");
}
#[test]
fn zen_mode_hides_score() {
use solitaire_core::game_state::GameMode;
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(42, DrawStockConfig::DrawOne, GameMode::Zen);
app.update();
// Zen mode spec: "No score display" → text must be empty.
assert_eq!(read_hud_text::<HudScore>(&mut app), "");
}
#[test]
fn time_display_uses_mm_ss_format() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.elapsed_seconds = 125;
app.update();
// 125 seconds = 2 minutes 5 seconds → "2:05"
assert_eq!(read_hud_text::<HudTime>(&mut app), "2:05");
}
// -----------------------------------------------------------------------
// format_time_limit (pure function)
// -----------------------------------------------------------------------
#[test]
fn format_time_limit_300_is_5_00() {
assert_eq!(format_time_limit(300), "5:00");
}
#[test]
fn format_time_limit_zero() {
assert_eq!(format_time_limit(0), "0:00");
}
#[test]
fn format_time_limit_pads_seconds() {
assert_eq!(format_time_limit(65), "1:05");
}
// -----------------------------------------------------------------------
// challenge_hud_text (pure function)
// -----------------------------------------------------------------------
#[test]
fn challenge_hud_text_shows_time_limit() {
let dc = DailyChallengeResource {
date: Local::now().date_naive(),
seed: 1,
goal_description: None,
target_score: None,
max_time_secs: Some(300),
};
assert_eq!(challenge_hud_text(&dc), "Limit: 5:00");
}
#[test]
fn challenge_hud_text_shows_score_goal() {
let dc = DailyChallengeResource {
date: Local::now().date_naive(),
seed: 1,
goal_description: None,
target_score: Some(4000),
max_time_secs: None,
};
assert_eq!(challenge_hud_text(&dc), "Goal: 4000 pts");
}
#[test]
fn challenge_hud_text_empty_when_no_constraints() {
let dc = DailyChallengeResource {
date: Local::now().date_naive(),
seed: 1,
goal_description: None,
target_score: None,
max_time_secs: None,
};
assert_eq!(challenge_hud_text(&dc), "");
}
#[test]
fn challenge_time_color_above_60_is_info() {
let c = challenge_time_color(61);
assert_eq!(c, STATE_INFO);
}
#[test]
fn challenge_time_color_exactly_60_is_info() {
let c = challenge_time_color(60);
assert_eq!(c, STATE_INFO);
}
#[test]
fn challenge_time_color_59_is_warning() {
let c = challenge_time_color(59);
assert_eq!(c, STATE_WARNING);
}
#[test]
fn challenge_time_color_30_is_warning() {
let c = challenge_time_color(30);
assert_eq!(c, STATE_WARNING);
}
#[test]
fn challenge_time_color_29_is_danger() {
let c = challenge_time_color(29);
assert_eq!(c, STATE_DANGER);
}
#[test]
fn challenge_time_color_zero_is_danger() {
let c = challenge_time_color(0);
assert_eq!(c, STATE_DANGER);
}
// -----------------------------------------------------------------------
// HudChallenge in-app tests
// -----------------------------------------------------------------------
#[test]
fn challenge_hud_empty_when_no_daily_resource() {
// No DailyChallengeResource inserted → HudChallenge must be empty.
let mut app = headless_app();
app.world_mut()
.resource_mut::<GameStateResource>()
.set_changed();
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
}
#[test]
fn challenge_hud_shows_time_limit_when_resource_present() {
let mut app = headless_app();
app.world_mut().insert_resource(DailyChallengeResource {
date: Local::now().date_naive(),
seed: 42,
goal_description: Some("Win fast".to_string()),
target_score: None,
max_time_secs: Some(300),
});
app.world_mut()
.resource_mut::<GameStateResource>()
.set_changed();
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00");
}
#[test]
fn challenge_hud_shows_score_goal_when_resource_present() {
let mut app = headless_app();
app.world_mut().insert_resource(DailyChallengeResource {
date: Local::now().date_naive(),
seed: 42,
goal_description: None,
target_score: Some(4000),
max_time_secs: None,
});
app.world_mut()
.resource_mut::<GameStateResource>()
.set_changed();
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts");
}
#[test]
fn challenge_hud_clears_on_win() {
let mut app = headless_app();
app.world_mut().insert_resource(DailyChallengeResource {
date: Local::now().date_naive(),
seed: 42,
goal_description: None,
target_score: None,
max_time_secs: Some(300),
});
// Mark the game as won — HudChallenge should be empty.
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.set_test_won(true);
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
}
// -----------------------------------------------------------------------
// HudUndos in-app tests
// -----------------------------------------------------------------------
#[test]
fn undos_hud_empty_at_game_start() {
let mut app = headless_app();
app.update();
assert_eq!(read_hud_text::<HudUndos>(&mut app), "");
}
#[test]
fn undos_hud_shows_count_after_undo() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_undos(3);
app.update();
assert_eq!(read_hud_text::<HudUndos>(&mut app), "Undos: 3");
}
// -----------------------------------------------------------------------
// HudAutoComplete in-app tests (Task #56)
// -----------------------------------------------------------------------
fn headless_app_with_auto_complete() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(HudPlugin);
app.init_resource::<AutoCompleteState>();
app.update();
app
}
#[test]
fn auto_complete_badge_shows_auto_when_active() {
let mut app = headless_app_with_auto_complete();
app.world_mut().resource_mut::<AutoCompleteState>().active = true;
// Also trigger game state change so the update fires.
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.set_test_move_count(1);
app.update();
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "AUTO");
}
#[test]
fn auto_complete_badge_empty_when_inactive() {
let mut app = headless_app_with_auto_complete();
// active is false by default.
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.set_test_move_count(1);
app.update();
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "");
}
// -----------------------------------------------------------------------
// HudRecycles in-app tests
// -----------------------------------------------------------------------
#[test]
fn recycles_hud_hidden_when_zero_in_draw_one_mode() {
let mut app = headless_app();
// Draw-One, no recycles yet — text must be empty.
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(42, DrawStockConfig::DrawOne);
app.update();
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "");
}
#[test]
fn recycles_hud_hidden_when_zero_in_draw_three_mode() {
let mut app = headless_app();
// Draw-Three, no recycles yet — text must also be empty.
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(42, DrawStockConfig::DrawThree);
app.update();
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "");
}
#[test]
fn recycles_hud_shows_count_draw_three() {
let mut app = headless_app();
let mut gs = GameState::new(42, DrawStockConfig::DrawThree);
gs.force_test_recycles(3);
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
app.update();
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 3");
}
#[test]
fn recycles_hud_shows_count_draw_one() {
let mut app = headless_app();
// Draw-One with recycle_count > 0 must now show the counter too.
let mut gs = GameState::new(42, DrawStockConfig::DrawOne);
gs.force_test_recycles(2);
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
app.update();
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 2");
}
// -----------------------------------------------------------------------
// Score-change feedback (G2)
// -----------------------------------------------------------------------
/// Tells `TimePlugin` to advance by `secs` on every subsequent
/// `app.update()`. Mirrors the helper in `ui_modal::tests`; kept
/// local to avoid coupling the two test modules.
fn set_manual_time_step(app: &mut App, secs: f32) {
use bevy::time::TimeUpdateStrategy;
use std::time::Duration;
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
secs,
)));
}
/// Counts entities matching component `M` currently in the world.
fn count_with<M: Component>(app: &mut App) -> usize {
app.world_mut().query::<&M>().iter(app.world()).count()
}
/// A score jump ≥ `SCORE_FLOATER_THRESHOLD` spawns a floating
/// `ScoreFloater` entity coloured `ACCENT_PRIMARY`. The pulse
/// component is also inserted on the score readout — both signals
/// fire from the same delta detection.
#[test]
fn score_increase_above_threshold_spawns_floater_in_accent_primary() {
let mut app = headless_app();
// Pin `Time::delta_secs()` to 0 so the floater's RGB and alpha
// can be asserted exactly: with Automatic strategy a few ms
// of wall-clock time leaks in between updates and the alpha
// drifts below 1.0 by `dt / lifetime`.
set_manual_time_step(&mut app, 0.0);
// Initial state has score=0; bumping by 50 (the threshold)
// is the smallest jump that triggers the floater.
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_score(50);
app.update();
// One floater should now exist.
let count = count_with::<ScoreFloater>(&mut app);
assert_eq!(count, 1, "expected a single ScoreFloater for a +50 jump");
// Its TextColor must be ACCENT_PRIMARY at full alpha. The
// detect system spawns the floater coloured ACCENT_PRIMARY
// and at dt=0 the first advance tick leaves alpha = 1.0.
let world = app.world_mut();
let mut q = world.query::<(&ScoreFloater, &TextColor)>();
let (_floater, color) = q.iter(world).next().expect("floater missing TextColor");
assert_eq!(color.0, ACCENT_PRIMARY);
}
/// After enough time for `MOTION_SCORE_PULSE_SECS * 2` to elapse
/// the floater has reached the end of its lifetime and despawned.
#[test]
fn score_floater_despawns_after_full_lifetime() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_score(50);
app.update();
assert_eq!(count_with::<ScoreFloater>(&mut app), 1);
// Advance by a delta well past the floater's lifetime — the
// single oversized tick clamps t at 1.0 and the entity is
// despawned in the same `Update`.
set_manual_time_step(&mut app, MOTION_SCORE_PULSE_SECS * 2.0 * 2.0 + 0.1);
app.update();
app.update(); // first update propagates the new strategy; second runs the system with non-zero dt.
assert_eq!(
count_with::<ScoreFloater>(&mut app),
0,
"floater should have despawned after its full lifetime"
);
}
/// A small score change (below the threshold) inserts a pulse on
/// the readout but never spawns a floater — keeping the floating
/// "+N" reserved for meaningful score jumps.
#[test]
fn score_increase_below_threshold_does_not_spawn_floater() {
let mut app = headless_app();
// +5 mirrors a single tableau-to-foundation move; well below
// the 50-point threshold so the floater path stays dormant.
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_score(5);
app.update();
assert_eq!(
count_with::<ScoreFloater>(&mut app),
0,
"delta of +5 must not spawn a floater"
);
}
/// The triangular pulse curve hits its peak (1.1) at t=0.5 and
/// returns to 1.0 at the endpoints. Pure-function check that
/// guards the curve shape against future tweaks.
#[test]
fn score_pulse_scale_is_triangular() {
assert!((score_pulse_scale(0.0) - 1.0).abs() < 1e-6);
assert!((score_pulse_scale(0.5) - 1.1).abs() < 1e-6);
assert!((score_pulse_scale(1.0) - 1.0).abs() < 1e-6);
// Values outside [0,1] are clamped before the curve runs.
assert!((score_pulse_scale(-0.2) - 1.0).abs() < 1e-6);
assert!((score_pulse_scale(2.0) - 1.0).abs() < 1e-6);
}
/// Streak flourish curve must be 1.0 at t=0, peak at t=0.5, and
/// return to 1.0 at t=duration. Mirrors the `foundation_flourish_scale`
/// curve test — the two animations share a triangular shape so a
/// future tweak that desyncs them shows up here.
#[test]
fn streak_flourish_scale_curves_through_one_one_one() {
let dur = MOTION_STREAK_FLOURISH_SECS;
assert!(
(streak_flourish_scale(0.0, dur) - 1.0).abs() < 1e-5,
"streak flourish scale at t=0 must be 1.0",
);
assert!(
(streak_flourish_scale(dur / 2.0, dur) - STREAK_FLOURISH_PEAK_SCALE).abs() < 1e-5,
"streak flourish scale at midpoint must be STREAK_FLOURISH_PEAK_SCALE",
);
assert!(
(streak_flourish_scale(dur, dur) - 1.0).abs() < 1e-5,
"streak flourish scale at t=duration must return to 1.0",
);
}
/// Out-of-range values are clamped, not extrapolated. Matches the
/// foundation flourish's clamp behaviour so the score readout never
/// freezes at a non-1.0 scale on the frame after the flourish ends.
#[test]
fn streak_flourish_scale_clamps_out_of_range() {
let dur = MOTION_STREAK_FLOURISH_SECS;
assert!((streak_flourish_scale(-1.0, dur) - 1.0).abs() < 1e-5);
assert!((streak_flourish_scale(dur * 5.0, dur) - 1.0).abs() < 1e-5);
}
/// Zero duration (e.g. `AnimSpeed::Instant`) returns identity, never
/// divides by zero.
#[test]
fn streak_flourish_scale_zero_duration_is_one() {
assert!((streak_flourish_scale(0.0, 0.0) - 1.0).abs() < 1e-5);
assert!((streak_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
}
// -----------------------------------------------------------------------
// Reduce-motion gates — ScorePulse, ScoreFloater, StreakFlourish
// -----------------------------------------------------------------------
/// Under `Settings::reduce_motion_mode`, a score bump must NOT spawn
/// a `ScorePulse` on the readout or a `ScoreFloater` on the stage.
#[test]
fn score_change_skips_pulse_and_floater_under_reduce_motion() {
use solitaire_data::Settings;
let mut app = headless_app();
app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true,
..Settings::default()
}));
// +100 would normally create both a ScorePulse and a ScoreFloater.
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_score(50);
app.update();
assert_eq!(
count_with::<ScorePulse>(&mut app),
0,
"ScorePulse must not spawn under reduce-motion"
);
assert_eq!(
count_with::<ScoreFloater>(&mut app),
0,
"ScoreFloater must not spawn under reduce-motion"
);
}
// -----------------------------------------------------------------------
// Phase 2: keyboard focus ring — HUD action bar
// -----------------------------------------------------------------------
/// Returns the `Focusable` carried by the unique entity matching
/// marker `M`. Helper for the HUD focus tests.
fn focusable_for<M: Component>(app: &mut App) -> Focusable {
app.world_mut()
.query_filtered::<&Focusable, With<M>>()
.iter(app.world())
.next()
.copied()
.unwrap_or_else(|| panic!("no Focusable on the {} button", std::any::type_name::<M>()))
}
#[test]
fn hud_buttons_get_focusable_marker() {
let mut app = headless_app();
// Every action-bar button is in `FocusGroup::Hud`.
for f in [
focusable_for::<MenuButton>(&mut app),
focusable_for::<UndoButton>(&mut app),
focusable_for::<PauseButton>(&mut app),
focusable_for::<HelpButton>(&mut app),
focusable_for::<HintButton>(&mut app),
focusable_for::<ModesButton>(&mut app),
focusable_for::<NewGameButton>(&mut app),
] {
assert_eq!(
f.group,
FocusGroup::Hud,
"every HUD action button must be in FocusGroup::Hud"
);
}
}
/// Returns the tooltip string carried by the unique entity matching
/// marker `M`. Panics if zero or more than one such entity exists,
/// which is the invariant we want to enforce for HUD readouts and
/// action buttons (each marker is spawned exactly once).
fn tooltip_for<M: Component>(app: &mut App) -> String {
let mut q = app.world_mut().query_filtered::<&Tooltip, With<M>>();
let world = app.world();
let mut iter = q.iter(world);
let first = iter
.next()
.unwrap_or_else(|| {
panic!(
"expected a Tooltip on the {} entity",
std::any::type_name::<M>()
)
})
.0
.clone()
.into_owned();
assert!(
iter.next().is_none(),
"expected exactly one Tooltip-bearing entity for {}",
std::any::type_name::<M>()
);
first
}
/// Every HUD readout and action button must spawn with a `Tooltip`
/// carrying the approved canonical microcopy. Mirrors the structure
/// of `hud_buttons_get_focusable_marker` (Phase 2 focus test) so the
/// invariant — one marker entity, one tooltip, exact text — is
/// asserted consistently across every element.
#[test]
fn hud_elements_carry_expected_tooltip_strings() {
let mut app = headless_app();
// HUD readouts (left column, top to bottom).
assert_eq!(
tooltip_for::<HudScore>(&mut app),
"Points earned this game. Hidden in Zen mode."
);
assert_eq!(
tooltip_for::<HudMoves>(&mut app),
"Moves you've made this game. Counts placements and stock draws."
);
assert_eq!(
tooltip_for::<HudTime>(&mut app),
"Time on this game. Counts down in Time Attack."
);
assert_eq!(
tooltip_for::<HudMode>(&mut app),
"Active game mode. Click Modes to switch."
);
assert_eq!(
tooltip_for::<HudChallenge>(&mut app),
"Today's daily challenge target. Beat it for bonus XP."
);
assert_eq!(
tooltip_for::<HudDrawCycle>(&mut app),
"Cards drawn on the next stock click in Draw-Three."
);
assert_eq!(
tooltip_for::<HudUndos>(&mut app),
"Undos used this game. Any undo blocks the No Undo achievement."
);
assert_eq!(
tooltip_for::<HudRecycles>(&mut app),
"Times you've recycled the stock. Three or more unlocks Comeback."
);
assert_eq!(
tooltip_for::<HudAutoComplete>(&mut app),
"Board is solvable from here. Press Enter to auto-finish."
);
assert_eq!(
tooltip_for::<HudSelection>(&mut app),
"Pile selected with Tab. Use arrows or Enter to act."
);
// Action bar (left to right).
assert_eq!(
tooltip_for::<MenuButton>(&mut app),
"Open Stats, Achievements, Profile, Settings, or Leaderboard."
);
assert_eq!(
tooltip_for::<UndoButton>(&mut app),
"Take back your last move. Costs points and blocks No Undo."
);
assert_eq!(
tooltip_for::<PauseButton>(&mut app),
"Pause the game and freeze the timer."
);
assert_eq!(
tooltip_for::<HelpButton>(&mut app),
"Show controls, rules, and keyboard shortcuts."
);
assert_eq!(
tooltip_for::<HintButton>(&mut app),
"Highlight a suggested move. Cycles through alternatives on repeat taps."
);
assert_eq!(
tooltip_for::<ModesButton>(&mut app),
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack."
);
assert_eq!(
tooltip_for::<NewGameButton>(&mut app),
"Start a fresh deal. Confirms first if a game is in progress."
);
}
/// Every interior row of the Modes and Menu popovers must carry a
/// `Tooltip`. The popovers open from action-bar buttons whose own
/// tooltips are already covered above; this test extends the
/// invariant inward so hover discoverability is uniform across the
/// HUD's nested controls.
///
/// We invoke the popover spawn helpers directly with a maxed-out
/// `ProgressResource` and a `DailyChallengeResource` so every row
/// branch fires (Classic, Daily, Zen, Challenge, Time Attack).
/// Headless click simulation isn't needed — the contract under
/// test is "every popover row spawns with a tooltip", which is a
/// property of the spawn helpers themselves.
#[test]
fn popover_rows_carry_tooltip_strings() {
use crate::progress_plugin::ProgressResource;
use solitaire_sync::progress::PlayerProgress;
let mut app = headless_app();
// Force every mode row to render: level past the challenge
// unlock threshold, plus a daily challenge resource so the
// Daily row appears.
let progress = ProgressResource(PlayerProgress {
level: CHALLENGE_UNLOCK_LEVEL,
..Default::default()
});
let daily = DailyChallengeResource {
date: Local::now().date_naive(),
seed: 1,
goal_description: None,
target_score: None,
max_time_secs: None,
};
// Spawn both popovers via their helpers. Mirrors how the click
// handlers invoke them in production — we just skip the click.
{
let world = app.world_mut();
let mut commands = world.commands();
spawn_modes_popover(&mut commands, Some(&progress), Some(&daily), None);
spawn_menu_popover(&mut commands, None);
world.flush();
}
app.update();
// Every ModeOption-tagged entity must also carry a Tooltip,
// and the count must match the five canonical modes.
let mut mode_q = app
.world_mut()
.query_filtered::<&Tooltip, With<ModeOption>>();
let mode_tooltips: Vec<String> = mode_q
.iter(app.world())
.map(|t| t.0.clone().into_owned())
.collect();
assert_eq!(
mode_tooltips.len(),
5,
"expected a tooltip on each of the 5 mode rows, got {}",
mode_tooltips.len()
);
// Every approved mode tooltip string must be present somewhere
// among the ModeOption rows. Order isn't asserted — the spawn
// order test elsewhere already covers that.
for expected in [
"Standard Klondike. Score, timer, and full progression.",
"Today's seeded deal. Same for every player worldwide.",
"No timer, no score, no penalties. Just play.",
"Hand-picked hard seeds. No undo allowed.",
"Win as many games as you can in ten minutes.",
] {
assert!(
mode_tooltips.iter().any(|s| s == expected),
"missing mode tooltip: {expected:?}"
);
}
// Same contract for MenuOption rows: seven entries, each with a
// tooltip, exact strings matching the approved microcopy.
let mut menu_q = app
.world_mut()
.query_filtered::<&Tooltip, With<MenuOption>>();
let menu_tooltips: Vec<String> = menu_q
.iter(app.world())
.map(|t| t.0.clone().into_owned())
.collect();
assert_eq!(
menu_tooltips.len(),
7,
"expected a tooltip on each of the 7 menu rows, got {}",
menu_tooltips.len()
);
for expected in [
"Pick a mode, continue, or start a new game.",
"Show controls, rules, and keyboard shortcuts.",
"Lifetime totals: wins, streaks, fastest time, best score.",
"Browse unlocked achievements and the rewards still ahead.",
"Your level, XP progress, and sync status.",
"Audio, animations, theme, draw mode, and sync.",
"Top players from your sync server. Opt in from Profile.",
] {
assert!(
menu_tooltips.iter().any(|s| s == expected),
"missing menu tooltip: {expected:?}"
);
}
}
#[test]
fn hud_button_order_matches_spawn_order() {
let mut app = headless_app();
// Visual reading order (left → right): Menu, Undo, Pause, Help,
// Hint, Modes, New Game. Their `order` fields must be 0..=6 in
// that order so Tab cycles them as the player reads them.
assert_eq!(focusable_for::<MenuButton>(&mut app).order, 0);
assert_eq!(focusable_for::<UndoButton>(&mut app).order, 1);
assert_eq!(focusable_for::<PauseButton>(&mut app).order, 2);
assert_eq!(focusable_for::<HelpButton>(&mut app).order, 3);
assert_eq!(focusable_for::<HintButton>(&mut app).order, 4);
assert_eq!(focusable_for::<ModesButton>(&mut app).order, 5);
assert_eq!(focusable_for::<NewGameButton>(&mut app).order, 6);
}
#[test]
fn hud_focus_only_engages_when_button_hovered() {
// Phase 2 declares membership in `FocusGroup::Hud`; the
// engagement rule lives in `handle_focus_keys`. Two halves to
// this test:
// (a) no modal + no hover ⇒ Tab is a no-op (Phase 1 contract
// still holds when nothing is hovered).
// (b) no modal + a HUD button hovered ⇒ Tab advances
// `FocusedButton` to a Hud-grouped entity.
use crate::ui_focus::{FocusedButton, UiFocusPlugin};
use crate::ui_modal::UiModalPlugin;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(HudPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
// (a) Sanity: HUD buttons exist and are focusable, but no
// modal open and no hover ⇒ FocusedButton stays None.
assert!(
app.world().resource::<FocusedButton>().0.is_none(),
"no modal open, no auto-focus"
);
// Press Tab. With no modal and no hover, `handle_focus_keys`
// resolves no active group and returns early — Tab must not
// advance the HUD focus ring on its own.
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release_all();
input.clear();
input.press(KeyCode::Tab);
}
app.update();
assert!(
app.world().resource::<FocusedButton>().0.is_none(),
"Tab with no modal and no Hud hover must not engage the HUD focus ring"
);
// (b) Hover the Menu button — the leftmost HUD action — and
// Tab. The Hud-group cycle should pick a Hud-tagged entity.
let menu_entity = app
.world_mut()
.query_filtered::<Entity, With<MenuButton>>()
.iter(app.world())
.next()
.expect("MenuButton entity should exist");
app.world_mut()
.entity_mut(menu_entity)
.insert(Interaction::Hovered);
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release_all();
input.clear();
input.press(KeyCode::Tab);
}
app.update();
let focused = app
.world()
.resource::<FocusedButton>()
.0
.expect("Tab with a HUD button hovered must engage the HUD focus ring");
// The focused entity must itself be Hud-grouped (i.e. one of
// the action-bar buttons), not anything else in the world.
let focusable = app
.world()
.entity(focused)
.get::<Focusable>()
.expect("focused entity must carry Focusable");
assert_eq!(
focusable.group,
FocusGroup::Hud,
"Hud-engaged Tab must focus a Hud-grouped entity"
);
}
+611
View File
@@ -0,0 +1,611 @@
//! Per-frame HUD text/typography/visibility updater systems.
use super::*;
use bevy::window::WindowResized;
use solitaire_core::Suit;
use solitaire_core::{DrawStockConfig, game_state::GameMode};
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::auto_complete_plugin::AutoCompleteState;
/// Formats a time-limit value in seconds as `"mm:ss"` for HUD display.
///
/// For example `format_time_limit(300)` returns `"5:00"`.
pub fn format_time_limit(secs: u64) -> String {
let m = secs / 60;
let s = secs % 60;
format!("{m}:{s:02}")
}
// ---------------------------------------------------------------------------
// Score-change feedback (G2)
//
// The flow for each Update tick:
// 1. `detect_score_change` diffs `GameStateResource.score` against
// `PreviousScore`. On any positive delta it inserts/refreshes
// `ScorePulse` on the score readout; on a delta ≥
// `SCORE_FLOATER_THRESHOLD` it also spawns a floating "+N" UI text
// anchored just below the score.
// 2. `advance_score_pulse` ticks the pulse component, applies the
// triangular 1.0 → 1.1 → 1.0 scale curve, and removes the
// component on completion.
// 3. `advance_score_floater` drifts each floater upward, fades it to
// transparent, and despawns it when its lifetime expires.
//
// The threshold of 50 (a foundation promotion's typical bonus) keeps
// floaters rare and meaningful — see `SCORE_FLOATER_THRESHOLD`.
// ---------------------------------------------------------------------------
/// Sets the [`HudWonPreviously`] text to "✓ Won before" whenever the
/// current deal's seed + draw_mode + mode triple matches an entry in
/// the rolling [`ReplayHistory`]. Cleared while the active game is won
/// (the on-screen "Game won!" cue already conveys victory) and on
/// fresh deals the player hasn't won before.
///
/// Lives in its own system rather than `update_hud` to keep this
/// orthogonal: `update_hud`'s query disambiguation is already busy
/// enough; threading another marker through every Without filter
/// would touch ~10 unrelated queries for no benefit.
pub(super) fn update_won_previously(
game: Res<GameStateResource>,
// Optional because the HUD plugin's headless tests run without
// `StatsPlugin` and therefore without this resource. With the
// resource absent there's no history to compare against; the
// indicator just stays empty.
history: Option<Res<crate::stats_plugin::ReplayHistoryResource>>,
mut q: Query<&mut Text, With<HudWonPreviously>>,
) {
let Ok(mut text) = q.single_mut() else {
return;
};
let won_before = !game.0.is_won()
&& history.as_ref().is_some_and(|h| {
h.0.replays.iter().any(|r| {
r.seed == game.0.seed && r.draw_mode == game.0.draw_mode() && r.mode == game.0.mode
})
});
let next = if won_before {
"\u{2713} Won before"
} else {
""
};
if text.0 != next {
text.0 = next.to_string();
}
}
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub(super) fn update_hud(
game: Res<GameStateResource>,
time_attack: Option<Res<TimeAttackResource>>,
daily: Option<Res<DailyChallengeResource>>,
auto_complete: Option<Res<AutoCompleteState>>,
mut score_q: Query<
&mut Text,
(
With<HudScore>,
Without<HudMoves>,
Without<HudTime>,
Without<HudMode>,
Without<HudChallenge>,
Without<HudUndos>,
Without<HudAutoComplete>,
Without<HudRecycles>,
Without<HudDrawCycle>,
Without<HudSelection>,
),
>,
mut moves_q: Query<
&mut Text,
(
With<HudMoves>,
Without<HudScore>,
Without<HudTime>,
Without<HudMode>,
Without<HudChallenge>,
Without<HudUndos>,
Without<HudAutoComplete>,
Without<HudRecycles>,
Without<HudDrawCycle>,
Without<HudSelection>,
),
>,
mut time_q: Query<
&mut Text,
(
With<HudTime>,
Without<HudScore>,
Without<HudMoves>,
Without<HudMode>,
Without<HudChallenge>,
Without<HudUndos>,
Without<HudAutoComplete>,
Without<HudRecycles>,
Without<HudDrawCycle>,
Without<HudSelection>,
),
>,
mut mode_q: Query<
&mut Text,
(
With<HudMode>,
Without<HudScore>,
Without<HudMoves>,
Without<HudTime>,
Without<HudChallenge>,
Without<HudUndos>,
Without<HudAutoComplete>,
Without<HudRecycles>,
Without<HudDrawCycle>,
Without<HudSelection>,
),
>,
mut challenge_q: Query<
(&mut Text, &mut TextColor),
(
With<HudChallenge>,
Without<HudScore>,
Without<HudMoves>,
Without<HudTime>,
Without<HudMode>,
Without<HudUndos>,
Without<HudAutoComplete>,
Without<HudRecycles>,
Without<HudDrawCycle>,
Without<HudSelection>,
),
>,
mut undos_q: Query<
(&mut Text, &mut TextColor),
(
With<HudUndos>,
Without<HudScore>,
Without<HudMoves>,
Without<HudTime>,
Without<HudMode>,
Without<HudChallenge>,
Without<HudAutoComplete>,
Without<HudRecycles>,
Without<HudDrawCycle>,
Without<HudSelection>,
),
>,
mut auto_q: Query<
&mut Text,
(
With<HudAutoComplete>,
Without<HudScore>,
Without<HudMoves>,
Without<HudTime>,
Without<HudMode>,
Without<HudChallenge>,
Without<HudUndos>,
Without<HudRecycles>,
Without<HudDrawCycle>,
Without<HudSelection>,
),
>,
mut recycles_q: Query<
&mut Text,
(
With<HudRecycles>,
Without<HudScore>,
Without<HudMoves>,
Without<HudTime>,
Without<HudMode>,
Without<HudChallenge>,
Without<HudUndos>,
Without<HudAutoComplete>,
Without<HudDrawCycle>,
Without<HudSelection>,
),
>,
mut draw_cycle_q: Query<
&mut Text,
(
With<HudDrawCycle>,
Without<HudScore>,
Without<HudMoves>,
Without<HudTime>,
Without<HudMode>,
Without<HudChallenge>,
Without<HudUndos>,
Without<HudAutoComplete>,
Without<HudRecycles>,
Without<HudSelection>,
),
>,
) {
let ta_active = time_attack.as_ref().is_some_and(|ta| ta.active);
// Score, moves, mode, challenge, and undos only need updating when game state changes.
if game.is_changed() {
let g = &game.0;
let is_zen = g.mode == GameMode::Zen;
if let Ok(mut t) = score_q.single_mut() {
// Zen mode suppresses score display per spec ("No score display").
**t = if is_zen {
String::new()
} else {
format!("Score: {}", g.score())
};
}
if let Ok(mut t) = moves_q.single_mut() {
**t = format!("Moves: {}", g.move_count());
}
if let Ok(mut t) = mode_q.single_mut() {
**t = match g.mode {
GameMode::Classic => match g.draw_mode() {
DrawStockConfig::DrawOne => String::new(),
DrawStockConfig::DrawThree => "Draw 3".to_string(),
},
GameMode::Zen => "ZEN".to_string(),
GameMode::Challenge => "CHALLENGE".to_string(),
GameMode::TimeAttack => "TIME ATTACK".to_string(),
GameMode::Difficulty(level) => level.label().to_uppercase(),
};
}
// --- Daily challenge constraint (with time-low colour warning) ---
if let Ok((mut t, mut color)) = challenge_q.single_mut() {
if g.is_won() {
**t = String::new();
} else if let Some(dc) = daily.as_deref() {
**t = challenge_hud_text(dc);
if let Some(max_secs) = dc.max_time_secs {
let remaining = max_secs.saturating_sub(g.elapsed_seconds);
*color = TextColor(challenge_time_color(remaining));
}
} else {
**t = String::new();
}
}
// --- Undo count ---
if let Ok((mut t, mut color)) = undos_q.single_mut() {
let count = g.undo_count();
if count == 0 {
**t = String::new();
*color = TextColor(TEXT_PRIMARY);
} else {
**t = format!("Undos: {count}");
// STATE_WARNING signals "you took a penalty" — same hue
// as the Recycles counter so they read as one category.
*color = TextColor(STATE_WARNING);
}
}
// --- Recycle counter (both modes, hidden until first recycle) ---
if let Ok(mut t) = recycles_q.single_mut() {
**t = if g.recycle_count() > 0 {
format!("Recycles: {}", g.recycle_count())
} else {
String::new()
};
}
// --- Draw-cycle indicator (Draw-Three mode only) ---
if let Ok(mut t) = draw_cycle_q.single_mut() {
**t = if g.is_won() || g.draw_mode() != DrawStockConfig::DrawThree {
// Hide when not in Draw-Three or after the game is won.
String::new()
} else {
let stock_len = g.stock_cards().len();
let next_draw = stock_len.min(3);
format!("Cycle: {next_draw}/3")
};
}
}
// Time display: show Time Attack countdown every frame when active;
// Zen mode suppresses the timer per spec ("No timer") — cleared unconditionally
// every frame so it disappears immediately on the frame Z is pressed.
// Otherwise show game elapsed time (updates once per second via game.is_changed()).
let is_zen = game.0.mode == GameMode::Zen;
let update_time = (ta_active || game.is_changed()) && !is_zen;
if update_time {
if let Ok(mut t) = time_q.single_mut() {
if let Some(ta) = time_attack.as_ref().filter(|ta| ta.active) {
let remaining = ta.remaining_secs.max(0.0) as u64;
let m = remaining / 60;
let s = remaining % 60;
**t = format!("{m}:{s:02}");
} else {
let secs = game.0.elapsed_seconds;
let m = secs / 60;
let s = secs % 60;
**t = format!("{m}:{s:02}");
}
}
} else if is_zen {
// Clear the time display immediately whenever Zen mode is active —
// do not guard on game.is_changed() so it clears on the same frame
// the player presses Z, before any move is made.
if let Ok(mut t) = time_q.single_mut() {
**t = String::new();
}
}
// --- Auto-complete badge ---
// Reflects the AutoCompleteState resource; update whenever it changes or game changes.
let ac_active = auto_complete.as_ref().is_some_and(|ac| ac.active);
let ac_changed = auto_complete.as_ref().is_some_and(|ac| ac.is_changed());
if (ac_changed || game.is_changed())
&& let Ok(mut t) = auto_q.single_mut()
{
**t = if ac_active {
"AUTO".to_string()
} else {
String::new()
};
}
}
/// Updates the `HudSelection` text node to show which pile is Tab-selected.
///
/// Displays `"▶ {pile_name}"` while `SelectionState::selected_pile` is `Some`,
/// or an empty string when no pile is selected. Runs every frame so the
/// indicator stays in sync with the selection resource.
pub(super) fn update_selection_hud(
selection: Option<Res<SelectionState>>,
game: Option<Res<GameStateResource>>,
mut q: Query<&mut Text, With<HudSelection>>,
) {
let Ok(mut t) = q.single_mut() else { return };
let label = match selection.as_deref().and_then(|s| s.selected_pile.as_ref()) {
None => String::new(),
Some(KlondikePile::Stock) => "▶ Waste".to_string(),
Some(KlondikePile::Foundation(slot)) => match game.as_deref() {
Some(g) => foundation_selection_label(*slot, &g.0),
// No game resource means we can't probe claimed_suit; show the
// slot-based placeholder so the HUD still surfaces the selection.
None => format!("▶ Foundation {}", foundation_number(*slot)),
},
Some(KlondikePile::Tableau(idx)) => format!("▶ Column {}", tableau_number(*idx)),
};
**t = label;
}
/// Returns the HUD selection label for a foundation slot.
///
/// When the slot has a claimed suit (any card has landed) the announcement is
/// "▶ {Suit} Foundation"; while the slot is empty it falls back to a
/// "▶ Foundation N" placeholder labelled by the 1-based slot index.
pub(super) fn foundation_selection_label(
slot: Foundation,
game: &solitaire_core::game_state::GameState,
) -> String {
let claimed = game
.pile(KlondikePile::Foundation(slot))
.first()
.map(|c| c.0.suit());
match claimed {
Some(suit) => {
let s = match suit {
Suit::Clubs => "Clubs",
Suit::Diamonds => "Diamonds",
Suit::Hearts => "Hearts",
Suit::Spades => "Spades",
};
format!("{s} Foundation")
}
None => format!("▶ Foundation {}", foundation_number(slot)),
}
}
const fn foundation_number(foundation: Foundation) -> u8 {
match foundation {
Foundation::Foundation1 => 1,
Foundation::Foundation2 => 2,
Foundation::Foundation3 => 3,
Foundation::Foundation4 => 4,
}
}
const fn tableau_number(tableau: Tableau) -> u8 {
match tableau {
Tableau::Tableau1 => 1,
Tableau::Tableau2 => 2,
Tableau::Tableau3 => 3,
Tableau::Tableau4 => 4,
Tableau::Tableau5 => 5,
Tableau::Tableau6 => 6,
Tableau::Tableau7 => 7,
}
}
/// Fires `InfoToastEvent("Auto-completing...")` exactly once each time
/// `AutoCompleteState` transitions from inactive to active. Uses a `Local<bool>`
/// to debounce so the toast only appears on the leading edge.
pub(super) fn announce_auto_complete(
auto_complete: Option<Res<AutoCompleteState>>,
mut toast: MessageWriter<InfoToastEvent>,
mut was_active: Local<bool>,
) {
let now_active = auto_complete.as_ref().is_some_and(|ac| ac.active);
if now_active && !*was_active {
toast.write(InfoToastEvent("Auto-completing...".to_string()));
}
*was_active = now_active;
}
/// Builds the HUD text for the active daily challenge constraints.
///
/// Returns `"Limit: mm:ss"` when a time limit is set, `"Goal: N pts"` when a
/// score target is set, or an empty string when the challenge has no extra
/// constraints.
pub(super) fn challenge_hud_text(dc: &DailyChallengeResource) -> String {
if let Some(secs) = dc.max_time_secs {
format!("Limit: {}", format_time_limit(secs))
} else if let Some(score) = dc.target_score {
format!("Goal: {score} pts")
} else {
String::new()
}
}
/// Returns the colour for the challenge time-limit HUD label based on
/// seconds remaining. Uses theme tokens so the urgency ramp picks up
/// palette changes for free.
///
/// | Remaining | Token |
/// |-------------|------------------|
/// | ≥ 60 s | `STATE_INFO` |
/// | 30 59 s | `STATE_WARNING` |
/// | < 30 s | `STATE_DANGER` |
pub fn challenge_time_color(remaining: u64) -> Color {
if remaining < 30 {
STATE_DANGER
} else if remaining < 60 {
STATE_WARNING
} else {
STATE_INFO
}
}
/// Scales HUD Tier-1 font sizes to fit a narrow viewport.
///
/// Fires on every `WindowResized` event. Below 480 logical pixels wide the
/// score drops from `TYPE_HEADLINE` (26 px) to `TYPE_BODY_LG` (18 px) and the
/// Moves/Timer labels drop from `TYPE_BODY_LG` to `TYPE_CAPTION` (11 px), so
/// all three items remain on one row inside the 50 %-wide HUD column
/// (≈ 180 dp on a 360 dp phone). At ≥ 480 px the original sizes are
/// restored so desktop/tablet layouts are unaffected.
type HudScoreFont<'w, 's> =
Query<'w, 's, &'static mut TextFont, (With<HudScore>, Without<HudMoves>, Without<HudTime>)>;
type HudMovesFont<'w, 's> =
Query<'w, 's, &'static mut TextFont, (With<HudMoves>, Without<HudScore>, Without<HudTime>)>;
type HudTimeFont<'w, 's> =
Query<'w, 's, &'static mut TextFont, (With<HudTime>, Without<HudScore>, Without<HudMoves>)>;
pub(super) fn update_hud_typography(
mut events: MessageReader<WindowResized>,
mut score_q: HudScoreFont,
mut moves_q: HudMovesFont,
mut time_q: HudTimeFont,
) {
let Some(ev) = events.read().last() else {
return;
};
let (score_size, secondary_size) = if ev.width < 480.0 {
(TYPE_BODY_LG, TYPE_CAPTION)
} else {
(TYPE_HEADLINE, TYPE_BODY_LG)
};
for mut font in &mut score_q {
font.font_size = score_size;
}
for mut font in &mut moves_q {
font.font_size = secondary_size;
}
for mut font in &mut time_q {
font.font_size = secondary_size;
}
}
pub(super) fn apply_hud_visibility(
hud_vis: Res<HudVisibility>,
mut action_bar: Query<&mut Visibility, With<HudActionBar>>,
) {
if !hud_vis.is_changed() {
return;
}
let v = if *hud_vis == HudVisibility::Visible {
Visibility::Visible
} else {
Visibility::Hidden
};
for mut vis in &mut action_bar {
*vis = v;
}
// The bottom action bar is a pure overlay — it does not claim any
// space in the card layout, so no WindowResized event is needed.
}
pub(super) fn restore_hud_on_modal(
new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
mut hud_vis: ResMut<HudVisibility>,
) {
if !new_scrims.is_empty() {
*hud_vis = HudVisibility::Visible;
}
}
/// Returns the action-bar label font size for a given logical window width.
pub(super) fn action_bar_font_size(window_width: f32) -> f32 {
if USE_TOUCH_UI_LAYOUT {
// Seven word-labels ("Menu","Undo","Pause","Help","Hint","Mode","New")
// must share one row. The widest characters are in FiraMono (a
// monospace whose advance is ~0.62 of the font size). On a 900
// logical-px phone the row budget after bar padding (2*12) and six
// 4 px column gaps is ~852 px for ~28 label chars + 7*2*3 px button
// padding. Solving 28*0.62*size + 42 <= 852 gives size <= ~46, so the
// labels are advance-bound only on very narrow viewports; the real
// constraint is legibility, not fit. ~1/60 of the width yields ~15 px
// at 900 px — comfortably one row with margin to spare — clamped so it
// never drops below the 12 px legibility floor or grows past 18 px on
// landscape tablets where it would crowd the row again.
(window_width / 60.0).clamp(12.0, 18.0)
} else {
TYPE_BODY
}
}
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.
(
UiRect::axes(Val::Px(3.0), Val::Px(4.0)),
Val::Px(44.0),
Val::Px(44.0),
)
} else {
(
UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
Val::Px(48.0),
Val::Px(48.0),
)
}
}
pub(super) fn spawn_action_button_label(
parent: &mut ChildSpawnerCommands,
label: &str,
font: &TextFont,
text_color: Color,
) {
if USE_TOUCH_UI_LAYOUT {
parent.spawn((
ActionButtonLabel,
Text::new(label),
font.clone(),
TextColor(text_color),
));
} else {
parent.spawn((Text::new(label), font.clone(), TextColor(text_color)));
}
}
/// Resizes the glyph text inside every [`ActionButtonLabel`] to match the
/// current viewport width whenever [`LayoutResource`] changes (orientation
/// change or window resize).
#[cfg(target_os = "android")]
pub(super) fn resize_action_bar_labels(
layout: Res<crate::layout::LayoutResource>,
windows: Query<&Window>,
mut labels: Query<&mut TextFont, With<ActionButtonLabel>>,
) {
let w = windows
.iter()
.next()
.map_or(layout.0.card_size.x * 7.25, |win| win.width());
let new_size = action_bar_font_size(w);
for mut font in &mut labels {
font.font_size = new_size;
}
}
File diff suppressed because it is too large Load Diff
+819
View File
@@ -0,0 +1,819 @@
use super::*;
use crate::layout::compute_layout;
use solitaire_core::{Deck, Foundation, Rank, Suit, Tableau};
use solitaire_core::{DrawStockConfig, game_state::GameState};
fn clear_test_piles(game: &mut GameState) {
game.set_test_stock_cards(Vec::new());
game.set_test_waste_cards(Vec::new());
for foundation in [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
game.set_test_foundation_cards(foundation, Vec::new());
}
for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
game.set_test_tableau_cards(tableau, Vec::new());
}
}
#[test]
fn dragged_card_z_matches_resting_stack_step() {
assert!((dragged_card_z(0) - DRAG_Z).abs() < 1e-6);
let step = dragged_card_z(1) - dragged_card_z(0);
assert!(
step > 0.02,
"drag step must exceed Android overlay local_z, got {step}"
);
assert!(
step + 1e-4 >= STACK_FAN_FRAC,
"drag step must stay aligned with resting stack spacing, got {step}"
);
}
#[test]
fn point_in_rect_inside_returns_true() {
let center = Vec2::new(10.0, 20.0);
let size = Vec2::new(40.0, 60.0);
assert!(point_in_rect(Vec2::new(10.0, 20.0), center, size));
assert!(point_in_rect(Vec2::new(29.0, 49.0), center, size));
assert!(point_in_rect(Vec2::new(-9.0, -9.0), center, size));
}
#[test]
fn point_in_rect_on_edge_returns_true() {
let center = Vec2::ZERO;
let size = Vec2::new(10.0, 10.0);
assert!(point_in_rect(Vec2::new(5.0, 5.0), center, size));
assert!(point_in_rect(Vec2::new(-5.0, -5.0), center, size));
}
#[test]
fn point_in_rect_outside_returns_false() {
let center = Vec2::ZERO;
let size = Vec2::new(10.0, 10.0);
assert!(!point_in_rect(Vec2::new(6.0, 0.0), center, size));
assert!(!point_in_rect(Vec2::new(0.0, 6.0), center, size));
assert!(!point_in_rect(Vec2::new(-100.0, 0.0), center, size));
}
#[test]
fn find_draggable_picks_top_of_tableau() {
let game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// In tableau 6, the visually topmost card is the last (face-up) one.
// Its position: base.y + fan * 6.
let top_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
let result = find_draggable_at(top_pos, &game, &layout).expect("hit");
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
assert_eq!(result.1, 6);
assert_eq!(result.2.len(), 1);
}
#[test]
fn find_draggable_picks_waste_top_with_multiple_cards() {
// Reproduces the reported "drags the wrong waste card" bug: with several
// cards in the waste, clicking the visible top must pick the actual top
// (last index), not the buffer card underneath it.
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
clear_test_piles(&mut game);
let waste = vec![
Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine),
];
game.set_test_waste_cards(waste.clone());
let top_index = waste.len() - 1; // 2 = the visible top
let top_pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
let result = find_draggable_at(top_pos, &game, &layout).expect("waste top is draggable");
assert_eq!(result.0, KlondikePile::Stock, "origin is the waste pile");
assert_eq!(result.1, top_index, "picks the top index, not the buffer");
assert_eq!(
result.2,
vec![waste[top_index].clone()],
"drags the top card only"
);
}
#[test]
fn find_draggable_picks_lone_waste_card() {
// "can't play the first card in the stock" — a waste of one card must
// still be draggable.
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
clear_test_piles(&mut game);
let card = Card::new(Deck::Deck1, Suit::Diamonds, Rank::Ace);
game.set_test_waste_cards(vec![card.clone()]);
let pos = card_position(&game, &layout, &KlondikePile::Stock, 0);
let result = find_draggable_at(pos, &game, &layout).expect("lone waste card is draggable");
assert_eq!(result.0, KlondikePile::Stock);
assert_eq!(result.1, 0);
assert_eq!(result.2, vec![card]);
}
#[test]
fn draw_three_waste_hit_test_matches_render_fan_step() {
// Regression: the Draw-Three waste hit-test must use the same fan step as
// the renderer (`card_plugin::waste_fan_step`). The previous hard-coded
// `card_size.x * 0.28` matched the renderer only on desktop (column step =
// 1.25*cw); under tighter Android-style spacing the two drift and the top
// fanned card's click target lands on the card beneath it — so dragging
// the visible top card plays the wrong one.
let mut game = GameState::new(7, DrawStockConfig::DrawThree);
let mut layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// Force tight (Android-like) column spacing: ~1.03 * card_width.
let cw = layout.card_size.x;
let base = layout.pile_positions[&KlondikePile::Stock];
let t1 = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
layout.pile_positions.insert(
KlondikePile::Tableau(Tableau::Tableau2),
Vec2::new(t1.x + cw * 1.03, t1.y),
);
clear_test_piles(&mut game);
let waste = vec![
Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine),
Card::new(Deck::Deck1, Suit::Diamonds, Rank::King),
];
game.set_test_waste_cards(waste.clone());
// visible_start = len-3 = 1, so the top card sits at fan slot 2.
let top_index = waste.len() - 1;
let pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
let expected = base.x + 2.0 * waste_fan_step(&layout);
assert!(
(pos.x - expected).abs() < 1e-3,
"hit-test must use the shared waste fan step"
);
// The old fixed constant would have drifted from the renderer here.
let old = base.x + 2.0 * cw * 0.28;
assert!(
(pos.x - old).abs() > 1.0,
"shared step must differ from the old fixed step under tight spacing"
);
}
#[test]
fn find_draggable_skips_face_down_cards() {
let game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// Tableau 6 has 7 cards: 6 face-down (indices 0..5) + 1 face-up at
// the bottom (index 6). Click at the topmost face-down card's
// position — its full body is partly visible above the fanned
// face-up card, but the iterator should skip face-down cards and
// the cursor sits above the face-up card's AABB, so the result
// is None.
let face_down_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
let result = find_draggable_at(face_down_pos, &game, &layout);
assert!(result.is_none(), "face-down cards should not be draggable");
}
#[test]
fn find_draggable_hits_face_up_card_with_face_down_cards_above_it() {
// Regression test for the bug where input_plugin's hit-testing used
// a uniform 0.25 fan step but card_plugin renders face-down cards
// at 0.12 — so for any column with face-down cards above the
// face-up bottom card, clicking the visible card face missed the
// hit-test box and only the bottom strip of the card responded.
let game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
// base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre.
let face_up_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
let result = find_draggable_at(face_up_pos, &game, &layout)
.expect("clicking the face-up card's visible centre must initiate a drag");
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
assert_eq!(result.1, 6);
assert_eq!(result.2.len(), 1);
}
#[test]
fn find_draggable_returns_run_when_picking_mid_stack() {
// Manually construct a tableau with three face-up cards all stacked.
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
use solitaire_core::Deck as D;
use solitaire_core::{Card, Rank, Suit};
let king = Card::new(D::Deck1, Suit::Spades, Rank::King);
let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen);
let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack);
game.set_test_tableau_cards(Tableau::Tableau1, vec![king, queen.clone(), jack.clone()]);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// The Queen's geometric center (index 1) is inside the Jack's bounding box
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
// Queen we click in her visible strip: the 0.25h band above the Jack's top
// edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h.
let queen_center = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375);
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
assert_eq!(start, 1);
assert_eq!(ids, vec![queen, jack]);
}
#[test]
fn find_draggable_skips_non_top_waste_card() {
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
use solitaire_core::Deck as D;
use solitaire_core::{Card, Rank, Suit};
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
game.set_test_waste_cards(vec![two_spades, three_hearts.clone()]);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// Both cards in waste sit at the same (x, y). Clicking should pick
// the visually top card (three_hearts), with count = 1.
let pos = card_position(&game, &layout, &KlondikePile::Stock, 0);
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
assert_eq!(pile, KlondikePile::Stock);
assert_eq!(start, 1);
assert_eq!(ids, vec![three_hearts]);
}
#[test]
fn find_drop_target_hits_empty_tableau_pile_marker() {
let game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// Move all cards out of tableau 0 so its marker is the only drop area.
let mut game = game;
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
let target = find_drop_target(
pos,
&game,
&layout,
&KlondikePile::Tableau(Tableau::Tableau7),
);
assert_eq!(target, Some(KlondikePile::Tableau(Tableau::Tableau1)));
}
#[test]
fn find_drop_target_returns_none_for_origin() {
let game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau4)];
let target = find_drop_target(
pos,
&game,
&layout,
&KlondikePile::Tableau(Tableau::Tableau4),
);
assert_eq!(target, None);
}
#[test]
fn pile_drop_rect_extends_for_tableau_with_cards() {
let game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// Tableau 6 has 7 cards.
let (_, size) = pile_drop_rect(&KlondikePile::Tableau(Tableau::Tableau7), &layout, &game);
// Expected: card_height + 6 fan steps.
let expected = layout.card_size.y * (1.0 + 6.0 * layout.tableau_fan_frac);
assert!(
(size.y - expected).abs() < 1e-3,
"expected {expected}, got {}",
size.y
);
}
#[test]
fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() {
use solitaire_core::Deck as D;
use solitaire_core::{Card, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameMode};
let mut game = GameState::new_with_mode(1, DrawStockConfig::DrawThree, GameMode::Classic);
// Three waste cards; top (four_clubs) is rightmost in the fan.
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
let four_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Four);
game.set_test_waste_cards(vec![two_spades, three_hearts, four_clubs.clone()]);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let waste_base = layout.pile_positions[&KlondikePile::Stock];
// Top card (slot=2) is at base.x + 2 * 0.28 * card_width.
let top_card_x = waste_base.x + 2.0 * 0.28 * layout.card_size.x;
let cursor = Vec2::new(top_card_x, waste_base.y);
let result = find_draggable_at(cursor, &game, &layout);
assert!(
result.is_some(),
"top fanned waste card must be hittable at its visual X position"
);
let (pile, _start, ids) = result.unwrap();
assert_eq!(pile, KlondikePile::Stock);
assert_eq!(
ids,
vec![four_clubs],
"only the top card is draggable from waste"
);
}
#[test]
fn find_draggable_returns_none_for_click_on_empty_pile() {
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// Clear tableau 0 so it's an empty slot.
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
let result = find_draggable_at(pos, &game, &layout);
assert!(
result.is_none(),
"clicking an empty pile must not produce a draggable"
);
}
#[test]
fn pile_drop_rect_is_card_sized_for_non_tableau() {
let game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
for pile in [
KlondikePile::Stock,
KlondikePile::Foundation(Foundation::Foundation3),
] {
let (_, size) = pile_drop_rect(&pile, &layout, &game);
assert_eq!(size, layout.card_size);
}
}
// -----------------------------------------------------------------------
// Task #27 — best_destination pure-function tests
// -----------------------------------------------------------------------
#[test]
fn best_destination_returns_none_when_no_legal_move() {
use solitaire_core::Deck as D;
use solitaire_core::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
// Clear everything except one card that has nowhere to go.
clear_test_piles(&mut game);
// A Two of Clubs with empty foundations and empty tableau has no destination.
let card = Card::new(D::Deck1, Suit::Clubs, Rank::Two);
assert!(best_destination(&card, &game).is_none());
}
// -----------------------------------------------------------------------
// best_tableau_destination_for_stack pure-function tests
// -----------------------------------------------------------------------
#[test]
fn best_tableau_destination_for_stack_skips_source_pile() {
use solitaire_core::Deck as D;
use solitaire_core::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
clear_test_piles(&mut game);
// Only tableau 0 has anything; every other column is empty.
// A King is the only card that can go on an empty tableau column.
// Source is Tableau(0), so the result must NOT be Tableau(0).
let king = Card::new(D::Deck1, Suit::Hearts, Rank::King);
game.set_test_tableau_cards(Tableau::Tableau1, vec![king.clone()]);
let result = best_tableau_destination_for_stack(
&king,
&KlondikePile::Tableau(Tableau::Tableau1),
&game,
1,
);
// Result must be some other empty tableau column, never the source.
if let Some((dest, _)) = result {
assert_ne!(dest, KlondikePile::Tableau(Tableau::Tableau1));
}
}
#[test]
fn best_tableau_destination_for_stack_returns_none_when_no_legal_move() {
use solitaire_core::Deck as D;
use solitaire_core::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
clear_test_piles(&mut game);
// Source: tableau 0 has a Two of Clubs (can't go on empty pile; not a King).
// All other piles are empty — no legal tableau target.
let two_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Two);
game.set_test_tableau_cards(Tableau::Tableau1, vec![two_clubs.clone()]);
let result = best_tableau_destination_for_stack(
&two_clubs,
&KlondikePile::Tableau(Tableau::Tableau1),
&game,
1,
);
assert!(
result.is_none(),
"Two of Clubs has no legal tableau destination on empty piles"
);
}
// -----------------------------------------------------------------------
// 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
// -----------------------------------------------------------------------
#[test]
fn find_hint_finds_ace_to_foundation() {
use solitaire_core::Deck as D;
use solitaire_core::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
// Place Ace of Clubs on top of tableau 0.
clear_test_piles(&mut game);
let ace_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Ace);
game.set_test_tableau_cards(Tableau::Tableau1, vec![ace_clubs]);
let hint = find_hint(&game);
assert!(hint.is_some(), "should find a hint");
let (from, to) = hint.unwrap();
assert_eq!(from, KlondikePile::Tableau(Tableau::Tableau1));
assert_eq!(to, KlondikePile::Foundation(Foundation::Foundation1));
}
// -----------------------------------------------------------------------
// G key fires ForfeitRequestEvent (modal-based forfeit flow)
// -----------------------------------------------------------------------
/// `handle_keyboard_forfeit` only checks `paused` and the G keypress;
/// the "is there actually a game?" gating lives in
/// `pause_plugin::handle_forfeit_request` so it can surface a
/// "No game to forfeit" toast instead of failing silently.
#[test]
fn g_key_paused_check_keeps_handler_silent_while_pause_modal_owns_input() {
// Build the system param state by hand so we don't rely on a
// full Bevy app: the assertion is that the function returns
// early on the paused branch without calling write_message.
// This is verified by the plain `if paused { return; }` shape;
// the body is small enough to inspect by reading.
// (Higher-level integration coverage lives in the pause-plugin
// tests where `forfeit_app` simulates the full flow.)
let _ = handle_keyboard_forfeit; // proves the symbol still compiles
}
// -----------------------------------------------------------------------
// all_hints / new-game window — pure-function tests added during refactor
// -----------------------------------------------------------------------
/// Pass 3 of `all_hints` should suggest drawing from the stock when there
/// are no other moves and the stock is non-empty.
#[test]
fn all_hints_suggests_draw_when_no_moves_and_stock_nonempty() {
use solitaire_core::Deck as D;
use solitaire_core::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
// Remove all foundation, tableau, and waste cards so no pile-to-pile
// move exists. Leave one card in the stock.
clear_test_piles(&mut game);
// Put one card back into the stock so "draw" is a valid suggestion.
game.set_test_stock_cards(vec![Card::new(D::Deck1, Suit::Clubs, Rank::Ace)]);
let hints = all_hints(&game);
assert_eq!(hints.len(), 1, "exactly one hint: draw from stock");
let (from, to) = &hints[0];
assert_eq!(*from, KlondikePile::Stock, "hint must come from Stock");
assert_eq!(*to, KlondikePile::Stock, "hint must point to Waste");
}
// `all_hints` must be empty when both stock and waste are empty and no
// pile-to-pile move exists — the game is truly stuck.
// -----------------------------------------------------------------------
// Drag-rejection return tween — `CardAnimation` replaces the legacy
// `ShakeAnim` on the dragged cards. The audio cue
// (`card_invalid.wav` via `MoveRejectedEvent`) is unchanged; only the
// visual response on the dragged cards swapped from a horizontal wiggle
// to a smooth ease-out glide back to the origin pile.
//
// These tests build the component values exactly as `end_drag` and
// `touch_end_drag` would, then assert the resulting `CardAnimation` is
// shaped correctly. Driving `end_drag` end-to-end requires a real window
// and mouse-button input, so we exercise the data path the same way the
// legacy `ShakeAnim` tests did.
// -----------------------------------------------------------------------
/// Helper: build the `CardAnimation` the rejection paths construct for
/// one dragged card. Mirrors the inline logic in `end_drag` and
/// `touch_end_drag` so the tests stay in sync with the production code.
fn build_drag_reject_animation(
drag_pos: Vec2,
drag_z: f32,
target_pos: Vec2,
stack_index: usize,
) -> CardAnimation {
let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
CardAnimation::slide(drag_pos, drag_z, target_pos, end_z, MotionCurve::Responsive)
.with_duration(MOTION_DRAG_REJECT_SECS)
}
/// Every card in `drag.cards` should receive its own `CardAnimation` on
/// rejection. With the shake → tween migration, the assertion changes
/// from "every dragged card gets a ShakeAnim" to "every dragged card
/// gets a CardAnimation" — same coverage, new component.
#[test]
fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
use solitaire_core::Deck as D;
use solitaire_core::{Card, Rank, Suit};
// Simulate a stack drag of two cards.
let dragged_cards: Vec<Card> = vec![
Card::new(D::Deck1, Suit::Hearts, Rank::King),
Card::new(D::Deck1, Suit::Spades, Rank::Queen),
];
let mut animated: Vec<Card> = Vec::new();
for card in &dragged_cards {
// In `end_drag` we iterate `drag.cards` and look up each card in
// `card_entities`. The cards we would insert a `CardAnimation` on
// must exactly match the dragged set.
animated.push(card.clone());
}
assert_eq!(
animated, dragged_cards,
"every card in drag.cards must receive a CardAnimation on rejection"
);
}
/// The `end` field of the inserted tween must equal the card's resting
/// slot in its origin pile — the position the card belongs at after a
/// rejected drop. Without this, the tween would glide to the wrong spot
/// and `sync_cards` would have to fight it back.
#[test]
fn rejected_drag_animation_targets_origin_resting_position() {
let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen
let target_pos = Vec2::new(123.5, -50.0); // origin pile slot
let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
assert!(
(anim.end - target_pos).length() < 1e-6,
"CardAnimation.end must match the origin slot's resting position. \
Expected {target_pos:?}, got {:?}",
anim.end
);
}
/// The `start` field of the inserted tween must equal the card's
/// drop-time transform position — i.e. wherever the cursor or finger
/// released the card. This is what makes the glide feel like a
/// continuous return rather than a teleport-then-shake.
#[test]
fn rejected_drag_animation_starts_from_drag_position() {
let drag_pos = Vec2::new(640.0, 200.0);
let target_pos = Vec2::new(80.0, -120.0);
let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
assert!(
(anim.start - drag_pos).length() < 1e-6,
"CardAnimation.start must match the drop-time transform position \
(where the cursor released). Expected {drag_pos:?}, got {:?}",
anim.start
);
// And the start must be visibly distinct from the origin slot — the
// whole point of the tween is that it visibly travels.
assert!(
(anim.start - anim.end).length() > 1.0,
"rejected drag should travel a visible distance, got start={:?} end={:?}",
anim.start,
anim.end
);
}
/// The tween duration is taken from the project-wide motion token so
/// designers can retune the feel from one place. Keeps the constant and
/// the call site honest.
#[test]
fn rejected_drag_animation_uses_correct_duration() {
let anim =
build_drag_reject_animation(Vec2::new(640.0, 200.0), DRAG_Z, Vec2::new(80.0, -120.0), 0);
assert!(
(anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6,
"drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \
({MOTION_DRAG_REJECT_SECS}), got {}",
anim.duration
);
}
/// The curve must be a no-overshoot ease-out so the card decelerates
/// cleanly into its rest position — overshoot on a rejection feels
/// jittery rather than forgiving.
#[test]
fn rejected_drag_animation_uses_responsive_curve() {
let anim =
build_drag_reject_animation(Vec2::new(640.0, 200.0), DRAG_Z, Vec2::new(80.0, -120.0), 0);
assert_eq!(
anim.curve,
MotionCurve::Responsive,
"drag-rejection tween must use Responsive (quintic ease-out) \
so the card snaps back without bouncing past the slot"
);
}
/// The `start_z` of the tween must equal the card's drop-time z
/// (`DRAG_Z`) so the card stays above the rest of the table while it
/// travels home, then settles at the correct resting z.
#[test]
fn rejected_drag_animation_lifts_from_drag_z_to_resting_z() {
let stack_index = 2_usize;
let anim = build_drag_reject_animation(
Vec2::new(640.0, 200.0),
DRAG_Z,
Vec2::new(80.0, -120.0),
stack_index,
);
assert!(
(anim.start_z - DRAG_Z).abs() < 1e-6,
"tween must start at DRAG_Z so the card stays on top during the glide"
);
let expected_end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
assert!(
(anim.end_z - expected_end_z).abs() < 1e-6,
"tween must end at the slot's resting z, got {} expected {expected_end_z}",
anim.end_z
);
}
// -----------------------------------------------------------------------
// Hint system — async port (v0.18.0+)
//
// `handle_keyboard_hint` no longer runs the solver inline; it
// spawns an `AsyncComputeTaskPool` task whose result the polling
// system in `pending_hint` turns into hint visuals one frame
// later. The behaviour contract this section pins is "pressing H
// populates `PendingHintTask`" — the spawn-to-emit pipeline is
// covered end-to-end in `pending_hint::tests`.
// -----------------------------------------------------------------------
/// Pressing H on a non-paused, non-won game with a live
/// `GameStateResource` + `LayoutResource` must populate
/// `PendingHintTask`. The polling system, exercised in
/// `pending_hint::tests`, drives the result to a visual event.
#[test]
fn pressing_h_spawns_pending_hint_task() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_message::<InfoToastEvent>();
app.add_message::<HintVisualEvent>();
app.init_resource::<HintCycleIndex>();
app.init_resource::<HintSolverConfig>();
app.init_resource::<crate::pending_hint::PendingHintTask>();
app.init_resource::<ButtonInput<KeyCode>>();
app.insert_resource(LayoutResource(compute_layout(
Vec2::new(1280.0, 800.0),
0.0,
0.0,
true,
)));
app.insert_resource(GameStateResource(GameState::new(
42,
DrawStockConfig::DrawOne,
)));
app.add_systems(Update, handle_keyboard_hint);
// Simulate the H key being pressed this frame.
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(KeyCode::KeyH);
input.clear();
input.press(KeyCode::KeyH);
}
app.update();
assert!(
app.world()
.resource::<crate::pending_hint::PendingHintTask>()
.is_pending(),
"pressing H must spawn an async hint task",
);
}
+88 -12
View File
@@ -7,6 +7,7 @@ use std::collections::HashMap;
use bevy::math::Vec2;
use bevy::prelude::{Resource, SystemSet};
use solitaire_core::game_state::GameState;
use solitaire_core::{Foundation, KlondikePile, Tableau};
/// Schedule labels for layout-related systems so cross-plugin ordering is
@@ -91,6 +92,15 @@ const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.14;
/// this column inside the visible window.
const MAX_TABLEAU_CARDS: f32 = 13.0;
/// Upper bound for the dynamic tableau fan step (fraction of card height) chosen
/// by [`apply_dynamic_tableau_fan`]. The fan is spread to fill the available
/// height, but a near-empty column has tiny demand, so without a cap its few
/// cards would fling far apart on a tall viewport. At 0.6 the face-up cards keep
/// clear overlap (a readable stack) while still filling most of a near-square /
/// unfolded-foldable screen. Tunable purely for feel — no effect on correctness
/// or hit-testing.
pub(crate) const MAX_DYNAMIC_FAN_FRAC: f32 = 0.9;
/// Vertical pixel band reserved at the top of the play area for the HUD
/// (action buttons, Score / Moves / Timer readouts). The card grid starts
/// below this band so the HUD doesn't bleed into the play surface.
@@ -275,13 +285,12 @@ pub fn compute_layout(
);
}
// Adaptive tableau fan fraction. On height-limited (desktop) windows the
// height-based sizing already ensures a worst-case 13-card column fits at
// TABLEAU_FAN_FRAC (0.25), so the formula returns ≈0.25 and the clamp
// keeps it there — no change from prior behaviour. On width-limited
// (portrait phone) windows card_size is small and lots of vertical space
// is unused; we solve for the fraction that exactly fills the available
// space to the bottom margin.
// Adaptive tableau fan fraction. On height-limited windows the height-based
// sizing already ensures a worst-case 13-card column fits at TABLEAU_FAN_FRAC,
// so the formula returns the minimum and the clamp keeps it there. On
// width-limited (portrait phone) windows card_size is small and lots of
// vertical space is unused; solve for the fraction that fills the available
// space. `apply_dynamic_tableau_fan` later refines this for the actual deal.
//
// avail = distance from the top of the first tableau card to the bottom
// margin — i.e. the space available for 12 fan steps.
@@ -292,20 +301,87 @@ pub fn compute_layout(
} else {
TABLEAU_FAN_FRAC
};
// Never go below the desktop minimum — avoids shrinking the fan on
// degenerate near-square windows where the formula might undershoot.
let tableau_fan_frac = ideal_fan_frac.max(TABLEAU_FAN_FRAC);
// Scale the face-down fraction proportionally so rendering and hit-testing
// stay in sync (TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC = 0.48 ratio).
let facedown_scale = TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC;
let tableau_facedown_fan_frac = tableau_fan_frac * facedown_scale;
let available_tableau_height = avail;
Layout {
card_size,
pile_positions,
tableau_fan_frac,
tableau_facedown_fan_frac,
available_tableau_height: avail,
available_tableau_height,
}
}
/// Spread the tableau fan so the deepest column fills the available vertical
/// height for the *current* deal, mutating `layout.tableau_fan_frac` and its
/// face-down companion in place.
///
/// `compute_layout` is pure geometry and sizes the fan for a worst-case 13-card
/// column, so early in a game (shallow columns) a tall or near-square viewport
/// — e.g. an unfolded foldable — is left with a large empty band below the
/// tableau. This refines the fan once the actual deal is known.
///
/// Depth is measured across *all* cards in a column, each face-down card
/// weighted by the fixed face-down/face-up step ratio. Counting the face-down
/// portion (not just the face-up tail) is what fills the lower screen on a fresh
/// deal, where the deepest column is several face-down cards under one face-up
/// one. Deeper columns drive the fraction down so everything still fits;
/// [`TABLEAU_FAN_FRAC`] floors it and [`MAX_DYNAMIC_FAN_FRAC`] caps it.
///
/// Called from card-sync at startup and on every `StateChangedEvent`, and from
/// the resize pipeline after `compute_layout`, so the cold-start deal, ongoing
/// play, and fold/unfold all stay filled. `card_position` / `card_positions`
/// read the same fractions, so rendering and hit-testing remain in sync.
pub(crate) fn apply_dynamic_tableau_fan(game: &GameState, layout: &mut Layout) {
let card_h = layout.card_size.y;
let avail = layout.available_tableau_height;
if card_h <= 0.0 {
return;
}
let facedown_ratio = TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC;
// "Step demand" of a column: the vertical offset of its bottom card from its
// top card, in units of the face-up fan step. Every card except the last
// contributes one step, weighted down to `facedown_ratio` while face-down.
let max_demand = [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
]
.into_iter()
.map(|tableau| {
let pile = game.pile(KlondikePile::Tableau(tableau));
let steps = pile.len().saturating_sub(1);
pile.iter()
.take(steps)
.map(|(_, face_up)| if *face_up { 1.0 } else { facedown_ratio })
.sum::<f32>()
})
.fold(0.0_f32, f32::max);
// No fannable column (every tableau pile has ≤ 1 card) — leave the fractions
// at the values compute_layout set.
if max_demand <= 0.0 {
return;
}
let ideal = avail / (max_demand * card_h);
let new_frac = ideal.clamp(TABLEAU_FAN_FRAC, MAX_DYNAMIC_FAN_FRAC);
let new_facedown_frac = new_frac * facedown_ratio;
if (layout.tableau_fan_frac - new_frac).abs() > 1e-4 {
layout.tableau_fan_frac = new_frac;
}
if (layout.tableau_facedown_fan_frac - new_facedown_frac).abs() > 1e-4 {
layout.tableau_facedown_fan_frac = new_facedown_frac;
}
}
+18 -6
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 {
@@ -862,10 +870,8 @@ fn handle_display_name_confirm(
.leaderboard_display_name
.clone()
.unwrap_or_else(|| {
if let SyncBackend::SolitaireServer {
ref username,
..
} = settings.0.sync_backend
if let SyncBackend::SolitaireServer { ref username, .. } =
settings.0.sync_backend
{
username.chars().take(32).collect()
} else {
@@ -890,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 {
+12 -4
View File
@@ -43,8 +43,10 @@ pub mod replay_overlay;
pub mod replay_playback;
pub mod resources;
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"))]
@@ -53,6 +55,8 @@ pub mod sync_plugin;
pub mod sync_setup_plugin;
pub mod table_plugin;
pub mod theme;
#[cfg(not(target_arch = "wasm32"))]
pub mod theme_store_plugin;
pub mod time_attack_plugin;
pub mod touch_selection_plugin;
pub mod ui_focus;
@@ -61,6 +65,7 @@ pub mod ui_theme;
pub mod ui_tooltip;
pub mod weekly_goals_plugin;
pub mod win_summary_plugin;
pub mod you_hub_plugin;
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
#[cfg(not(target_arch = "wasm32"))]
@@ -78,9 +83,8 @@ pub use avatar_plugin::{AvatarFetchEvent, AvatarPlugin, AvatarResource};
pub use card_animation::{
AnimationChain, AnimationTuning, BufferedInput, CardAnimation, CardAnimationPlugin,
DEAL_INTERVAL_SECS, DIAG_WINDOW_SIZE, FrameTimeDiagnostics, HoverState, InputBuffer,
InputPlatform, MAX_DURATION_SECS, MIN_DURATION_SECS, MotionCurve, WIN_CASCADE_INTERVAL_SECS,
WinCascadePlugin, cascade_delay, compute_duration, micro_vary, 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,
@@ -102,7 +106,7 @@ pub use events::{
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
StartTimeAttackRequestEvent, StartZenRequestEvent, StateChangedEvent, SyncCompleteEvent,
StartTimeAttackRequestEvent, StartZenRequestEvent, StateChangedEvent,
ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent,
ToggleSettingsRequestEvent, ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent,
XpAwardedEvent,
@@ -158,6 +162,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,
@@ -175,6 +180,8 @@ pub use theme::{
ActiveTheme, CardTheme, CardThemeLoader, ThemeEntry, ThemePlugin, ThemeRegistry,
ThemeRegistryPlugin, set_theme,
};
#[cfg(not(target_arch = "wasm32"))]
pub use theme_store_plugin::{ThemeStorePlugin, ThemeStoreScreen};
pub use time_attack_plugin::{
TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource,
};
@@ -190,3 +197,4 @@ pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
pub use win_summary_plugin::{
ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin, format_win_time,
};
pub use you_hub_plugin::{ActiveYouTab, YouHubPlugin, YouHubScreen, YouTab};
+1 -1
View File
@@ -27,6 +27,7 @@ use solitaire_data::{Settings, save_settings_to};
use crate::font_plugin::FontResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::splash_plugin::SplashRoot;
use crate::ui_modal::{
ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
spawn_modal_header,
@@ -36,7 +37,6 @@ use crate::ui_theme::{
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION,
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
};
use crate::splash_plugin::SplashRoot;
use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING};
// ---------------------------------------------------------------------------
+36 -1
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,
@@ -969,7 +1001,10 @@ mod tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
app.insert_resource(GameStateResource(GameState::new(
1,
DrawStockConfig::DrawOne,
)));
app.update();
app
}
+25 -41
View File
@@ -178,9 +178,9 @@ mod tests {
use super::*;
use crate::events::HintVisualEvent;
use crate::input_plugin::HintSolverConfig;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::{Foundation, KlondikePile, Tableau};
/// Build a minimal Bevy app exercising only the polling system
/// and the resources/messages it touches.
@@ -220,32 +220,11 @@ mod tests {
] {
game.set_test_foundation_cards(foundation, Vec::new());
}
for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
for tableau in solitaire_core::TABLEAUS {
game.set_test_tableau_cards(tableau, Vec::new());
}
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
let ranks_below_king = [
Rank::Ace,
Rank::Two,
Rank::Three,
Rank::Four,
Rank::Five,
Rank::Six,
Rank::Seven,
Rank::Eight,
Rank::Nine,
Rank::Ten,
Rank::Jack,
Rank::Queen,
];
let suits = Suit::SUITS;
let ranks_below_king = &Rank::RANKS[..12]; // everything below King
for (foundation, suit) in [
Foundation::Foundation1,
Foundation::Foundation2,
@@ -270,10 +249,7 @@ mod tests {
.into_iter()
.zip(suits.iter())
{
game.set_test_tableau_cards(
tableau,
vec![Card::new(Deck::Deck1, *suit, Rank::King)],
);
game.set_test_tableau_cards(tableau, vec![Card::new(Deck::Deck1, *suit, Rank::King)]);
}
game
}
@@ -288,9 +264,11 @@ mod tests {
let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state()));
let cfg = *app.world().resource::<HintSolverConfig>();
app.world_mut()
.resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
app.world_mut().resource_mut::<PendingHintTask>().spawn(
near_finished_state(),
cfg.moves_budget,
cfg.states_budget,
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
while app.world().resource::<PendingHintTask>().is_pending() {
@@ -327,9 +305,11 @@ mod tests {
let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state()));
let cfg = *app.world().resource::<HintSolverConfig>();
app.world_mut()
.resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
app.world_mut().resource_mut::<PendingHintTask>().spawn(
near_finished_state(),
cfg.moves_budget,
cfg.states_budget,
);
assert!(
app.world().resource::<PendingHintTask>().is_pending(),
"task is in flight after spawn",
@@ -365,18 +345,22 @@ mod tests {
let cfg = *app.world().resource::<HintSolverConfig>();
// First spawn.
app.world_mut()
.resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
app.world_mut().resource_mut::<PendingHintTask>().spawn(
near_finished_state(),
cfg.moves_budget,
cfg.states_budget,
);
let first_handle_present = app.world().resource::<PendingHintTask>().is_pending();
assert!(first_handle_present);
// Second spawn. The `spawn` helper drops the prior task
// before assigning the new one — at no point are two tasks
// in flight.
app.world_mut()
.resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
app.world_mut().resource_mut::<PendingHintTask>().spawn(
near_finished_state(),
cfg.moves_budget,
cfg.states_budget,
);
// Resource still pending (the second task), but the first
// is gone. We can't directly observe the first handle once
// it's been overwritten — what we *can* assert is that the
+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 -27
View File
@@ -47,9 +47,10 @@ use bevy::input::touch::Touches;
use bevy::math::Vec2;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::Card;
use solitaire_core::game_state::GameState;
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC;
use crate::events::{MoveRejectedEvent, MoveRequestEvent};
@@ -254,13 +255,13 @@ pub fn legal_destinations_for_card(
game: &GameState,
) -> Vec<KlondikePile> {
let mut out = Vec::new();
for foundation in foundations() {
for foundation in FOUNDATIONS {
let dest = KlondikePile::Foundation(foundation);
if game.can_move_cards(source_pile, &dest, 1) {
out.push(dest);
}
}
for tableau in tableaus() {
for tableau in TABLEAUS {
let dest = KlondikePile::Tableau(tableau);
if game.can_move_cards(source_pile, &dest, 1) {
out.push(dest);
@@ -359,28 +360,6 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
}
}
const fn foundations() -> [Foundation; 4] {
[
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
]
}
const fn tableaus() -> [Tableau; 7] {
[
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
]
}
/// Builds the `(destination, anchor)` list for a fresh radial open.
///
/// `half_extents` is the window half-size in world space — icons are clamped
@@ -399,8 +378,10 @@ fn build_radial_destinations(
.map(|(i, d)| {
let raw = radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX);
let clamped = Vec2::new(
raw.x.clamp(-half_extents.x + margin, half_extents.x - margin),
raw.y.clamp(-half_extents.y + margin, half_extents.y - margin),
raw.x
.clamp(-half_extents.x + margin, half_extents.x - margin),
raw.y
.clamp(-half_extents.y + margin, half_extents.y - margin),
);
(d, clamped)
})
+13 -13
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;
@@ -246,7 +242,11 @@ pub(crate) fn format_suit_glyph(suit: Suit) -> &'static str {
/// known card, or `"--"` for an absent top card (empty pile).
pub(crate) fn format_card_short(card: Option<&(Card, bool)>) -> String {
match card {
Some((c, _)) => format!("{}{}", format_rank_short(c.rank()), format_suit_glyph(c.suit())),
Some((c, _)) => format!(
"{}{}",
format_rank_short(c.rank()),
format_suit_glyph(c.suit())
),
None => "--".to_string(),
}
}
@@ -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],
),
)
}
+1 -1
View File
@@ -6,8 +6,8 @@ use std::sync::Arc;
use bevy::math::Vec2;
use bevy::prelude::Resource;
use chrono::{DateTime, Utc};
use solitaire_core::KlondikePile;
use solitaire_core::Card;
use solitaire_core::KlondikePile;
use solitaire_core::game_state::GameState;
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
+135 -15
View File
@@ -82,9 +82,11 @@ 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::rearm_on_resumed)
.add_systems(Update, android::refresh_surface_size);
}
}
@@ -191,10 +193,12 @@ fn apply_safe_area_to_modal_scrims(
/// whatever `SafeAreaInsets` are current at that moment.
///
/// On Android the `android::rearm_on_resumed` system runs in the same frame
/// and resets both `SafeAreaPollTries` and `SafeAreaInsets` to zero, causing
/// `refresh_insets` to re-poll JNI over the next few frames. When it resolves
/// the correct values, `on_safe_area_changed` in `table_plugin` emits a second
/// synthetic `WindowResized` and the layout converges to the right position.
/// and resets `SafeAreaPollTries` (the cached `SafeAreaInsets` keep their
/// last-known values), causing `refresh_insets` to re-poll JNI over the next
/// few frames. If the insets changed while backgrounded, `on_safe_area_changed`
/// in `table_plugin` emits a second synthetic `WindowResized` and the layout
/// converges to the right position; if they didn't, nothing is rewritten and
/// the layout stays put.
///
/// On non-Android targets this handler still fires — it ensures that a resume
/// event always refreshes the layout (e.g., after a minimise/restore on
@@ -223,6 +227,7 @@ fn on_app_resumed(
mod android {
use super::{AppLifecycle, SafeAreaInsets};
use bevy::prelude::*;
use bevy::window::WindowResized;
/// Tracks how many frames `refresh_insets` has polled. Stored as a
/// `Resource` (not `Local`) so that `rearm_on_resumed` can reset it to 0
@@ -232,9 +237,14 @@ mod android {
pub(super) struct SafeAreaPollTries(pub u32);
/// Polls Android for safe-area insets until we get a non-zero
/// reading, then stops. `getRootWindowInsets()` returns `null` (or
/// all-zero `Insets`) until the decor view has been laid out, which
/// is typically frame 13 of a fresh launch.
/// reading, then settles until [`rearm_on_resumed`] re-arms it on the
/// next foreground resume — insets can change while backgrounded
/// (rotation, fold/unfold, gesture ↔ 3-button nav). The poll counter
/// (not `insets.is_populated()`) gates the loop, so a re-armed cycle
/// re-queries JNI even though cached values are already populated.
/// `getRootWindowInsets()` returns `null` (or all-zero `Insets`)
/// until the decor view has been laid out, which is typically frame
/// 13 of a fresh launch.
pub(super) fn refresh_insets(
mut insets: ResMut<SafeAreaInsets>,
mut poll: ResMut<SafeAreaPollTries>,
@@ -243,18 +253,25 @@ mod android {
// devices that genuinely report zero insets.
const MAX_TRIES: u32 = 120; // ~2 seconds @ 60 fps
if poll.0 >= MAX_TRIES || insets.is_populated() {
if poll.0 >= MAX_TRIES {
return;
}
poll.0 += 1;
match query_insets() {
Ok(v) if v.is_populated() => {
info!(
"safe_area: insets resolved top={} bottom={} left={} right={} (after {} frames)",
v.top, v.bottom, v.left, v.right, poll.0
);
*insets = v;
if *insets != v {
info!(
"safe_area: insets resolved top={} bottom={} left={} right={} (after {} frames)",
v.top, v.bottom, v.left, v.right, poll.0
);
*insets = v;
}
// Settled for this poll cycle; `rearm_on_resumed` re-arms on
// the next resume. Writing `insets` only on an actual change
// keeps resource change detection (and the relayout it
// triggers) quiet on resumes where nothing moved.
poll.0 = MAX_TRIES;
}
Ok(_) => {
// Layout not ready yet; try again next frame.
@@ -285,11 +302,114 @@ mod android {
) {
for event in lifecycle.read() {
if matches!(event, AppLifecycle::WillResume) {
// Evidence line for #130: winit's Android backend has open
// TODOs around forwarding resume notifications, so whether
// this ever fires on a given device is an open question.
info!("safe_area: AppLifecycle::WillResume received; re-arming inset poll");
poll.0 = 0;
}
}
}
/// Polls the decor view's size via JNI and forces a relayout when it
/// disagrees with Bevy's cached `Window` resolution (#130).
///
/// winit's Android backend does not forward content-rect changes that
/// happen while the app is backgrounded (fold/unfold on foldables), so
/// after a fold cycle Bevy can keep rendering and laying out for the
/// previous screen's dimensions. Unlike `refresh_insets` this poller
/// never settles: it cannot rely on `AppLifecycle::WillResume` to re-arm
/// it, because that event is itself delivered through the same unreliable
/// lifecycle plumbing. A JNI round-trip every `POLL_INTERVAL_FRAMES`
/// frames is cheap.
///
/// On a mismatch it:
/// 1. writes the real size into `window.resolution` so the renderer
/// reconfigures the surface and systems reading `window.width()` see
/// the truth,
/// 2. emits a synthetic `WindowResized` (logical pixels) so
/// `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,
/// 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
*frame += 1;
if !frame.is_multiple_of(POLL_INTERVAL_FRAMES) {
return;
}
let Some((entity, mut window)) = windows.iter_mut().next() else {
return;
};
let (decor_w, decor_h) = match query_decor_size() {
Ok(size) => size,
Err(e) => {
// One-time note; the bridge simply isn't up yet during the
// first frames of a launch.
if *frame == POLL_INTERVAL_FRAMES {
warn!("safe_area: decor size query failed (will retry): {e}");
}
return;
}
};
if decor_w == 0 || decor_h == 0 {
return; // decor view not laid out yet
}
// Reads go through `Deref` and do not trip change detection; only
// mutate `window` once a mismatch is confirmed.
let cached_w = window.resolution.physical_width();
let cached_h = window.resolution.physical_height();
if decor_w == cached_w && decor_h == cached_h {
return;
}
info!(
"safe_area: decor view is {decor_w}x{decor_h} but cached resolution is \
{cached_w}x{cached_h}; forcing relayout (fold/unfold missed by winit?)"
);
window.resolution.set_physical_resolution(decor_w, decor_h);
let scale = window.scale_factor();
resize_events.write(WindowResized {
window: entity,
width: decor_w as f32 / scale,
height: decor_h as f32 / scale,
});
state_events.write(crate::events::StateChangedEvent);
poll.0 = 0;
}
/// Physical pixel size of the activity's decor view — the ground truth
/// for the surface we are actually being displayed on, independent of
/// whatever winit last told Bevy.
fn query_decor_size() -> Result<(u32, u32), String> {
use solitaire_data::android_jni;
android_jni::with_activity_env(|env, activity| {
let window = env
.call_method(activity, "getWindow", "()Landroid/view/Window;", &[])?
.l()?;
let decor = env
.call_method(&window, "getDecorView", "()Landroid/view/View;", &[])?
.l()?;
let w = env.call_method(&decor, "getWidth", "()I", &[])?.i()?;
let h = env.call_method(&decor, "getHeight", "()I", &[])?.i()?;
Ok((w.max(0) as u32, h.max(0) as u32))
})
}
fn query_insets() -> Result<SafeAreaInsets, String> {
use solitaire_data::android_jni;
+106
View File
@@ -0,0 +1,106 @@
//! Schedule hygiene checks (issue #143).
//!
//! Bevy runs systems with conflicting data access in nondeterministic order
//! unless an ordering edge exists. Nothing enforced this historically, and
//! the headless gameplay cluster carries a large legacy backlog of such
//! pairs — most benign (event/resource writers that never observably race),
//! but unproven. Rather than a permanently red hard-error test or no test
//! at all, this is a RATCHET: the count may only go down. New conflicting
//! pairs fail CI immediately; the backlog can be triaged incrementally
//! (add `.before`/`.after` where order matters, `.ambiguous_with` where it
//! provably doesn't, then lower `AMBIGUITY_BASELINE`).
//!
//! To see the offending pair names, add the `bevy_debug` / debug feature to
//! the bevy dev-dependency (system names are stripped otherwise) and set
//! `ambiguity_detection: LogLevel::Error` manually.
//!
//! Scope note: `CoreGamePlugin` (the full composition) performs real
//! storage/platform I/O in `build` and cannot run in a unit test; the
//! cluster below is the same one the engine's headless behaviour tests use.
#[cfg(test)]
mod tests {
use bevy::ecs::schedule::{LogLevel, ScheduleBuildSettings};
use bevy::prelude::*;
use crate::auto_complete_plugin::AutoCompletePlugin;
use crate::card_plugin::CardPlugin;
use crate::game_plugin::GamePlugin;
use crate::hud_plugin::HudPlugin;
use crate::settings_plugin::SettingsPlugin;
use crate::table_plugin::TablePlugin;
use crate::ui_focus::UiFocusPlugin;
use crate::ui_modal::UiModalPlugin;
/// The backlog (302 pairs on 2026-07-06) was burned down to ZERO the
/// same day (#143, PRs #146#149) — this is now a hard gate. If your
/// change trips this assertion you have added a pair of systems with
/// conflicting data access and no ordering edge: add `.before`/`.after`
/// where order matters, or `.ambiguous_with` the relevant domain set
/// (BoardVisuals, MarkerVisuals, UiTextFx, HudButtons, writer sets)
/// where it provably does not. Do not raise this constant.
const AMBIGUITY_BASELINE: usize = 0;
fn cluster_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(CardPlugin)
.add_plugins(HudPlugin)
.add_plugins(AutoCompletePlugin)
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin)
.add_plugins(SettingsPlugin::headless());
app.init_resource::<ButtonInput<KeyCode>>();
app
}
#[test]
fn update_schedule_ambiguities_do_not_grow() {
let mut app = cluster_app();
app.edit_schedule(Update, |s| {
s.set_build_settings(ScheduleBuildSettings {
ambiguity_detection: LogLevel::Error,
..Default::default()
});
});
// With LogLevel::Error the first schedule build panics when any
// ambiguity exists, and the panic message begins with the pair
// count. Catch it and ratchet on that count. (Parsing a panic
// message is brittle by design — if a Bevy upgrade rewords it,
// this test fails loudly and the parse below needs one-line
// maintenance, which is preferable to losing the ratchet.)
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
app.update();
}));
let count = match result {
Ok(()) => 0,
Err(payload) => {
let msg = payload
.downcast_ref::<String>()
.cloned()
.or_else(|| payload.downcast_ref::<&str>().map(|s| (*s).to_string()))
.unwrap_or_default();
let parsed = msg
.split(" pairs of systems")
.next()
.and_then(|prefix| prefix.split_whitespace().last())
.and_then(|n| n.parse::<usize>().ok());
parsed.unwrap_or_else(|| {
panic!("ambiguity panic message no longer parseable (Bevy upgrade?): {msg}")
})
}
};
assert_eq!(
count, AMBIGUITY_BASELINE,
"system-order ambiguities changed from the enforced baseline. \
Add .before/.after or .ambiguous_with at the new registration site \
(or, if the count legitimately dropped below a nonzero baseline, \
lower AMBIGUITY_BASELINE).",
);
}
}
+11 -33
View File
@@ -37,9 +37,9 @@
use bevy::input::ButtonInput;
use bevy::prelude::*;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::Card;
use solitaire_core::game_state::GameState;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::card_plugin::CardEntityIndex;
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
@@ -487,8 +487,10 @@ fn handle_selection_keys(
1
};
let start = source_cards.len().saturating_sub(count);
let lifted_cards: Vec<Card> =
source_cards[start..].iter().map(|(c, _)| c.clone()).collect();
let lifted_cards: Vec<Card> = source_cards[start..]
.iter()
.map(|(c, _)| c.clone())
.collect();
let Some((bottom, _)) = source_cards.get(start) else {
return;
};
@@ -597,10 +599,7 @@ fn face_up_run_len(cards: &[(Card, bool)]) -> usize {
/// This is intentionally separated from [`best_destination`] so the Enter
/// handler can attempt a foundation move first and fall through to a
/// multi-card stack move rather than accepting a single-card tableau move.
fn try_foundation_dest(
card: &Card,
game: &GameState,
) -> Option<KlondikePile> {
fn try_foundation_dest(card: &Card, game: &GameState) -> Option<KlondikePile> {
let source = game.pile_containing_card(card.clone())?;
for foundation in [
Foundation::Foundation1,
@@ -697,13 +696,7 @@ fn update_selection_highlight(
if let Some(ref pile) = source_pile
&& let Some(card) = top_face_up_card(pile, &game.0)
{
spawn_highlight_on_card(
&mut commands,
&card_index,
&card,
card_size,
source_color,
);
spawn_highlight_on_card(&mut commands, &card_index, &card, card_size, source_color);
}
// Destination highlight while lifted.
@@ -714,13 +707,7 @@ fn update_selection_highlight(
// in destination-pick mode and the focused index is observable
// via the resource.
if let Some(card) = top_face_up_card(dest, &game.0) {
spawn_highlight_on_card(
&mut commands,
&card_index,
&card,
card_size,
dest_color,
);
spawn_highlight_on_card(&mut commands, &card_index, &card, card_size, dest_color);
}
}
}
@@ -1047,10 +1034,7 @@ mod tests {
press_key(&mut app, KeyCode::Tab);
app.update();
let selected = app
.world()
.resource::<SelectionState>()
.selected_pile;
let selected = app.world().resource::<SelectionState>().selected_pile;
// The cycle order starts at Waste, but Waste is empty so the next
// available pile (Tableau(0)) is selected.
assert_eq!(selected, Some(KlondikePile::Tableau(Tableau::Tableau1)));
@@ -1216,16 +1200,10 @@ mod tests {
drag.active_touch_id = None;
}
let before = app
.world()
.resource::<SelectionState>()
.selected_pile;
let before = app.world().resource::<SelectionState>().selected_pile;
press_key(&mut app, KeyCode::Tab);
app.update();
let after = app
.world()
.resource::<SelectionState>()
.selected_pile;
let after = app.world().resource::<SelectionState>().selected_pile;
assert_eq!(
before, after,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,666 @@
//! Input handling for the Settings panel: button presses, keyboard
//! accelerators, focus attachment, and scrolling.
use super::*;
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::ui::{ComputedNode, UiGlobalTransform};
use solitaire_core::DrawStockConfig;
use solitaire_data::{
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_STEP_SECS,
settings::Theme,
};
use crate::events::{
DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
SyncLogoutRequestEvent, ToggleSettingsRequestEvent,
};
use crate::ui_focus::{FocusGroup, Focusable, FocusedButton};
use crate::ui_modal::{ModalButton, ModalScrim};
use crate::ui_theme::SPACE_2;
pub(super) fn handle_volume_keys(
keys: Res<ButtonInput<KeyCode>>,
mut settings: ResMut<SettingsResource>,
path: Res<SettingsStoragePath>,
mut changed: MessageWriter<SettingsChangedEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
let mut delta = 0.0_f32;
if keys.just_pressed(KeyCode::BracketLeft) {
delta -= SFX_STEP;
}
if keys.just_pressed(KeyCode::BracketRight) {
delta += SFX_STEP;
}
if delta == 0.0 {
return;
}
let before = settings.0.sfx_volume;
let after = settings.0.adjust_sfx_volume(delta);
if (before - after).abs() < f32::EPSILON {
return;
}
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
toast.write(InfoToastEvent(format!(
"SFX volume: {}%",
(after * 100.0).round() as i32
)));
}
/// Opens or closes the Settings panel — `O` keyboard accelerator or
/// `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;
}
}
/// Reacts to button presses inside the Settings panel.
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
pub(super) fn handle_settings_buttons(
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
mut settings: ResMut<SettingsResource>,
mut screen: ResMut<SettingsScreen>,
path: Res<SettingsStoragePath>,
mut changed: MessageWriter<SettingsChangedEvent>,
mut sfx_text: Query<
&mut Text,
(
With<SfxVolumeText>,
Without<MusicVolumeText>,
Without<DrawModeText>,
Without<ThemeText>,
Without<AnimSpeedText>,
Without<ColorBlindText>,
Without<HighContrastText>,
Without<ReduceMotionText>,
),
>,
mut music_text: Query<
&mut Text,
(
With<MusicVolumeText>,
Without<SfxVolumeText>,
Without<DrawModeText>,
Without<ThemeText>,
Without<AnimSpeedText>,
Without<ColorBlindText>,
Without<HighContrastText>,
Without<ReduceMotionText>,
),
>,
mut draw_text: Query<
&mut Text,
(
With<DrawModeText>,
Without<SfxVolumeText>,
Without<MusicVolumeText>,
Without<ThemeText>,
Without<AnimSpeedText>,
Without<ColorBlindText>,
Without<HighContrastText>,
Without<ReduceMotionText>,
),
>,
mut theme_text: Query<
&mut Text,
(
With<ThemeText>,
Without<SfxVolumeText>,
Without<MusicVolumeText>,
Without<DrawModeText>,
Without<AnimSpeedText>,
Without<ColorBlindText>,
Without<HighContrastText>,
Without<ReduceMotionText>,
),
>,
mut anim_speed_text: Query<
&mut Text,
(
With<AnimSpeedText>,
Without<SfxVolumeText>,
Without<MusicVolumeText>,
Without<DrawModeText>,
Without<ThemeText>,
Without<ColorBlindText>,
Without<HighContrastText>,
Without<ReduceMotionText>,
),
>,
mut color_blind_text: Query<
&mut Text,
(
With<ColorBlindText>,
Without<SfxVolumeText>,
Without<MusicVolumeText>,
Without<DrawModeText>,
Without<ThemeText>,
Without<AnimSpeedText>,
Without<HighContrastText>,
Without<ReduceMotionText>,
),
>,
mut high_contrast_text: Query<
&mut Text,
(
With<HighContrastText>,
Without<SfxVolumeText>,
Without<MusicVolumeText>,
Without<DrawModeText>,
Without<ThemeText>,
Without<AnimSpeedText>,
Without<ColorBlindText>,
Without<ReduceMotionText>,
),
>,
mut reduce_motion_text: Query<
&mut Text,
(
With<ReduceMotionText>,
Without<SfxVolumeText>,
Without<MusicVolumeText>,
Without<DrawModeText>,
Without<ThemeText>,
Without<AnimSpeedText>,
Without<ColorBlindText>,
Without<HighContrastText>,
),
>,
) {
for (interaction, button) in &interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
match button {
SettingsButton::SfxDown => {
let before = settings.0.sfx_volume;
let after = settings.0.adjust_sfx_volume(-SFX_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = sfx_text.single_mut() {
**t = format!("{after:.2}");
}
}
}
SettingsButton::SfxUp => {
let before = settings.0.sfx_volume;
let after = settings.0.adjust_sfx_volume(SFX_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = sfx_text.single_mut() {
**t = format!("{after:.2}");
}
}
}
SettingsButton::MusicDown => {
let before = settings.0.music_volume;
let after = settings.0.adjust_music_volume(-SFX_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = music_text.single_mut() {
**t = format!("{after:.2}");
}
}
}
SettingsButton::MusicUp => {
let before = settings.0.music_volume;
let after = settings.0.adjust_music_volume(SFX_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = music_text.single_mut() {
**t = format!("{after:.2}");
}
}
}
SettingsButton::ToggleDrawMode => {
settings.0.draw_mode = match settings.0.draw_mode {
DrawStockConfig::DrawOne => DrawStockConfig::DrawThree,
DrawStockConfig::DrawThree => DrawStockConfig::DrawOne,
};
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = draw_text.single_mut() {
**t = draw_mode_label(&settings.0.draw_mode);
}
}
SettingsButton::CycleAnimSpeed => {
settings.0.animation_speed = match settings.0.animation_speed {
AnimSpeed::Normal => AnimSpeed::Fast,
AnimSpeed::Fast => AnimSpeed::Instant,
AnimSpeed::Instant => AnimSpeed::Normal,
};
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = anim_speed_text.single_mut() {
**t = anim_speed_label(&settings.0.animation_speed);
}
}
SettingsButton::TooltipDelayDown => {
let before = settings.0.tooltip_delay_secs;
let after = settings.0.adjust_tooltip_delay(-TOOLTIP_DELAY_STEP_SECS);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
// The Text node is refreshed by `update_tooltip_delay_text`
// on the next frame via `settings.is_changed()`.
}
}
SettingsButton::TooltipDelayUp => {
let before = settings.0.tooltip_delay_secs;
let after = settings.0.adjust_tooltip_delay(TOOLTIP_DELAY_STEP_SECS);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
}
}
SettingsButton::TimeBonusDown => {
let before = settings.0.time_bonus_multiplier;
let after = settings
.0
.adjust_time_bonus_multiplier(-TIME_BONUS_MULTIPLIER_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
// The Text node is refreshed by
// `update_time_bonus_multiplier_text` on the next
// frame via `settings.is_changed()`.
}
}
SettingsButton::TimeBonusUp => {
let before = settings.0.time_bonus_multiplier;
let after = settings
.0
.adjust_time_bonus_multiplier(TIME_BONUS_MULTIPLIER_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
}
}
SettingsButton::ReplayMoveIntervalDown => {
let before = settings.0.replay_move_interval_secs;
let after = settings
.0
.adjust_replay_move_interval(-REPLAY_MOVE_INTERVAL_STEP_SECS);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
// The Text node is refreshed by
// `update_replay_move_interval_text` on the next
// frame via `settings.is_changed()`.
}
}
SettingsButton::ReplayMoveIntervalUp => {
let before = settings.0.replay_move_interval_secs;
let after = settings
.0
.adjust_replay_move_interval(REPLAY_MOVE_INTERVAL_STEP_SECS);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
}
}
SettingsButton::ToggleTheme => {
settings.0.theme = match settings.0.theme {
Theme::Green => Theme::Blue,
Theme::Blue => Theme::Dark,
Theme::Dark => Theme::Green,
};
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = theme_text.single_mut() {
**t = theme_label(&settings.0.theme);
}
}
SettingsButton::ToggleColorBlind => {
settings.0.color_blind_mode = !settings.0.color_blind_mode;
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = color_blind_text.single_mut() {
**t = color_blind_label(settings.0.color_blind_mode);
}
}
SettingsButton::ToggleHighContrast => {
settings.0.high_contrast_mode = !settings.0.high_contrast_mode;
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = high_contrast_text.single_mut() {
**t = on_off_label(settings.0.high_contrast_mode);
}
}
SettingsButton::ToggleReduceMotion => {
settings.0.reduce_motion_mode = !settings.0.reduce_motion_mode;
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = reduce_motion_text.single_mut() {
**t = on_off_label(settings.0.reduce_motion_mode);
}
}
SettingsButton::ToggleTouchInputMode => {
use solitaire_data::settings::TouchInputMode;
settings.0.touch_input_mode = match settings.0.touch_input_mode {
TouchInputMode::OneTap => TouchInputMode::TapToSelect,
TouchInputMode::TapToSelect => TouchInputMode::OneTap,
};
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
// Text refreshed by `update_touch_input_mode_text` next frame.
}
SettingsButton::ToggleWinnableDealsOnly => {
settings.0.winnable_deals_only = !settings.0.winnable_deals_only;
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
// The Text node is refreshed by `update_winnable_deals_only_text`
// on the next frame via `settings.is_changed()`.
}
SettingsButton::ToggleAnalytics => {
settings.0.analytics_enabled = !settings.0.analytics_enabled;
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
// Text refreshed by `update_analytics_enabled_text` next frame.
}
SettingsButton::ToggleSmartDefaultSize => {
settings.0.disable_smart_default_size = !settings.0.disable_smart_default_size;
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
// The Text node is refreshed by
// `update_smart_default_size_text` next frame. The
// sizer system is gated only at startup, so flipping
// this mid-session takes effect on the next launch —
// documented on the field in `solitaire_data::Settings`.
}
SettingsButton::SelectCardBack(idx) => {
settings.0.selected_card_back = *idx;
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
}
SettingsButton::SelectBackground(idx) => {
settings.0.selected_background = *idx;
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
}
SettingsButton::SelectTheme(theme_id) => {
if settings.0.selected_theme_id != *theme_id {
settings.0.selected_theme_id = theme_id.clone();
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
}
}
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::ScanThemes => {
// Handled by `handle_scan_themes`.
}
SettingsButton::SyncNow
| SettingsButton::ConnectSync
| SettingsButton::DisconnectSync
| SettingsButton::DeleteAccount => {
// Handled by `handle_sync_buttons`.
}
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::OpenThemeStore => {
// Handled by `handle_sync_buttons`.
}
SettingsButton::Done => {
screen.0 = false;
}
}
}
}
/// 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.
pub(super) fn handle_sync_buttons(
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
#[cfg(not(target_arch = "wasm32"))] mut open_theme_store: MessageWriter<
crate::events::ThemeStoreOpenRequestEvent,
>,
mut screen: ResMut<SettingsScreen>,
) {
for (interaction, button) in &interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
match button {
SettingsButton::SyncNow => {
manual_sync.write(ManualSyncRequestEvent);
}
SettingsButton::ConnectSync => {
// Close settings before the sync-setup modal opens so the
// guard in open_sync_setup_modal doesn't block on our own scrim.
screen.0 = false;
configure_sync.write(SyncConfigureRequestEvent);
}
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::OpenThemeStore => {
// Same shape as ConnectSync: close settings first so the
// store modal's own-scrim guard doesn't block.
screen.0 = false;
open_theme_store.write(crate::events::ThemeStoreOpenRequestEvent);
}
SettingsButton::DisconnectSync => {
logout_sync.write(SyncLogoutRequestEvent);
}
SettingsButton::DeleteAccount => {
delete_account.write(DeleteAccountRequestEvent);
}
_ => {}
}
}
}
/// Auto-attaches [`Focusable`] to every bespoke Settings button — icon
/// buttons (volume +/, toggle, cycle), swatch buttons (card-back,
/// background pickers), and the "Sync Now" button. The "Done" button is
/// already tagged by `attach_focusable_to_modal_buttons` (it carries
/// [`ModalButton`]) and is filtered out here.
///
/// Walks ancestors via [`ChildOf`] to find the [`ModalScrim`] that owns
/// the panel so the new [`Focusable`]'s group is bound to that scrim —
/// same defensive shape as the Phase 1 / 2 attach systems.
#[allow(clippy::type_complexity)]
pub(super) fn attach_focusable_to_settings_buttons(
mut commands: Commands,
new_buttons: Query<
(Entity, &SettingsButton),
(With<Button>, Without<Focusable>, Without<ModalButton>),
>,
parents: Query<&ChildOf>,
scrims: Query<(), With<ModalScrim>>,
) {
for (button, settings_button) in &new_buttons {
let mut current = button;
let mut scrim_entity: Option<Entity> = None;
for _ in 0..32 {
if scrims.get(current).is_ok() {
scrim_entity = Some(current);
break;
}
match parents.get(current) {
Ok(parent) => current = parent.parent(),
Err(_) => break,
}
}
if let Some(scrim) = scrim_entity {
commands.entity(button).insert(Focusable {
group: FocusGroup::Modal(scrim),
order: settings_button.focus_order(),
});
}
}
}
/// Vertical padding (logical px) added around the focused button when
/// scrolling it into view. Keeps the focus ring's halo visible above /
/// below the viewport edge.
const FOCUS_SCROLL_PADDING: f32 = SPACE_2;
/// When the focused entity sits outside the visible Settings scroll
/// viewport, adjust the viewport's [`ScrollPosition`] so the button is
/// fully visible. No-op when:
///
/// - `FocusedButton` is `None`
/// - the focused entity has no [`UiGlobalTransform`] / [`ComputedNode`]
/// (e.g. a freshly-spawned modal hasn't laid out yet)
/// - the focused entity is not a descendant of the
/// [`SettingsPanelScrollable`] container
///
/// The viewport's visible Y range is `[scroll_y, scroll_y +
/// viewport_height]` in physical pixels (matching `ComputedNode.size`).
/// The focused button's vertical extent is computed from its
/// `UiGlobalTransform.translation.y` (centre, physical) ± half its
/// `ComputedNode.size.y`. Because the scroll container's local
/// coordinates run [0, content_height] and the visible window is
/// [scroll_y, scroll_y + viewport], we convert the button's window-
/// space Y to container-local Y by subtracting the container's window-
/// space top and adding the current scroll offset.
#[allow(clippy::type_complexity)]
pub(super) fn scroll_focus_into_view(
focused: Res<FocusedButton>,
parents: Query<&ChildOf>,
nodes: Query<(&UiGlobalTransform, &ComputedNode)>,
mut containers: Query<
(&mut ScrollPosition, &UiGlobalTransform, &ComputedNode),
With<SettingsPanelScrollable>,
>,
) {
let Some(target) = focused.0 else { return };
// Gather button geometry.
let Ok((target_transform, target_node)) = nodes.get(target) else {
return;
};
// Walk ancestors looking for the scroll container. Bounded to keep
// a malformed hierarchy from hanging the system.
let mut current = target;
let mut container_entity: Option<Entity> = None;
for _ in 0..32 {
if containers.get(current).is_ok() {
container_entity = Some(current);
break;
}
match parents.get(current) {
Ok(parent) => current = parent.parent(),
Err(_) => break,
}
}
let Some(container) = container_entity else {
return;
};
let Ok((mut scroll, container_transform, container_node)) = containers.get_mut(container)
else {
return;
};
// Geometry is reported in physical pixels by `ComputedNode.size` and
// `UiGlobalTransform.translation`. `ScrollPosition` is in logical px,
// so convert via `inverse_scale_factor` before we write.
let inv = target_node.inverse_scale_factor;
let target_height = target_node.size().y;
let target_centre_y = target_transform.translation.y;
let target_top = target_centre_y - target_height * 0.5;
let target_bottom = target_centre_y + target_height * 0.5;
let container_height = container_node.size().y;
let container_top = container_transform.translation.y - container_height * 0.5;
// Convert button window-space Y to container-local Y. The container
// is currently scrolled by `scroll.0.y` *logical* pixels — multiply
// by physical-per-logical to compare with physical pixel extents.
let scroll_phys = scroll.0.y / inv.max(f32::EPSILON);
let viewport_top = container_top + scroll_phys;
let viewport_bottom = viewport_top + container_height;
// Layout may not have run yet (zero size on first frame) — no
// sensible scroll target until the container has dimensions.
if container_height <= 0.0 {
return;
}
let pad_phys = FOCUS_SCROLL_PADDING / inv.max(f32::EPSILON);
if target_top < viewport_top {
// Button extends above the viewport — scroll up.
let new_top = target_top - pad_phys;
let delta = new_top - viewport_top;
scroll.0.y = ((scroll_phys + delta) * inv).max(0.0);
} else if target_bottom > viewport_bottom {
// Button extends below the viewport — scroll down.
let new_bottom = target_bottom + pad_phys;
let delta = new_bottom - viewport_bottom;
scroll.0.y = ((scroll_phys + delta) * inv).max(0.0);
}
}
/// Scrolls the settings panel inner card in response to mouse-wheel events.
///
/// `offset_y` increases downward (0 = top of content). Scrolling down (ev.y < 0)
/// adds to the offset; scrolling up subtracts. Clamped to >= 0 so it never
/// scrolls past the top.
pub(super) fn scroll_settings_panel(
mut scroll_evr: MessageReader<MouseWheel>,
screen: Res<SettingsScreen>,
mut scrollables: Query<&mut ScrollPosition, With<SettingsPanelScrollable>>,
) {
if !screen.0 {
scroll_evr.clear();
return;
}
let delta_y: f32 = scroll_evr
.read()
.map(|ev| match ev.unit {
MouseScrollUnit::Line => ev.y * 50.0,
MouseScrollUnit::Pixel => ev.y,
})
.sum();
if delta_y == 0.0 {
return;
}
for mut sp in scrollables.iter_mut() {
sp.0.y = (sp.0.y - delta_y).max(0.0);
}
}
// ---------------------------------------------------------------------------
// Window geometry persistence
// ---------------------------------------------------------------------------
+647
View File
@@ -0,0 +1,647 @@
//! Persists `solitaire_data::Settings`, exposes hotkeys for live tuning,
//! and renders a Bevy UI Settings panel.
//!
//! Hotkeys (always active, no overlay required):
//! - `[` — decrease SFX volume by `SFX_STEP`
//! - `]` — increase SFX volume by `SFX_STEP`
//! - `O` — open / close the Settings panel
//!
//! On change, the plugin persists `settings.json` and fires
//! `SettingsChangedEvent` so dependents (e.g. `AudioPlugin`) can react.
use std::path::PathBuf;
use bevy::input::mouse::MouseWheel;
use bevy::prelude::*;
use bevy::window::{WindowMoved, WindowResized};
use solitaire_data::{
Settings, WindowGeometry, load_settings_from, save_settings_to, settings_file_path,
};
use crate::events::{
DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
SyncLogoutRequestEvent, ToggleSettingsRequestEvent,
};
use crate::resources::SettingsScrollPos;
use crate::theme::ThemeThumbnailPair;
mod input;
mod ui;
mod updates;
use input::*;
use ui::*;
use updates::*;
/// Side length of a swatch button in the card-back / background pickers.
/// Smaller than the smallest spacing rung so it stays a literal.
const SWATCH_PX: f32 = 40.0;
/// Side length of a small toggle / cycle button (e.g. the "⇄" affordances).
/// Sub-rung sizing — kept as a literal, see SWATCH_PX. 32 px meets the
/// minimum desktop hit-target threshold while staying smaller than `SWATCH_PX`.
const ICON_BUTTON_PX: f32 = 32.0;
/// Volume adjustment step applied by the `[` / `]` hotkeys.
pub const SFX_STEP: f32 = 0.1;
/// Bevy resource wrapping the current `Settings`.
#[derive(Resource, Debug, Clone)]
pub struct SettingsResource(pub Settings);
/// Persistence path for `SettingsResource`. `None` disables I/O (used in tests).
#[derive(Resource, Debug, Clone)]
pub struct SettingsStoragePath(pub Option<PathBuf>);
/// Whether the Settings panel is currently visible. Toggle with `O`.
#[derive(Resource, Debug, Clone, Default)]
pub struct SettingsScreen(pub bool);
/// Debounce window for persisting window-geometry changes, in seconds.
///
/// `WindowResized` and `WindowMoved` fire continuously during a resize/
/// move drag, so writing to disk on every event would thrash the file
/// system. Instead the geometry-watch system records the pending value
/// and waits this long after the *last* event before saving.
pub const WINDOW_GEOMETRY_DEBOUNCE_SECS: f32 = 0.5;
/// Tracks a pending window-geometry change so the saver can debounce
/// `WindowResized` / `WindowMoved` storms during a resize / move drag.
#[derive(Resource, Debug, Default, Clone, Copy)]
pub struct PendingWindowGeometry {
/// Most recent observed geometry. `None` when nothing is pending.
pub geometry: Option<WindowGeometry>,
/// `Time::elapsed_secs()` value at which `geometry` was last updated.
pub last_changed_secs: f32,
}
/// Fired whenever settings change so consumers (audio, UI) can react.
#[derive(Message, Debug, Clone)]
pub struct SettingsChangedEvent(pub Settings);
/// System set for the systems that mutate [`SettingsResource`] every frame
/// (hotkeys and window-geometry persistence). Ordered before
/// [`crate::game_plugin::GameMutation`]; readers of settings should sit
/// after this set (directly, or transitively via `.after(GameMutation)`)
/// so they observe the current frame's settings deterministically (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct SettingsMutation;
/// Marker on the root Settings panel entity.
#[derive(Component, Debug)]
pub struct SettingsPanel;
/// Marks the `Text` node showing the live SFX volume value.
#[derive(Component, Debug)]
struct SfxVolumeText;
/// Marks the `Text` node showing the live music volume value.
#[derive(Component, Debug)]
struct MusicVolumeText;
/// Marks the `Text` node showing the current draw mode.
#[derive(Component, Debug)]
struct DrawModeText;
/// Marks the `Text` node showing the current theme.
#[derive(Component, Debug)]
struct ThemeText;
/// Marks the `Text` node showing the live sync status.
#[derive(Component, Debug)]
struct SyncStatusText;
/// Marks the `Text` node showing the active card-back index.
#[derive(Component, Debug)]
struct CardBackText;
/// Marks the `Text` node showing the current animation speed.
#[derive(Component, Debug)]
struct AnimSpeedText;
/// Marks the `Text` node showing the active background index.
#[derive(Component, Debug)]
struct BackgroundText;
/// Marks the `Text` node showing the current color-blind mode state.
#[derive(Component, Debug)]
struct ColorBlindText;
/// Marks the `Text` node showing the current high-contrast mode state.
#[derive(Component, Debug)]
struct HighContrastText;
/// Marks the `Text` node showing the current reduce-motion mode state.
#[derive(Component, Debug)]
struct ReduceMotionText;
/// Marks the `Text` node showing the current touch input mode state.
#[derive(Component, Debug)]
struct TouchInputModeText;
/// Marks the `Text` node showing the live tooltip-delay value.
#[derive(Component, Debug)]
struct TooltipDelayText;
/// Marks the `Text` node showing the live time-bonus-multiplier value.
#[derive(Component, Debug)]
struct TimeBonusMultiplierText;
/// Marks the `Text` node showing the live replay-playback per-move
/// interval value. The Gameplay-section row beside this label lets the
/// player tune `Settings::replay_move_interval_secs`.
#[derive(Component, Debug)]
struct ReplayMoveIntervalText;
/// Marks the `Text` node showing the current "Winnable deals only"
/// state ("ON" / "OFF") in the Gameplay section.
#[derive(Component, Debug)]
struct WinnableDealsOnlyText;
/// Marks the `Text` node showing the current "Smart window size"
/// state ("ON" / "OFF") in the Gameplay section. The flag is stored
/// negatively in `Settings::disable_smart_default_size`, so the
/// label inverts: "ON" = smart sizing enabled (the default).
#[derive(Component, Debug)]
struct SmartDefaultSizeText;
/// Marks the `Text` node showing the current "Share usage data" (analytics)
/// state ("ON" / "OFF") in the Privacy section.
#[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;
/// Marks the scrollable inner card so its `ScrollPosition` can be read before despawn.
#[derive(Component, Debug)]
struct SettingsScrollNode;
/// Snapshot row used by [`spawn_settings_panel`] to render the card-art
/// theme picker. Carries the `ThemeRegistry` entry's display fields plus
/// the (optional) thumbnail pair from [`ThemeThumbnailCache`]. A `None`
/// thumbnail means the picker should render a placeholder swatch — used
/// when the cache hasn't generated handles yet, or when a user theme
/// is missing one of the required preview SVGs.
#[derive(Debug, Clone)]
struct ThemePickerEntry {
/// Stable theme id (matches `ThemeMeta::id`).
id: String,
/// Player-facing label.
display_name: String,
/// Pre-generated picker preview pair, when ready. `None` collapses
/// the chip to its plain-text fallback.
thumbnails: Option<ThemeThumbnailPair>,
}
/// Tags interactive buttons inside the Settings panel.
#[derive(Component, Debug)]
enum SettingsButton {
SfxDown,
SfxUp,
MusicDown,
MusicUp,
ToggleDrawMode,
CycleAnimSpeed,
/// Decrement the tooltip-hover dwell delay by one step.
TooltipDelayDown,
/// Increment the tooltip-hover dwell delay by one step.
TooltipDelayUp,
/// Decrement the cosmetic time-bonus multiplier by one step.
TimeBonusDown,
/// Increment the cosmetic time-bonus multiplier by one step.
TimeBonusUp,
/// Decrement the replay-playback per-move interval by one step
/// (i.e. speed playback up).
ReplayMoveIntervalDown,
/// Increment the replay-playback per-move interval by one step
/// (i.e. slow playback down).
ReplayMoveIntervalUp,
ToggleTheme,
ToggleColorBlind,
/// Toggle the [`Settings::high_contrast_mode`] flag — boosts
/// foreground / suit-red glyphs to higher-luminance variants per
/// `design-system.md` §Accessibility (#2).
ToggleHighContrast,
/// Toggle the [`Settings::reduce_motion_mode`] flag — suppresses
/// non-essential motion (card-slide animations become instant
/// snaps) per `design-system.md` §Accessibility (#3).
ToggleReduceMotion,
/// Toggle [`Settings::touch_input_mode`] between `OneTap`
/// (auto-move on tap, default) and `TapToSelect` (first tap selects
/// a card/stack, second tap on a target pile moves it).
ToggleTouchInputMode,
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
/// random Classic-mode deals are filtered through
/// [`solitaire_core::game_state::GameState::solve_fresh_deal`] until one is provably
/// winnable (or the retry cap is hit). Off by default.
ToggleWinnableDealsOnly,
/// Toggle the inverse of [`Settings::disable_smart_default_size`].
/// When the visible label reads "ON", the launch-time window
/// sizer scales the window to ~70 % of the primary monitor on a
/// fresh install; "OFF" pins the literal 1280×800 baseline. The
/// flag only affects launches without saved geometry — the
/// player's last window size always wins.
ToggleSmartDefaultSize,
/// Toggle [`Settings::analytics_enabled`]. Only rendered when a
/// Matomo URL is configured.
ToggleAnalytics,
/// Scan `user_theme_dir()` for new `.zip` files and import each one.
#[cfg(not(target_arch = "wasm32"))]
ScanThemes,
/// Open the in-game theme-store modal (closes Settings first).
#[cfg(not(target_arch = "wasm32"))]
OpenThemeStore,
SyncNow,
/// Open the sync-server Connect modal (shown when backend = Local).
ConnectSync,
/// Disconnect from the sync server (shown when backend = SolitaireServer).
DisconnectSync,
/// Open the account-deletion confirmation modal.
DeleteAccount,
Done,
/// Select a specific card-back by index from the picker row.
SelectCardBack(usize),
/// Select a specific background by index from the picker row.
SelectBackground(usize),
/// Select a specific card-art theme by `meta.id` from the
/// `ThemeRegistry`. The string is owned so the click handler can
/// hand it directly to `Settings::selected_theme_id`.
SelectTheme(String),
}
impl SettingsButton {
/// Tab-walk priority — lower numbers visited first. Visual reading
/// order is top-to-bottom by section, left-to-right inside each row.
/// Two buttons in the same picker row receive the same `order`;
/// `handle_focus_keys` then breaks ties by entity index, which
/// matches `Children` spawn order inside each row.
fn focus_order(&self) -> i32 {
match self {
// Audio section
SettingsButton::SfxDown => 10,
SettingsButton::SfxUp => 11,
SettingsButton::MusicDown => 20,
SettingsButton::MusicUp => 21,
// Gameplay section
SettingsButton::ToggleDrawMode => 30,
SettingsButton::ToggleWinnableDealsOnly => 35,
SettingsButton::CycleAnimSpeed => 40,
SettingsButton::TooltipDelayDown => 45,
SettingsButton::TooltipDelayUp => 46,
SettingsButton::TimeBonusDown => 47,
SettingsButton::TimeBonusUp => 48,
// Replay-speed slider — last Gameplay-section row, so it
// sits between TimeBonusUp (48) and the Cosmetic section.
SettingsButton::ReplayMoveIntervalDown => 49,
SettingsButton::ReplayMoveIntervalUp => 49,
// Smart-default-size toggle — sits at the end of Gameplay.
SettingsButton::ToggleSmartDefaultSize => 50,
// Privacy section — just before Sync.
SettingsButton::ToggleAnalytics => 89,
// Cosmetic section
SettingsButton::ToggleTheme => 55,
SettingsButton::ToggleColorBlind => 60,
// Accessibility-section toggles sit alongside Color-blind so
// tab-walk visits all three a11y flags in the same vertical
// run before continuing to the picker rows.
SettingsButton::ToggleHighContrast => 61,
SettingsButton::ToggleReduceMotion => 62,
SettingsButton::ToggleTouchInputMode => 63,
// Picker rows — every swatch in a row shares the row's
// priority so entity-index tiebreaking yields left → right.
SettingsButton::SelectCardBack(_) => 70,
SettingsButton::SelectBackground(_) => 80,
SettingsButton::SelectTheme(_) => 85,
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::ScanThemes => 86,
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::OpenThemeStore => 87,
// Sync section
SettingsButton::SyncNow => 90,
SettingsButton::ConnectSync => 91,
SettingsButton::DisconnectSync => 92,
SettingsButton::DeleteAccount => 93,
// Done is tagged by `attach_focusable_to_modal_buttons` and
// never reaches `attach_focusable_to_settings_buttons`; the
// value here is only a fallback for completeness.
SettingsButton::Done => 100,
}
}
}
/// Plugin that owns the settings lifecycle.
pub struct SettingsPlugin {
/// Path to `settings.json`. `None` in headless/test mode.
pub storage_path: Option<PathBuf>,
/// When `false`, panel spawn/despawn systems are not registered.
/// Use [`SettingsPlugin::headless`] for tests running under `MinimalPlugins`.
pub ui_enabled: bool,
}
impl Default for SettingsPlugin {
fn default() -> Self {
Self {
storage_path: settings_file_path(),
ui_enabled: true,
}
}
}
impl SettingsPlugin {
/// No persistence, no UI — safe to use under `MinimalPlugins` in tests.
pub fn headless() -> Self {
Self {
storage_path: None,
ui_enabled: false,
}
}
}
impl Plugin for SettingsPlugin {
fn build(&self, app: &mut App) {
let loaded = match &self.storage_path {
Some(path) => load_settings_from(path),
None => Settings::default(),
};
app.insert_resource(SettingsResource(loaded))
.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>()
.add_message::<SyncConfigureRequestEvent>()
.add_message::<SyncLogoutRequestEvent>()
.add_message::<DeleteAccountRequestEvent>()
.add_message::<ToggleSettingsRequestEvent>()
.add_message::<crate::events::ThemeStoreOpenRequestEvent>()
.add_message::<InfoToastEvent>()
.add_message::<MouseWheel>()
.add_message::<TouchInput>()
// `WindowResized` / `WindowMoved` are real Bevy window events
// and emitted by the windowing backend under `DefaultPlugins`,
// but we register them explicitly here so the geometry watcher
// also runs cleanly under `MinimalPlugins` (tests).
.add_message::<WindowResized>()
.add_message::<WindowMoved>()
// Settings changes land before game logic runs: the mutator
// chain (volume keys → geometry record → geometry persist) is a
// deterministic spine, and the whole set precedes GameMutation so
// every reader already ordered after GameMutation sees this
// frame's settings transitively (ambiguity burn-down, #143).
.configure_sets(
Update,
SettingsMutation
.after(crate::layout::LayoutSystem::UpdateOnResize)
.before(crate::game_plugin::GameMutation),
)
.add_systems(
Update,
(
handle_volume_keys,
record_window_geometry_changes,
persist_window_geometry_after_debounce,
)
.chain()
.in_set(SettingsMutation),
)
.add_systems(
Update,
(
toggle_settings_screen
.before(crate::ui_focus::FocusKeys)
.ambiguous_with(crate::hud_plugin::HudButtons),
scroll_settings_panel,
crate::ui_modal::touch_scroll_panel::<SettingsPanelScrollable>,
)
.chain(),
);
if self.ui_enabled {
app.add_systems(
Update,
(
// 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"))]
handle_scan_themes,
update_sync_status_text,
update_card_back_text,
update_background_text,
update_anim_speed_text,
update_color_blind_text,
update_high_contrast_text,
update_high_contrast_borders.run_if(resource_changed::<SettingsResource>),
update_high_contrast_backgrounds.run_if(resource_changed::<SettingsResource>),
update_reduce_motion_text,
update_touch_input_mode_text,
update_tooltip_delay_text,
update_time_bonus_multiplier_text,
update_replay_move_interval_text,
update_winnable_deals_only_text,
update_smart_default_size_text,
),
);
app.add_systems(
Update,
(
update_analytics_enabled_text,
attach_focusable_to_settings_buttons,
),
);
app.add_systems(Update, scroll_focus_into_view);
}
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
fn persist(path: &SettingsStoragePath, settings: &Settings) {
let Some(target) = &path.0 else { return };
if let Err(e) = save_settings_to(target, settings) {
warn!("failed to save settings: {e}");
}
}
/// Pure helper: returns `true` when a pending geometry change has sat
/// quietly long enough to flush to disk.
///
/// Extracted so the debounce condition can be unit-tested without
/// spinning up a Bevy app.
fn should_persist_geometry(now_secs: f32, last_changed_secs: f32) -> bool {
(now_secs - last_changed_secs) >= WINDOW_GEOMETRY_DEBOUNCE_SECS
}
/// Returns the geometry implied by an event pair `(width, height, x, y)`,
/// using each component from `existing` when the corresponding event-derived
/// value is `None`. Returns `None` when neither side supplies width/height.
///
/// Pure helper so the merge logic can be unit-tested without an `App`.
fn merge_geometry(
existing: Option<WindowGeometry>,
new_size: Option<(u32, u32)>,
new_pos: Option<(i32, i32)>,
) -> Option<WindowGeometry> {
let (width, height) = new_size.or_else(|| existing.map(|g| (g.width, g.height)))?;
let (x, y) = new_pos
.or_else(|| existing.map(|g| (g.x, g.y)))
.unwrap_or((0, 0));
Some(WindowGeometry {
width,
height,
x,
y,
})
}
// ---------------------------------------------------------------------------
// Systems
// ---------------------------------------------------------------------------
/// Returns the next unlocked index after `current` in the sorted `unlocked` list.
/// Wraps around. Falls back to `unlocked[0]` if `current` is not found.
#[cfg(test)]
fn cycle_unlocked(unlocked: &[usize], current: usize) -> usize {
if unlocked.is_empty() {
return 0;
}
let pos = unlocked.iter().position(|&i| i == current).unwrap_or(0);
unlocked[(pos + 1) % unlocked.len()]
}
/// Records `WindowResized` and `WindowMoved` events into
/// [`PendingWindowGeometry`], coalescing every event arriving this frame
/// into the latest pending geometry.
///
/// The actual disk write is debounced — see
/// [`persist_window_geometry_after_debounce`] — so the file system isn't
/// hit on every pixel of a resize / move drag.
fn record_window_geometry_changes(
time: Res<Time>,
mut resized: MessageReader<WindowResized>,
mut moved: MessageReader<WindowMoved>,
settings: Res<SettingsResource>,
mut pending: ResMut<PendingWindowGeometry>,
) {
// Read .last() — only the final event matters for persistence; the
// intermediate sizes/positions are noise during a drag.
let new_size = resized.read().last().map(|ev| {
(
ev.width.round().max(0.0) as u32,
ev.height.round().max(0.0) as u32,
)
});
let new_pos = moved.read().last().map(|ev| (ev.position.x, ev.position.y));
if new_size.is_none() && new_pos.is_none() {
return;
}
// Fold the new components into the existing pending value (if any),
// otherwise into the persisted geometry from settings.
let baseline = pending.geometry.or(settings.0.window_geometry);
let Some(geometry) = merge_geometry(baseline, new_size, new_pos) else {
return;
};
pending.geometry = Some(geometry);
pending.last_changed_secs = time.elapsed_secs();
}
/// After [`WINDOW_GEOMETRY_DEBOUNCE_SECS`] of quiet (no `WindowResized` or
/// `WindowMoved` events arriving), commits the pending geometry to
/// `SettingsResource` and writes `settings.json`. Skips the write when the
/// pending value already matches the settings (e.g. a resize that was
/// reverted, or a synthetic event with no geometry change).
fn persist_window_geometry_after_debounce(
time: Res<Time>,
mut pending: ResMut<PendingWindowGeometry>,
mut settings: ResMut<SettingsResource>,
path: Res<SettingsStoragePath>,
mut changed: MessageWriter<SettingsChangedEvent>,
) {
let Some(new_geom) = pending.geometry else {
return;
};
if !should_persist_geometry(time.elapsed_secs(), pending.last_changed_secs) {
return;
}
// Always clear the pending slot regardless of whether we end up
// writing — otherwise an idempotent change would re-trigger this
// system every tick.
pending.geometry = None;
if settings.0.window_geometry == Some(new_geom) {
return;
}
settings.0.window_geometry = Some(new_geom);
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
}
// ---------------------------------------------------------------------------
// UI construction
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests;
@@ -0,0 +1,738 @@
use super::*;
use crate::ui_focus::{FocusRow, Focusable};
use crate::ui_modal::ModalButton;
use crate::ui_tooltip::Tooltip;
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(SettingsPlugin::headless());
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
fn press(app: &mut App, key: KeyCode) {
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(key);
input.clear();
input.press(key);
}
#[test]
fn defaults_are_loaded() {
let app = headless_app();
assert_eq!(
app.world().resource::<SettingsResource>().0,
Settings::default()
);
}
#[test]
fn pressing_left_bracket_decreases_volume_and_emits_event() {
let mut app = headless_app();
let before = app.world().resource::<SettingsResource>().0.sfx_volume;
press(&mut app, KeyCode::BracketLeft);
app.update();
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
assert!(after < before);
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(cursor.read(events).count(), 1);
}
#[test]
fn pressing_right_bracket_increases_volume() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.sfx_volume = 0.5;
press(&mut app, KeyCode::BracketRight);
app.update();
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
assert!((after - 0.6).abs() < 1e-3);
}
#[test]
fn clamped_change_does_not_emit_event() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.sfx_volume = 1.0;
press(&mut app, KeyCode::BracketRight);
app.update();
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(cursor.read(events).count(), 0);
}
#[test]
fn volume_clamped_at_zero_does_not_emit_event() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.sfx_volume = 0.0;
press(&mut app, KeyCode::BracketLeft);
app.update();
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
assert!(after >= 0.0, "volume must not go below zero");
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(
cursor.read(events).count(),
0,
"no event when clamped at floor"
);
}
/// 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();
assert!(
!app.world().resource::<SettingsScreen>().0,
"screen is closed initially"
);
press(&mut app, KeyCode::KeyO);
app.update();
assert!(
app.world().resource::<SettingsScreen>().0,
"O opens settings"
);
press(&mut app, KeyCode::KeyO);
app.update();
assert!(
!app.world().resource::<SettingsScreen>().0,
"second O closes settings"
);
}
/// 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() {
// [0, 1, 2] → cycling from 2 wraps to 0
assert_eq!(cycle_unlocked(&[0, 1, 2], 2), 0);
}
#[test]
fn cycle_unlocked_advances_normally() {
assert_eq!(cycle_unlocked(&[0, 1, 2], 0), 1);
assert_eq!(cycle_unlocked(&[0, 1, 2], 1), 2);
}
#[test]
fn cycle_unlocked_single_element_stays() {
// Only one unlockable — cycling always returns it.
assert_eq!(cycle_unlocked(&[0], 0), 0);
}
#[test]
fn cycle_unlocked_current_not_in_list_falls_back_to_second() {
// current=5 is not in [0,1,2]; falls back to pos=0, so next = unlocked[1] = 1
assert_eq!(cycle_unlocked(&[0, 1, 2], 5), 1);
}
#[test]
fn cycle_unlocked_empty_returns_zero() {
assert_eq!(cycle_unlocked(&[], 0), 0);
}
#[test]
fn scroll_is_noop_when_settings_panel_closed() {
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
let mut app = headless_app();
// Panel starts closed (SettingsScreen(false)); spawn a scrollable entity.
let entity = app
.world_mut()
.spawn((SettingsPanelScrollable, ScrollPosition::default()))
.id();
// Send a downward scroll event while the panel is closed.
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: -3.0,
window: Entity::PLACEHOLDER,
});
app.update();
// ScrollPosition must remain at 0.0 — panel was closed.
let offset = app
.world()
.entity(entity)
.get::<ScrollPosition>()
.unwrap()
.0
.y;
assert_eq!(offset, 0.0, "scroll must not move when panel is closed");
}
#[test]
fn scroll_moves_offset_when_panel_open() {
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
let mut app = headless_app();
// Open the panel.
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
// Spawn a scrollable entity with an existing offset so we can distinguish clamping.
let entity = app
.world_mut()
.spawn((
SettingsPanelScrollable,
ScrollPosition(Vec2::new(0.0, 100.0)),
))
.id();
// Scroll down by 2 lines (50 px/line → +100 px added to offset_y).
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: -2.0,
window: Entity::PLACEHOLDER,
});
app.update();
let offset = app
.world()
.entity(entity)
.get::<ScrollPosition>()
.unwrap()
.0
.y;
assert!(
(offset - 200.0).abs() < 1e-3,
"scrolling down should increase offset_y; got {offset}"
);
}
// -----------------------------------------------------------------------
// Phase 3 — keyboard focus ring, Settings buttons + FocusRow
// -----------------------------------------------------------------------
/// Headless app that runs the *real* (UI-enabled) `SettingsPlugin`
/// alongside `UiModalPlugin` and `UiFocusPlugin`, so the spawn /
/// auto-tag systems fire end-to-end without writing to disk.
fn headless_app_with_focus() -> App {
use crate::ui_focus::UiFocusPlugin;
use crate::ui_modal::UiModalPlugin;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin)
.add_plugins(SettingsPlugin {
// No persistence — keep the test isolated.
storage_path: None,
ui_enabled: true,
});
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
#[test]
fn settings_buttons_get_focusable_marker() {
let mut app = headless_app_with_focus();
// 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);
}
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!(
total_tagged >= 20,
"expected the five tabs to spawn many bespoke buttons; got {total_tagged}"
);
}
/// Every bespoke `SettingsButton` (volume +/, toggles, swatches,
/// Sync Now) must spawn with a `Tooltip` so the glyph-only icons and
/// indexed swatches carry hover-reveal context. Mirrors
/// `settings_buttons_get_focusable_marker` (Phase 3 focus test) so
/// the invariant — every interactive Settings element except the
/// `Done` modal button has a tooltip — is asserted consistently.
#[test]
fn settings_buttons_carry_tooltip() {
let mut app = headless_app_with_focus();
// 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);
}
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!(
total_tipped >= 20,
"expected the five tabs to spawn many tooltipped buttons; got {total_tipped}"
);
// The loop ends on the Account tab, where with default (Local)
// settings the Connect button spawns.
// We verify its tooltip carries the canonical microcopy.
let connect_tip = app
.world_mut()
.query::<(&SettingsButton, &Tooltip)>()
.iter(app.world())
.find_map(|(btn, tip)| matches!(btn, SettingsButton::ConnectSync).then(|| tip.0.clone()))
.expect("Connect button should spawn with a Tooltip when backend is Local");
assert_eq!(
connect_tip.as_ref(),
"Connect to a self-hosted Ferrous Solitaire sync server.",
"ConnectSync tooltip must use the canonical microcopy"
);
}
#[test]
fn settings_picker_rows_get_focus_row_marker() {
let mut app = headless_app_with_focus();
// 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.
let row_count = app
.world_mut()
.query_filtered::<Entity, With<FocusRow>>()
.iter(app.world())
.count();
assert!(
row_count >= 2,
"expected at least two FocusRow containers (card-back + background); got {row_count}"
);
}
/// 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
/// rendered chip carries a [`ThemeThumbnailMarker`]-tagged
/// `ImageNode` for each preview slot.
#[test]
fn theme_picker_chip_includes_thumbnail_sprite_when_thumbnails_loaded() {
use crate::theme::{ThemeEntry, ThemeRegistry, ThemeThumbnailCache, ThemeThumbnailPair};
let mut app = headless_app_with_focus();
// Prime an Assets<Image> resource so we can mint stable handles
// for the synthetic thumbnail pair.
app.init_resource::<Assets<Image>>();
let (ace_handle, back_handle) = {
let mut images = app.world_mut().resource_mut::<Assets<Image>>();
let ace = images.add(Image::default());
let back = images.add(Image::default());
(ace, back)
};
// Inject one theme entry + a matching thumbnail pair.
app.insert_resource(ThemeRegistry {
entries: vec![ThemeEntry {
id: "test_theme".into(),
display_name: "Test Theme".into(),
manifest_url: "themes://test_theme/theme.ron".into(),
meta: crate::theme::ThemeMeta {
id: "test_theme".into(),
name: "Test Theme".into(),
author: "x".into(),
version: "x".into(),
card_aspect: (2, 3),
},
}],
});
let mut cache = ThemeThumbnailCache::default();
cache.entries.insert(
"test_theme".into(),
ThemeThumbnailPair {
ace: ace_handle.clone(),
back: back_handle.clone(),
},
);
app.insert_resource(cache);
// 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
// two of them (ace + back).
let thumbnail_count = app
.world_mut()
.query_filtered::<&ImageNode, With<ThemeThumbnailMarker>>()
.iter(app.world())
.count();
assert!(
thumbnail_count >= 2,
"expected at least one ace + back thumbnail (2 sprites); got {thumbnail_count}"
);
// Spot-check: at least one thumbnail's image handle matches one
// of the ones we inserted into the cache. This guards against a
// future refactor that accidentally clones the wrong handle.
let any_matches = app
.world_mut()
.query_filtered::<&ImageNode, With<ThemeThumbnailMarker>>()
.iter(app.world())
.any(|node| node.image == ace_handle || node.image == back_handle);
assert!(
any_matches,
"at least one rendered thumbnail must reuse the cached handle"
);
}
// -----------------------------------------------------------------------
// Window geometry persistence
// -----------------------------------------------------------------------
#[test]
fn should_persist_geometry_respects_debounce_window() {
// Within the debounce window: not yet.
assert!(!should_persist_geometry(10.0, 9.7));
assert!(!should_persist_geometry(
10.0,
10.0 - WINDOW_GEOMETRY_DEBOUNCE_SECS + 0.01
));
// Exactly the debounce window: allowed (>= comparison).
assert!(should_persist_geometry(
10.0,
10.0 - WINDOW_GEOMETRY_DEBOUNCE_SECS
));
// Well past the debounce window: allowed.
assert!(should_persist_geometry(20.0, 10.0));
}
#[test]
fn merge_geometry_uses_existing_when_event_components_missing() {
let existing = WindowGeometry {
width: 1280,
height: 800,
x: 100,
y: 50,
};
// Position-only event keeps existing size.
let merged = merge_geometry(Some(existing), None, Some((200, 75))).unwrap();
assert_eq!(merged.width, 1280);
assert_eq!(merged.height, 800);
assert_eq!(merged.x, 200);
assert_eq!(merged.y, 75);
// Size-only event keeps existing position.
let merged = merge_geometry(Some(existing), Some((1024, 768)), None).unwrap();
assert_eq!(merged.width, 1024);
assert_eq!(merged.height, 768);
assert_eq!(merged.x, 100);
assert_eq!(merged.y, 50);
}
#[test]
fn merge_geometry_returns_none_when_size_unknown() {
// No existing geometry, no size in the event → can't fabricate one.
assert!(merge_geometry(None, None, Some((10, 20))).is_none());
}
/// Drives `app.update()` past [`WINDOW_GEOMETRY_DEBOUNCE_SECS`] using
/// `TimeUpdateStrategy::ManualDuration`. `Time<Virtual>` clamps each
/// frame's delta to `max_delta` (default 250 ms), so we step in 150 ms
/// slices and run enough ticks to comfortably exceed the debounce
/// window after the first record tick has set `last_changed_secs`.
fn advance_past_geometry_debounce(app: &mut App) {
use bevy::time::TimeUpdateStrategy;
use std::time::Duration;
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
0.15,
)));
// Tick 1 sets last_changed_secs from any pending events. Each
// subsequent tick advances the clock by 150 ms; five ticks total
// buys 0.75 s of elapsed time relative to the record tick — well
// past the 0.5 s debounce window.
for _ in 0..5 {
app.update();
}
}
fn fire_resize(app: &mut App, width: f32, height: f32) {
app.world_mut().write_message(WindowResized {
window: Entity::PLACEHOLDER,
width,
height,
});
}
fn fire_move(app: &mut App, x: i32, y: i32) {
app.world_mut().write_message(WindowMoved {
window: Entity::PLACEHOLDER,
position: IVec2::new(x, y),
});
}
#[test]
fn resize_event_then_quiet_persists_window_geometry() {
let mut app = headless_app();
// Sanity: geometry starts unset (default).
assert!(
app.world()
.resource::<SettingsResource>()
.0
.window_geometry
.is_none()
);
// Fire a resize, then go quiet for past the debounce.
fire_resize(&mut app, 1500.0, 950.0);
advance_past_geometry_debounce(&mut app);
let geom = app
.world()
.resource::<SettingsResource>()
.0
.window_geometry
.expect("geometry should be persisted after debounce");
assert_eq!(geom.width, 1500);
assert_eq!(geom.height, 950);
// Position not yet observed → defaults to 0, 0 since there was
// no existing geometry to fall back on.
assert_eq!(geom.x, 0);
assert_eq!(geom.y, 0);
}
#[test]
fn move_event_after_resize_updates_position_only() {
let mut app = headless_app();
// First, establish a baseline geometry via a resize event.
fire_resize(&mut app, 1280.0, 800.0);
advance_past_geometry_debounce(&mut app);
let baseline = app
.world()
.resource::<SettingsResource>()
.0
.window_geometry
.unwrap();
assert_eq!(baseline.width, 1280);
// Now fire a move-only event — size must be preserved from the
// existing geometry.
fire_move(&mut app, 250, 175);
advance_past_geometry_debounce(&mut app);
let geom = app
.world()
.resource::<SettingsResource>()
.0
.window_geometry
.unwrap();
assert_eq!(
geom.width, 1280,
"size must be preserved across a move-only update"
);
assert_eq!(geom.height, 800);
assert_eq!(geom.x, 250);
assert_eq!(geom.y, 175);
}
#[test]
fn rapid_resize_storm_only_persists_final_size() {
let mut app = headless_app();
// Burst of resize events on a single frame — only the last one
// should be the eventually-persisted size.
fire_resize(&mut app, 900.0, 600.0);
fire_resize(&mut app, 1100.0, 700.0);
fire_resize(&mut app, 1400.0, 850.0);
advance_past_geometry_debounce(&mut app);
let geom = app
.world()
.resource::<SettingsResource>()
.0
.window_geometry
.unwrap();
assert_eq!((geom.width, geom.height), (1400, 850));
}
#[test]
fn no_window_events_no_geometry_change() {
let mut app = headless_app();
// Just advance time — without any events, settings must stay clean.
advance_past_geometry_debounce(&mut app);
assert!(
app.world()
.resource::<SettingsResource>()
.0
.window_geometry
.is_none()
);
}
#[test]
fn scroll_clamps_offset_to_zero_at_top() {
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
let mut app = headless_app();
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
// Entity starts at 10 px offset.
let entity = app
.world_mut()
.spawn((
SettingsPanelScrollable,
ScrollPosition(Vec2::new(0.0, 10.0)),
))
.id();
// Scroll up by 5 lines → would subtract 250 px, but must clamp to 0.
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: 5.0,
window: Entity::PLACEHOLDER,
});
app.update();
let offset = app
.world()
.entity(entity)
.get::<ScrollPosition>()
.unwrap()
.0
.y;
assert_eq!(
offset, 0.0,
"scrolling past top must clamp to 0, got {offset}"
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,555 @@
//! Per-frame value-text updater systems and their label helpers.
use super::*;
use solitaire_core::DrawStockConfig;
use solitaire_data::{AnimSpeed, settings::Theme};
use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource;
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
use crate::theme::ThemeThumbnailCache;
use crate::ui_modal::ModalScrim;
use crate::ui_theme::{BORDER_SUBTLE_HC, HighContrastBackground, HighContrastBorder};
/// Spawns the Settings panel when `SettingsScreen` becomes `true`;
/// despawns it when it becomes `false`.
#[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>>,
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 !screen.is_changed() {
return;
}
if screen.0 {
if panels.is_empty() && other_modal_scrims.is_empty() {
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(),
scroll_pos.0,
active_tab.0,
);
}
} else {
// Save the current scroll offset before despawning the panel.
if let Ok(sp) = scroll_nodes.single() {
scroll_pos.0 = sp.0.y;
}
for entity in &panels {
commands.entity(entity).despawn();
}
}
}
/// 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>>,
mut text_nodes: Query<&mut Text, With<SyncStatusText>>,
) {
let Some(status) = sync_status else {
return;
};
if !status.is_changed() {
return;
}
let label = sync_status_label(&status.0);
for mut text in &mut text_nodes {
**text = label.clone();
}
}
pub(super) fn update_card_back_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<CardBackText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = card_back_label(settings.0.selected_card_back);
}
}
pub(super) fn update_background_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<BackgroundText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = background_label(settings.0.selected_background);
}
}
pub(super) fn update_anim_speed_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<AnimSpeedText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = anim_speed_label(&settings.0.animation_speed);
}
}
pub(super) fn update_color_blind_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<ColorBlindText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = color_blind_label(settings.0.color_blind_mode);
}
}
pub(super) fn update_high_contrast_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<HighContrastText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = on_off_label(settings.0.high_contrast_mode);
}
}
/// Repaints `BorderColor` on every entity tagged with
/// [`HighContrastBorder`] based on `Settings::high_contrast_mode`.
/// Off → the marker's `default_color`; on → `BORDER_SUBTLE_HC`
/// (`#a0a0a0`). Compares against the current border colour and
/// only mutates when different so Bevy's change-detection
/// doesn't trigger repaints every frame.
///
/// Spec at `design-system.md` §Accessibility (#2): under HC,
/// outlines boost from `#505050` (BORDER_STRONG) to `#a0a0a0` so
/// modal panels, popover edges, and focus-ring carriers stay
/// legible on low-quality displays / for low-vision users.
///
/// Tagged sites in v0.21.x: the modal scaffold's card border
/// (`ui_modal::spawn_modal`). More sites can be tagged in
/// follow-ups by adding `HighContrastBorder::with_default(...)`
/// to their spawn tuple.
pub(super) fn update_high_contrast_borders(
settings: Res<SettingsResource>,
mut borders: Query<(&HighContrastBorder, &mut BorderColor)>,
) {
let high_contrast = settings.0.high_contrast_mode;
for (marker, mut border) in borders.iter_mut() {
let target = if high_contrast {
BORDER_SUBTLE_HC
} else {
marker.default_color
};
// Only mutate when actually different — avoids per-frame
// change-detection churn. `border.left` is representative
// because every tagged site uses `BorderColor::all(...)`.
if border.left != target {
*border = BorderColor::all(target);
}
}
}
/// Repaints `BackgroundColor` on every entity tagged with
/// [`HighContrastBackground`] based on `Settings::high_contrast_mode`.
/// Off → the marker's `default_color`; on → `BORDER_SUBTLE_HC`
/// (`#a0a0a0`). Compares against the current background and only
/// mutates when different so Bevy's change-detection doesn't trigger
/// repaints every frame.
///
/// Parallel to [`update_high_contrast_borders`]. Same on/off rule,
/// same change-suppression idiom, different colour channel —
/// `BackgroundColor` for tick marks, decorative strips, fine
/// separators that paint their shape directly rather than via a
/// `BorderColor` on a wider Node.
///
/// Tagged sites in v0.21.x: the replay overlay's 1 px scrub track
/// + 5 quarter-mark notch ticks (`replay_overlay::spawn_overlay`).
///
/// More sites can be tagged in follow-ups by adding
/// `HighContrastBackground::with_default(...)` to their spawn tuple.
pub(super) fn update_high_contrast_backgrounds(
settings: Res<SettingsResource>,
mut backgrounds: Query<(&HighContrastBackground, &mut BackgroundColor)>,
) {
let high_contrast = settings.0.high_contrast_mode;
for (marker, mut bg) in backgrounds.iter_mut() {
let target = if high_contrast {
marker.hc_color
} else {
marker.default_color
};
if bg.0 != target {
*bg = BackgroundColor(target);
}
}
}
pub(super) fn update_reduce_motion_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<ReduceMotionText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = on_off_label(settings.0.reduce_motion_mode);
}
}
pub(super) fn update_touch_input_mode_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<TouchInputModeText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = touch_input_mode_label(&settings.0.touch_input_mode);
}
}
/// Refreshes the live "Winnable deals only" toggle value in the
/// Gameplay section whenever `SettingsResource` changes (button click,
/// hand-edited `settings.json` reload, etc.).
pub(super) fn update_winnable_deals_only_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<WinnableDealsOnlyText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = winnable_deals_only_label(settings.0.winnable_deals_only);
}
}
/// Refreshes the live "Share usage data" toggle value in the Privacy section
/// whenever `SettingsResource` changes.
pub(super) fn update_analytics_enabled_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<AnalyticsEnabledText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = on_off_label(settings.0.analytics_enabled);
}
}
/// Refreshes the live "Smart window size" toggle value whenever
/// `SettingsResource` changes. The flag is stored negatively as
/// `disable_smart_default_size`, so the label inverts.
pub(super) fn update_smart_default_size_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<SmartDefaultSizeText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = smart_default_size_label(!settings.0.disable_smart_default_size);
}
}
/// Refreshes the live tooltip-delay value in the Gameplay section
/// whenever `SettingsResource` changes (slider buttons, hand-edited
/// settings.json reload, etc.).
pub(super) fn update_tooltip_delay_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<TooltipDelayText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = tooltip_delay_label(settings.0.tooltip_delay_secs);
}
}
/// Refreshes the live time-bonus-multiplier value in the Gameplay
/// section whenever `SettingsResource` changes.
pub(super) fn update_time_bonus_multiplier_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<TimeBonusMultiplierText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = time_bonus_label(settings.0.time_bonus_multiplier);
}
}
/// Refreshes the live replay-playback per-move-interval value in the
/// Gameplay section whenever `SettingsResource` changes (slider buttons,
/// hand-edited settings.json reload, etc.).
pub(super) fn update_replay_move_interval_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<ReplayMoveIntervalText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = replay_move_interval_label(settings.0.replay_move_interval_secs);
}
}
pub(super) fn card_back_label(idx: usize) -> String {
if idx == 0 {
"Default".to_string()
} else {
format!("Style {idx}")
}
}
pub(super) fn background_label(idx: usize) -> String {
if idx == 0 {
"Default".to_string()
} else {
format!("Style {idx}")
}
}
pub(super) fn sync_status_label(status: &SyncStatus) -> String {
match status {
SyncStatus::Idle => "Status: idle".to_string(),
SyncStatus::Syncing => "Status: syncing…".to_string(),
SyncStatus::LastSynced(t) => {
let secs = chrono::Utc::now()
.signed_duration_since(*t)
.num_seconds()
.max(0);
if secs < 60 {
format!("Last synced: {secs}s ago")
} else {
format!("Last synced: {}m ago", secs / 60)
}
}
SyncStatus::Error(e) => format!("Sync error: {e}"),
}
}
pub(super) fn draw_mode_label(mode: &DrawStockConfig) -> String {
match mode {
DrawStockConfig::DrawOne => "Draw 1".into(),
DrawStockConfig::DrawThree => "Draw 3".into(),
}
}
pub(super) fn anim_speed_label(speed: &AnimSpeed) -> String {
match speed {
AnimSpeed::Normal => "Normal".into(),
AnimSpeed::Fast => "Fast".into(),
AnimSpeed::Instant => "Instant".into(),
}
}
pub(super) fn theme_label(theme: &Theme) -> String {
match theme {
Theme::Green => "Green".into(),
Theme::Blue => "Blue".into(),
Theme::Dark => "Dark".into(),
}
}
pub(super) fn color_blind_label(enabled: bool) -> String {
if enabled { "ON".into() } else { "OFF".into() }
}
/// Generic ON/OFF label shared by the high-contrast and reduce-
/// motion accessibility toggles. Same format as
/// [`color_blind_label`] / [`winnable_deals_only_label`] —
/// keeping all simple boolean toggle rows visually uniform.
pub(super) fn on_off_label(enabled: bool) -> String {
if enabled { "ON".into() } else { "OFF".into() }
}
/// Display string for the "Winnable deals only" toggle. Mirrors
/// [`color_blind_label`] — "ON" / "OFF" — so the layout is uniform
/// with the rest of the Gameplay-section toggles.
pub(super) fn winnable_deals_only_label(enabled: bool) -> String {
if enabled { "ON".into() } else { "OFF".into() }
}
pub(super) fn touch_input_mode_label(mode: &solitaire_data::settings::TouchInputMode) -> String {
use solitaire_data::settings::TouchInputMode;
match mode {
TouchInputMode::OneTap => "One-tap".into(),
TouchInputMode::TapToSelect => "Tap to select".into(),
}
}
/// Display string for the "Smart window size" toggle. The argument
/// is the *enabled* state (i.e. the inverse of the underlying
/// `disable_smart_default_size` field) so reading the label gives
/// the player intuitive ON/OFF semantics.
pub(super) fn smart_default_size_label(enabled: bool) -> String {
if enabled { "ON".into() } else { "OFF".into() }
}
/// Formats the tooltip-hover delay for display in the Settings panel.
/// `0.0` reads as `"Instant"` so the zero-delay case has a name; any
/// other value prints as `"{n:.1} s"` (e.g. `"0.5 s"`, `"1.2 s"`).
pub(super) fn tooltip_delay_label(secs: f32) -> String {
if secs <= 0.0 {
"Instant".into()
} else {
format!("{secs:.1} s")
}
}
/// Formats the cosmetic time-bonus multiplier for display in the
/// Settings panel. `0.0` reads as `"Off"` so the player understands the
/// time-bonus row will be hidden; any other value prints as
/// `"{n:.1}×"` (e.g. `"1.0×"`, `"1.5×"`).
pub(super) fn time_bonus_label(value: f32) -> String {
if value <= 0.0 {
"Off".into()
} else {
format!("{value:.1}×")
}
}
/// Formats the replay-playback per-move interval for display in the
/// Settings panel. Mirrors [`tooltip_delay_label`] for parity — the
/// readout is `"{n:.2} s/move"` (e.g. `"0.45 s/move"`, `"0.10 s/move"`),
/// using two decimal places because the step is 0.05 s.
pub(super) fn replay_move_interval_label(secs: f32) -> String {
format!("{secs:.2} s/move")
}
@@ -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",
);
}
}
+97 -111
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
}
@@ -1500,10 +1474,8 @@ mod tests {
#[test]
fn zen_win_event_updates_zen_best_score_only() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.mode = solitaire_core::game_state::GameMode::Zen;
app.world_mut().resource_mut::<GameStateResource>().0.mode =
solitaire_core::game_state::GameMode::Zen;
app.world_mut().write_message(GameWonEvent {
score: 1800,
@@ -1834,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();
@@ -1842,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()
@@ -1880,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()
@@ -1903,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()
@@ -1957,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
+44 -39
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,18 +256,26 @@ 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);
}
}
}
/// Last-schedule system: starts a best-effort push of the current local state
/// on [`AppExit`] without blocking the Bevy main thread.
/// Upper bound on how long [`push_on_exit`] may block the closing app.
/// Long enough for one healthy round-trip; short enough that quitting
/// never feels hung when the server is unreachable.
const EXIT_PUSH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
/// Last-schedule system: pushes the current local state on [`AppExit`],
/// blocking the main thread for at most [`EXIT_PUSH_TIMEOUT`].
///
/// The detached task may be cut short by process teardown, so local atomic
/// persistence remains the durable source of truth even if the final remote
/// push does not complete.
/// This deliberately blocks: the previous detached-task version was almost
/// always cut short by process teardown, so the final session's push
/// silently never happened (2026-07-06 review, finding M3). A bounded wait
/// during the app's final frame is invisible to the player and lets the
/// round-trip actually complete on a healthy network. On timeout or error
/// the push is skipped — local atomic persistence remains the durable
/// source of truth and the next launch's pull/push converges.
fn push_on_exit(
mut exit_events: MessageReader<AppExit>,
provider: Res<SyncProviderResource>,
@@ -292,16 +290,17 @@ fn push_on_exit(
exit_events.clear();
let payload = build_payload(&stats.0, &achievements.0, &progress.0);
let provider = provider.0.clone();
let rt = rt.0.clone();
AsyncComputeTaskPool::get()
.spawn(async move {
match rt.block_on(provider.push(&payload)) {
Ok(_) | Err(SyncError::UnsupportedPlatform) => {}
Err(e) => warn!("sync push on exit failed: {e}"),
}
})
.detach();
let result = rt.0.block_on(async {
tokio::time::timeout(EXIT_PUSH_TIMEOUT, provider.0.push(&payload)).await
});
match result {
Ok(Ok(_)) | Ok(Err(SyncError::UnsupportedPlatform)) => {}
Ok(Err(e)) => warn!("sync push on exit failed: {e}"),
Err(_) => warn!(
"sync push on exit timed out after {}s; will sync on next launch",
EXIT_PUSH_TIMEOUT.as_secs()
),
}
}
/// Update-schedule system: on each `GameWonEvent` push the just-completed
@@ -336,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();
@@ -622,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,
@@ -657,9 +664,7 @@ mod tests {
);
// In-memory contract: replays[0].share_url is now Some(url).
let live = app
.world()
.resource::<ReplayHistoryResource>();
let live = app.world().resource::<ReplayHistoryResource>();
assert_eq!(
live.0.replays.first().and_then(|r| r.share_url.clone()),
Some(url.clone()),
+133 -37
View File
@@ -6,12 +6,16 @@
use bevy::prelude::*;
use bevy::window::WindowResized;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::KlondikePile;
use solitaire_core::Suit;
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use crate::events::{HintVisualEvent, StateChangedEvent};
use crate::game_plugin::GameMutation;
use crate::hud_plugin::HudVisibility;
use crate::layout::{Layout, LayoutResource, LayoutSystem, TABLE_COLOUR, compute_layout};
use crate::layout::{
Layout, LayoutResource, LayoutSystem, TABLE_COLOUR, apply_dynamic_tableau_fan, compute_layout,
};
use crate::resources::GameStateResource;
use crate::safe_area::SafeAreaInsets;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
@@ -81,6 +85,13 @@ pub struct HintPileHighlight {
/// Registers the table background and pile-marker rendering.
pub struct TablePlugin;
/// Set wrapping the pile-marker painter chain (theme, hint highlights,
/// visibility). Runs after [`crate::card_plugin::BoardVisuals`]; chrome-fx
/// systems that touch `Visibility` on UI entities declare themselves
/// ambiguous with it (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct MarkerVisuals;
impl Plugin for TablePlugin {
fn build(&self, app: &mut App) {
// Register WindowResized so the plugin works under MinimalPlugins in
@@ -97,10 +108,18 @@ impl Plugin for TablePlugin {
(
on_safe_area_changed.before(LayoutSystem::UpdateOnResize),
on_window_resized.in_set(LayoutSystem::UpdateOnResize),
apply_theme_on_settings_change,
apply_hint_pile_highlight,
tick_hint_pile_highlights,
sync_pile_marker_visibility,
// Marker painters: deterministic chain after the card
// paint pipeline — markers and cards share Sprite/
// Transform access (#143).
(
apply_theme_on_settings_change,
apply_hint_pile_highlight,
tick_hint_pile_highlights,
sync_pile_marker_visibility.after(GameMutation),
)
.chain()
.in_set(MarkerVisuals)
.after(crate::card_plugin::BoardVisuals),
),
);
}
@@ -278,10 +297,10 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
let mut piles: Vec<KlondikePile> = Vec::with_capacity(12);
piles.push(KlondikePile::Stock);
for foundation in foundations() {
for foundation in FOUNDATIONS {
piles.push(KlondikePile::Foundation(foundation));
}
for tableau in tableaus() {
for tableau in TABLEAUS {
piles.push(KlondikePile::Tableau(tableau));
}
@@ -348,18 +367,31 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
}
}
#[allow(clippy::type_complexity)]
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn on_window_resized(
mut events: MessageReader<WindowResized>,
safe_area: Option<Res<SafeAreaInsets>>,
windows: Query<&Window>,
hud_vis: Option<Res<HudVisibility>>,
game: Option<Res<GameStateResource>>,
mut layout_res: Option<ResMut<LayoutResource>>,
mut backgrounds: Query<
(&mut Sprite, &mut Transform),
(With<TableBackground>, Without<PileMarker>),
>,
mut markers: Query<(&PileMarker, &mut Sprite, &mut Transform), Without<TableBackground>>,
mut markers: Query<
(&PileMarker, &mut Sprite, &mut Transform, Option<&Children>),
Without<TableBackground>,
>,
mut marker_outlines: Query<
&mut Sprite,
(
Without<PileMarker>,
Without<TableBackground>,
Without<Text2d>,
),
>,
mut marker_labels: Query<&mut TextFont, With<Text2d>>,
) {
let Some(ev) = events.read().last() else {
return;
@@ -370,7 +402,24 @@ fn on_window_resized(
let safe_area_top = insets.top / scale;
let safe_area_bottom = insets.bottom / scale;
let hud_visible = hud_vis.as_deref().copied().unwrap_or_default() == HudVisibility::Visible;
let new_layout = compute_layout(window_size, safe_area_top, safe_area_bottom, hud_visible);
// Android surface sizes after fold/unfold are unreliable (winit does not
// forward every content-rect change), so make each relayout observable
// in logcat — this is the primary evidence channel for foldable bugs.
#[cfg(target_os = "android")]
info!(
"layout: resize to {:.0}x{:.0} (insets top={:.0} bottom={:.0}, hud={hud_visible})",
window_size.x, window_size.y, insets.top, insets.bottom
);
let mut new_layout = compute_layout(window_size, safe_area_top, safe_area_bottom, hud_visible);
// compute_layout sizes the fan for a worst-case column; refine it to the
// current deal so a resize (incl. the Android safe-area-inset resize that
// fires in the first few frames, and fold/unfold on foldables) keeps the
// tableau filling the viewport instead of snapping back to the sparse
// worst-case fan.
if let Some(game) = game.as_ref() {
apply_dynamic_tableau_fan(&game.0, &mut new_layout);
}
if let Some(layout_res) = layout_res.as_deref_mut() {
layout_res.0 = new_layout.clone();
@@ -382,11 +431,26 @@ fn on_window_resized(
transform.translation.y = 0.0;
}
for (marker, mut sprite, mut transform) in &mut markers {
for (marker, mut sprite, mut transform, children) in &mut markers {
if let Some(pos) = new_layout.pile_positions.get(&marker.0) {
sprite.custom_size = Some(new_layout.card_size);
transform.translation.x = pos.x;
transform.translation.y = pos.y;
// Children carry the outline frame and the "A"/"K" watermark.
// They are sized at spawn from that layout's card size, so they
// must be re-derived here too or a resize (fold/unfold, rotation)
// leaves them at the stale size — visible as oversized grey
// frames on empty piles.
let outline_size = new_layout.card_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
let font_size = new_layout.card_size.x * 0.28;
for child in children.into_iter().flatten() {
if let Ok(mut outline) = marker_outlines.get_mut(*child) {
outline.custom_size = Some(outline_size);
}
if let Ok(mut label) = marker_labels.get_mut(*child) {
label.font_size = font_size;
}
}
}
}
@@ -532,31 +596,11 @@ fn pile_cards(
}
}
const fn foundations() -> [Foundation; 4] {
[
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
]
}
const fn tableaus() -> [Tableau; 7] {
[
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use solitaire_core::{Foundation, Tableau};
/// Minimal headless app — omits windowing so pile markers are spawned with
/// the default 1280×800 layout and no camera is created.
@@ -611,6 +655,61 @@ mod tests {
assert_eq!(types.len(), 12);
}
#[test]
fn resize_updates_marker_outline_and_watermark_children() {
// Regression (found on Galaxy Fold 7, v0.41.0): `on_window_resized`
// resized the marker fill sprite but never its children, so after a
// fold/unfold the outline frame and "A"/"K" watermark stayed at
// spawn-time size — rendering as oversized grey slabs on empty piles.
let mut app = headless_app();
// Shrink the window well below the 1280×800 spawn default.
app.world_mut().write_message(WindowResized {
window: Entity::PLACEHOLDER,
width: 640.0,
height: 480.0,
});
app.update();
let card_size = app.world().resource::<LayoutResource>().0.card_size;
let expected_outline = card_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
let expected_font = card_size.x * 0.28;
let marker_children: Vec<Entity> = {
let mut q = app.world_mut().query::<(&PileMarker, &Children)>();
q.iter(app.world())
.flat_map(|(_, c)| c.iter().collect::<Vec<_>>())
.collect()
};
assert!(
!marker_children.is_empty(),
"pile markers must have outline/watermark children"
);
let mut outlines = 0;
let mut labels = 0;
for child in marker_children {
if let Some(sprite) = app.world().entity(child).get::<Sprite>() {
assert_eq!(
sprite.custom_size,
Some(expected_outline),
"outline child must track the resized card size",
);
outlines += 1;
}
if let Some(font) = app.world().entity(child).get::<TextFont>() {
assert!(
(font.font_size - expected_font).abs() < 0.01,
"watermark font must track the resized card size",
);
labels += 1;
}
}
assert_eq!(outlines, 12, "all 12 markers carry an outline child");
assert!(
labels >= 11,
"tableau + foundation markers carry watermarks"
);
}
#[test]
fn pile_markers_hide_when_pile_is_occupied() {
// After a fresh deal: the 7 tableau piles + the stock pile are
@@ -844,10 +943,7 @@ mod tests {
#[test]
fn suit_symbol_all_four_are_distinct() {
let symbols: Vec<&str> = [Suit::Spades, Suit::Hearts, Suit::Diamonds, Suit::Clubs]
.iter()
.map(suit_symbol)
.collect();
let symbols: Vec<&str> = Suit::SUITS.iter().map(suit_symbol).collect();
let unique: std::collections::HashSet<&&str> = symbols.iter().collect();
assert_eq!(unique.len(), 4, "all four suit symbols must be distinct");
}
+4 -16
View File
@@ -62,22 +62,10 @@ impl CardKey {
/// Iterator over all 52 valid keys, in suit-major / rank-ascending order.
/// Used to enumerate the manifest's required entries.
pub fn all() -> impl Iterator<Item = CardKey> {
const SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
const RANKS: [Rank; 13] = [
Rank::Ace,
Rank::Two,
Rank::Three,
Rank::Four,
Rank::Five,
Rank::Six,
Rank::Seven,
Rank::Eight,
Rank::Nine,
Rank::Ten,
Rank::Jack,
Rank::Queen,
Rank::King,
];
// Order-independent enumeration — consumers check completeness and
// round-trips, never positions.
const SUITS: [Suit; 4] = Suit::SUITS;
const RANKS: [Rank; 13] = Rank::RANKS;
SUITS
.into_iter()
.flat_map(|s| RANKS.into_iter().map(move |r| CardKey::new(s, r)))
+71 -85
View File
@@ -17,7 +17,7 @@ use solitaire_core::{Rank, Suit};
use crate::assets::{
bundled_theme_url, classic_theme_svg_bytes, dark_theme_svg_bytes, rasterize_svg, user_theme_dir,
};
use crate::card_plugin::CardImageSet;
use crate::card_plugin::{CardImageSet, rank_index, suit_index};
use crate::events::StateChangedEvent;
use super::loader::CardThemeLoader;
@@ -116,24 +116,32 @@ impl Plugin for ThemePlugin {
}
}
/// Resolves the manifest URL for `theme_id`: the bundled themes
/// (`"dark"`, `"classic"`) load from the embedded source; any other id
/// is a user theme served from the `themes://` directory source.
fn theme_manifest_url(theme_id: &str) -> String {
bundled_theme_url(theme_id)
.map(str::to_string)
.unwrap_or_else(|| format!("themes://{theme_id}/theme.ron"))
}
/// Kicks off the initial theme load — the one named by
/// `Settings::selected_theme_id` if available, falling back to the
/// embedded default. The actual rasterisation runs asynchronously on
/// the asset task pool; the sync system below picks up the
/// `LoadedWithDependencies` event when every face + back is ready.
/// fresh-install default from `solitaire_data::Settings`. The actual
/// rasterisation runs asynchronously on the asset task pool; the sync
/// system below picks up the `LoadedWithDependencies` event when every
/// face + back is ready.
fn load_initial_theme(
asset_server: Res<AssetServer>,
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
mut commands: Commands,
) {
let default_id = solitaire_data::Settings::default().selected_theme_id;
let id = settings
.as_deref()
.map(|s| s.0.selected_theme_id.as_str())
.unwrap_or("classic");
let url = bundled_theme_url(id)
.map(str::to_string)
.unwrap_or_else(|| format!("themes://{id}/theme.ron"));
let handle: Handle<CardTheme> = asset_server.load(url);
.unwrap_or(&default_id);
let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(id));
commands.insert_resource(ActiveTheme(handle));
}
@@ -162,10 +170,7 @@ fn react_to_settings_theme_change(
return;
}
let url = bundled_theme_url(new_id)
.map(str::to_string)
.unwrap_or_else(|| format!("themes://{new_id}/theme.ron"));
let handle: Handle<CardTheme> = asset_server.load(url);
let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(new_id));
commands.insert_resource(ActiveTheme(handle));
}
@@ -242,22 +247,8 @@ fn sync_card_image_set_with_active_theme(
/// `theme_back` when present, so writing here is sufficient to make
/// every face-down card pick up the theme's art on the next sync.
fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet) {
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
for rank in [
Rank::Ace,
Rank::Two,
Rank::Three,
Rank::Four,
Rank::Five,
Rank::Six,
Rank::Seven,
Rank::Eight,
Rank::Nine,
Rank::Ten,
Rank::Jack,
Rank::Queen,
Rank::King,
] {
for suit in Suit::SUITS {
for rank in Rank::RANKS {
if let Some(handle) = theme.faces.get(&CardKey::new(suit, rank)) {
image_set.faces[suit_index(suit)][rank_index(rank)] = handle.clone();
}
@@ -266,41 +257,11 @@ fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet
image_set.theme_back = Some(theme.back.clone());
}
/// Index used by [`CardImageSet::faces`] for a given suit. Mirrors
/// the `card_plugin` doc comment: Clubs=0, Diamonds=1, Hearts=2, Spades=3.
const fn suit_index(s: Suit) -> usize {
match s {
Suit::Clubs => 0,
Suit::Diamonds => 1,
Suit::Hearts => 2,
Suit::Spades => 3,
}
}
/// Index used by [`CardImageSet::faces`] for a given rank.
/// Ace=0, Two=1 … King=12.
const fn rank_index(r: Rank) -> usize {
match r {
Rank::Ace => 0,
Rank::Two => 1,
Rank::Three => 2,
Rank::Four => 3,
Rank::Five => 4,
Rank::Six => 5,
Rank::Seven => 6,
Rank::Eight => 7,
Rank::Nine => 8,
Rank::Ten => 9,
Rank::Jack => 10,
Rank::Queen => 11,
Rank::King => 12,
}
}
/// Switches the active theme to the one served at
/// `themes://<theme_id>/theme.ron`. Returns the new `Handle<CardTheme>`
/// so callers can poll `Assets<CardTheme>` if they want to wait for
/// the load before changing UI state.
/// Switches the active theme to `theme_id` — the embedded source for
/// the bundled `"dark"` / `"classic"` themes, `themes://` for user
/// themes. Returns the new `Handle<CardTheme>` so callers can poll
/// `Assets<CardTheme>` if they want to wait for the load before
/// changing UI state.
///
/// The handle is also written to the [`ActiveTheme`] resource — the
/// per-frame sync system picks up the `LoadedWithDependencies` event
@@ -311,8 +272,7 @@ pub fn set_theme(
asset_server: &AssetServer,
theme_id: &str,
) -> Handle<CardTheme> {
let url = format!("themes://{theme_id}/theme.ron");
let handle: Handle<CardTheme> = asset_server.load(url);
let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(theme_id));
commands.insert_resource(ActiveTheme(handle.clone()));
handle
}
@@ -446,21 +406,17 @@ mod tests {
}
#[test]
fn suit_index_ranges_match_card_plugin_layout() {
assert_eq!(suit_index(Suit::Clubs), 0);
assert_eq!(suit_index(Suit::Diamonds), 1);
assert_eq!(suit_index(Suit::Hearts), 2);
assert_eq!(suit_index(Suit::Spades), 3);
fn suit_index_matches_upstream_suits_order() {
for (i, s) in Suit::SUITS.iter().enumerate() {
assert_eq!(suit_index(*s), i, "faces outer layout = Suit::SUITS order");
}
}
#[test]
fn rank_index_starts_at_ace_zero_and_ends_at_king_twelve() {
assert_eq!(rank_index(Rank::Ace), 0);
assert_eq!(rank_index(Rank::Two), 1);
assert_eq!(rank_index(Rank::Ten), 9);
assert_eq!(rank_index(Rank::Jack), 10);
assert_eq!(rank_index(Rank::Queen), 11);
assert_eq!(rank_index(Rank::King), 12);
fn rank_index_matches_upstream_ranks_order() {
for (i, r) in Rank::RANKS.iter().enumerate() {
assert_eq!(rank_index(*r), i, "faces inner layout = Rank::RANKS order");
}
}
#[test]
@@ -524,13 +480,43 @@ mod tests {
}
#[test]
fn set_theme_url_format_matches_themes_source() {
// The format string is the only behavioural surface of
// set_theme that doesn't require an App. We assert the URL
// shape so a future refactor doesn't accidentally change the
// path layout.
let url = format!("themes://{}/theme.ron", "user_uploaded");
assert_eq!(url, "themes://user_uploaded/theme.ron");
fn theme_manifest_url_routes_bundled_ids_to_embedded_source() {
// The bundled themes live in the binary, not in the user
// themes directory — resolving them to `themes://` would
// NotFound and silently leave the previous theme active.
assert_eq!(
theme_manifest_url("dark"),
crate::assets::DARK_THEME_MANIFEST_URL
);
assert_eq!(
theme_manifest_url("classic"),
crate::assets::CLASSIC_THEME_MANIFEST_URL
);
}
#[test]
fn theme_manifest_url_routes_user_ids_to_themes_source() {
// We assert the URL shape so a future refactor doesn't
// accidentally change the path layout of the user-themes
// source.
assert_eq!(
theme_manifest_url("user_uploaded"),
"themes://user_uploaded/theme.ron"
);
}
#[test]
fn initial_theme_fallback_matches_settings_default() {
// `load_initial_theme` falls back to the data-crate default
// when `SettingsResource` is absent. That default must always
// name a bundled theme, or a fresh minimal setup would resolve
// to a nonexistent user theme (see the v0.33.0 "classic"
// fallback drift in CHANGELOG.md).
let default_id = solitaire_data::Settings::default().selected_theme_id;
assert!(
bundled_theme_url(&default_id).is_some(),
"default theme id {default_id:?} must be a bundled theme"
);
}
/// Test 1: the bundled dark theme always has embedded SVG bytes
+664
View File
@@ -0,0 +1,664 @@
//! In-game theme store: browse the sync server's theme catalog and
//! install themes without leaving the app.
//!
//! Settings → Cosmetic → "Browse theme store" fires
//! [`crate::events::ThemeStoreOpenRequestEvent`]; this plugin opens a
//! modal listing the catalog (fetched on [`AsyncComputeTaskPool`]) and
//! handles per-theme Install buttons. An install downloads the archive
//! (SHA-256-verified by [`ThemeStoreClient`]), writes it atomically
//! into `user_theme_dir()`, and runs it through the same hardened
//! [`import_theme`] pipeline as a manually dropped zip — the store
//! never bypasses the importer's validation.
//!
//! Native-only: downloads need `reqwest`/Tokio and the importer is
//! filesystem-based; the plugin is gated out on wasm32 alongside
//! `SyncPlugin`.
use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use thiserror::Error;
use solitaire_data::theme_store_client::{ThemeStoreClient, ThemeStoreError};
use solitaire_data::{Settings, SyncBackend};
use solitaire_sync::ThemeCatalogEntry;
use crate::assets::user_theme_dir;
use crate::events::{InfoToastEvent, ThemeStoreOpenRequestEvent, WarningToastEvent};
use crate::font_plugin::FontResource;
use crate::resources::TokioRuntimeResource;
use crate::settings_plugin::{SettingsPanel, SettingsResource};
use crate::theme::{ImportError, ThemeRegistry, import_theme, refresh_registry};
use crate::ui_modal::{
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{
BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3,
Z_MODAL_PANEL,
};
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
/// Everything that can go wrong between clicking Install and the theme
/// appearing in the picker.
#[derive(Debug, Error)]
enum ThemeInstallError {
/// Catalog fetch or archive download failed (network, HTTP status,
/// size/checksum verification).
#[error(transparent)]
Download(#[from] ThemeStoreError),
/// The verified archive could not be written into the themes dir.
#[error("could not save archive: {0}")]
Io(#[from] std::io::Error),
/// The downloaded archive failed importer validation.
#[error(transparent)]
Import(#[from] ImportError),
}
// ---------------------------------------------------------------------------
// Components & resources
// ---------------------------------------------------------------------------
/// Marker on the theme-store modal's scrim root.
#[derive(Component)]
pub struct ThemeStoreScreen;
/// Marker on the modal's Close button.
#[derive(Component)]
struct ThemeStoreCloseButton;
/// Per-row Install button carrying its catalog entry.
#[derive(Component)]
struct ThemeStoreInstallButton(ThemeCatalogEntry);
/// Catalog fetch lifecycle. The modal renders directly from this state
/// and is rebuilt (despawn + respawn, mirroring the leaderboard panel)
/// whenever it changes.
#[derive(Resource, Default)]
enum CatalogState {
/// No fetch has run since the modal was last opened.
#[default]
Idle,
Loading,
Loaded(Vec<ThemeCatalogEntry>),
Error(String),
}
/// In-flight catalog fetch, polled without blocking.
#[derive(Resource, Default)]
struct CatalogTask(Option<Task<Result<Vec<ThemeCatalogEntry>, ThemeStoreError>>>);
/// Result of one download + import: `Ok(Some(id))` on a fresh install,
/// `Ok(None)` when the theme was already installed (id collision) —
/// informational, not an error.
type InstallResult = Result<Option<String>, ThemeInstallError>;
/// In-flight download + import. `String` is the theme id, for toasts.
#[derive(Resource, Default)]
struct InstallTask(Option<(String, Task<InstallResult>)>);
/// Server base URL captured when the modal opens. Catalog
/// `download_url`s are server-relative, so installs join them onto
/// this. `None` while the modal has never been opened.
#[derive(Resource, Default)]
struct StoreBaseUrl(Option<String>);
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
/// Bevy plugin for the in-game theme store. See the module docs.
pub struct ThemeStorePlugin;
impl Plugin for ThemeStorePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<CatalogState>()
.init_resource::<CatalogTask>()
.init_resource::<InstallTask>()
.init_resource::<StoreBaseUrl>()
// 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>()
.add_systems(
Update,
(
handle_open_request,
poll_catalog_task,
handle_install_buttons,
poll_install_task,
handle_close_button,
)
.chain(),
);
}
}
// ---------------------------------------------------------------------------
// Systems
// ---------------------------------------------------------------------------
/// Resolves the store's server base URL from the player's sync
/// backend. The store rides the same self-hosted server as sync;
/// without one configured there is nothing to browse.
fn store_base_url(settings: &Settings) -> Option<String> {
match &settings.sync_backend {
SyncBackend::SolitaireServer { url, .. } => Some(url.clone()),
_ => None,
}
}
/// Opens the store modal and kicks off the catalog fetch.
///
/// Mirrors `open_sync_setup_modal`: the Settings button closes the
/// settings panel in the same frame it fires the event, and despawns
/// are deferred, so the settings scrim is excluded from the
/// another-modal-is-open guard.
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn handle_open_request(
mut events: MessageReader<ThemeStoreOpenRequestEvent>,
existing: Query<(), With<ThemeStoreScreen>>,
other_modal_scrims: Query<
(),
(
With<ModalScrim>,
Without<ThemeStoreScreen>,
Without<SettingsPanel>,
),
>,
settings: Option<Res<SettingsResource>>,
rt: Option<Res<TokioRuntimeResource>>,
mut catalog_state: ResMut<CatalogState>,
mut catalog_task: ResMut<CatalogTask>,
mut store_base: ResMut<StoreBaseUrl>,
mut warning_toast: MessageWriter<WarningToastEvent>,
mut commands: Commands,
font_res: Option<Res<FontResource>>,
) {
if events.is_empty() {
return;
}
events.clear();
if !existing.is_empty() || !other_modal_scrims.is_empty() {
return;
}
let Some(base_url) = settings.as_deref().and_then(|s| store_base_url(&s.0)) else {
warning_toast.write(WarningToastEvent(
"Theme store needs a server — connect one in Settings → Sync.".to_string(),
));
return;
};
let Some(rt) = rt else {
warning_toast.write(WarningToastEvent(
"Theme store unavailable — no network runtime.".to_string(),
));
return;
};
store_base.0 = Some(base_url.clone());
*catalog_state = CatalogState::Loading;
let rt = rt.0.clone();
catalog_task.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
rt.block_on(async { ThemeStoreClient::new(base_url).fetch_catalog().await })
}));
spawn_store_modal(&mut commands, &catalog_state, None, font_res.as_deref());
}
/// Polls the catalog fetch; on completion updates [`CatalogState`] and
/// rebuilds the modal if it is still open.
fn poll_catalog_task(
mut catalog_task: ResMut<CatalogTask>,
mut catalog_state: ResMut<CatalogState>,
screens: Query<Entity, With<ThemeStoreScreen>>,
registry: Option<Res<ThemeRegistry>>,
mut commands: Commands,
font_res: Option<Res<FontResource>>,
) {
let Some(task) = catalog_task.0.as_mut() else {
return;
};
let Some(result) = future::block_on(future::poll_once(task)) else {
return;
};
catalog_task.0 = None;
*catalog_state = match result {
Ok(entries) => CatalogState::Loaded(entries),
Err(e) => {
warn!("theme store: catalog fetch failed: {e}");
CatalogState::Error(e.to_string())
}
};
rebuild_open_modal(
&screens,
&mut commands,
&catalog_state,
registry.as_deref(),
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(
interactions: Query<(&Interaction, &ThemeStoreInstallButton), Changed<Interaction>>,
rt: Option<Res<TokioRuntimeResource>>,
store_base: Res<StoreBaseUrl>,
mut install_task: ResMut<InstallTask>,
mut info_toast: MessageWriter<InfoToastEvent>,
) {
for (interaction, button) in &interactions {
if *interaction != Interaction::Pressed || install_task.0.is_some() {
continue;
}
let (Some(rt), Some(base_url)) = (rt.as_ref(), store_base.0.clone()) else {
continue;
};
let entry = button.0.clone();
info_toast.write(InfoToastEvent(format!("Downloading '{}'…", entry.name)));
let rt = rt.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move {
let bytes = rt
.block_on(async { ThemeStoreClient::new(base_url).download_theme(&entry).await })?;
install_verified_archive(&entry.id, &bytes)
});
install_task.0 = Some((button.0.id.clone(), task));
}
}
/// Downloads land here (already SHA-256-verified): write the archive
/// atomically into the user themes dir (`.tmp` + rename, §2.5), then
/// run the standard importer. Returns `Ok(None)` when the theme was
/// already installed.
fn install_verified_archive(id: &str, bytes: &[u8]) -> Result<Option<String>, ThemeInstallError> {
let themes_dir = user_theme_dir();
std::fs::create_dir_all(&themes_dir)?;
let final_path = themes_dir.join(format!("{id}.zip"));
let tmp_path = themes_dir.join(format!("{id}.zip.tmp"));
std::fs::write(&tmp_path, bytes)?;
std::fs::rename(&tmp_path, &final_path)?;
match import_theme(&final_path) {
Ok(theme_id) => Ok(Some(theme_id.as_str().to_string())),
Err(ImportError::IdCollision { .. }) => Ok(None),
Err(e) => {
// Don't leave a zip that fails validation lying around for
// the folder-scan flow to re-try on every scan.
let _ = std::fs::remove_file(&final_path);
Err(e.into())
}
}
}
/// Polls the install task; on completion refreshes the theme registry,
/// toasts the outcome, and rebuilds the modal so the row flips to
/// "Installed".
#[allow(clippy::too_many_arguments)]
fn poll_install_task(
mut install_task: ResMut<InstallTask>,
mut registry: Option<ResMut<ThemeRegistry>>,
catalog_state: Res<CatalogState>,
screens: Query<Entity, With<ThemeStoreScreen>>,
mut info_toast: MessageWriter<InfoToastEvent>,
mut warning_toast: MessageWriter<WarningToastEvent>,
mut commands: Commands,
font_res: Option<Res<FontResource>>,
) {
let Some((id, task)) = install_task.0.as_mut() else {
return;
};
let Some(result) = future::block_on(future::poll_once(task)) else {
return;
};
let id = id.clone();
install_task.0 = None;
match result {
Ok(Some(theme_id)) => {
if let Some(registry) = registry.as_deref_mut() {
refresh_registry(registry, &user_theme_dir());
}
info_toast.write(InfoToastEvent(format!(
"Theme '{theme_id}' installed — pick it in Settings → Cosmetic."
)));
}
Ok(None) => {
info_toast.write(InfoToastEvent(format!("Theme '{id}' already installed.")));
}
Err(e) => {
warn!("theme store: install of '{id}' failed: {e}");
warning_toast.write(WarningToastEvent(format!("Install failed: {e}")));
}
}
rebuild_open_modal(
&screens,
&mut commands,
&catalog_state,
registry.as_deref(),
font_res.as_deref(),
);
}
/// 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,
) {
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();
}
}
// ---------------------------------------------------------------------------
// Modal rendering
// ---------------------------------------------------------------------------
/// Despawn + respawn the modal for every open screen (Bevy despawns
/// are deferred, so this is safe within one frame).
fn rebuild_open_modal(
screens: &Query<Entity, With<ThemeStoreScreen>>,
commands: &mut Commands,
catalog_state: &CatalogState,
registry: Option<&ThemeRegistry>,
font_res: Option<&FontResource>,
) {
for entity in screens {
commands.entity(entity).despawn();
spawn_store_modal(commands, catalog_state, registry, font_res);
}
}
/// Spawns the store modal for the current catalog state.
fn spawn_store_modal(
commands: &mut Commands,
catalog_state: &CatalogState,
registry: Option<&ThemeRegistry>,
font_res: Option<&FontResource>,
) {
let body_font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_BODY,
..default()
};
let caption_font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_CAPTION,
..default()
};
let scrim = spawn_modal(commands, ThemeStoreScreen, Z_MODAL_PANEL + 1, |card| {
spawn_modal_header(card, "Theme Store", font_res);
match catalog_state {
CatalogState::Idle | CatalogState::Loading => {
card.spawn((
Text::new("Loading catalog…"),
body_font.clone(),
TextColor(TEXT_SECONDARY),
));
}
CatalogState::Error(msg) => {
card.spawn((
Text::new(format!("Could not load the catalog: {msg}")),
body_font.clone(),
TextColor(TEXT_SECONDARY),
));
}
CatalogState::Loaded(entries) if entries.is_empty() => {
card.spawn((
Text::new("The server has no themes yet."),
body_font.clone(),
TextColor(TEXT_SECONDARY),
));
}
CatalogState::Loaded(entries) => {
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_modal_actions(card, |actions| {
spawn_modal_button(
actions,
ThemeStoreCloseButton,
"Close",
None,
ButtonVariant::Primary,
font_res,
);
});
});
commands.entity(scrim).insert(ScrimDismissible);
}
/// One catalog row: name + author/size caption on the left, Install
/// button (or "Installed" caption) on the right.
fn spawn_store_row(
parent: &mut ChildSpawnerCommands,
entry: &ThemeCatalogEntry,
installed: bool,
body_font: &TextFont,
caption_font: &TextFont,
font_res: Option<&FontResource>,
) {
parent
.spawn((
Node {
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::SpaceBetween,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_3,
padding: UiRect::vertical(VAL_SPACE_2),
border: UiRect::bottom(Val::Px(1.0)),
..default()
},
BorderColor::all(BORDER_SUBTLE),
))
.with_children(|row| {
row.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((
Text::new("Installed"),
caption_font.clone(),
TextColor(TEXT_SECONDARY),
));
} else {
spawn_modal_button(
row,
ThemeStoreInstallButton(entry.clone()),
"Install",
None,
ButtonVariant::Secondary,
font_res,
);
}
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use bevy::ecs::message::Messages;
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(ThemeStorePlugin);
app.update();
app
}
fn settings_with_server() -> SettingsResource {
SettingsResource(Settings {
sync_backend: SyncBackend::SolitaireServer {
url: "http://127.0.0.1:1".to_string(),
username: "tester".to_string(),
avatar_url: None,
},
..Settings::default()
})
}
fn screen_count(app: &mut App) -> usize {
app.world_mut()
.query::<&ThemeStoreScreen>()
.iter(app.world())
.count()
}
#[test]
fn store_base_url_requires_server_backend() {
assert_eq!(store_base_url(&Settings::default()), None);
assert_eq!(
store_base_url(&settings_with_server().0).as_deref(),
Some("http://127.0.0.1:1")
);
}
#[test]
fn open_request_without_server_warns_and_spawns_nothing() {
let mut app = headless_app();
// Default settings → SyncBackend::Local → no store.
app.insert_resource(SettingsResource(Settings::default()));
app.world_mut()
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
.write(ThemeStoreOpenRequestEvent);
app.update();
assert_eq!(screen_count(&mut app), 0);
assert!(
!app.world()
.resource::<Messages<WarningToastEvent>>()
.is_empty(),
"player must be told why the store did not open"
);
}
#[test]
fn open_request_without_runtime_warns_and_spawns_nothing() {
let mut app = headless_app();
app.insert_resource(settings_with_server());
// No TokioRuntimeResource inserted.
app.world_mut()
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
.write(ThemeStoreOpenRequestEvent);
app.update();
assert_eq!(screen_count(&mut app), 0);
assert!(
!app.world()
.resource::<Messages<WarningToastEvent>>()
.is_empty()
);
}
#[test]
fn open_request_with_server_spawns_modal_in_loading_state() {
let mut app = headless_app();
app.insert_resource(settings_with_server());
app.insert_resource(TokioRuntimeResource::new().expect("test runtime"));
app.world_mut()
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
.write(ThemeStoreOpenRequestEvent);
app.update();
assert_eq!(screen_count(&mut app), 1);
assert!(matches!(
*app.world().resource::<CatalogState>(),
CatalogState::Loading | CatalogState::Error(_)
));
assert_eq!(
app.world().resource::<StoreBaseUrl>().0.as_deref(),
Some("http://127.0.0.1:1")
);
}
#[test]
fn second_open_request_does_not_stack_a_second_modal() {
let mut app = headless_app();
app.insert_resource(settings_with_server());
app.insert_resource(TokioRuntimeResource::new().expect("test runtime"));
for _ in 0..2 {
app.world_mut()
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
.write(ThemeStoreOpenRequestEvent);
app.update();
}
assert_eq!(screen_count(&mut app), 1);
}
#[test]
fn failed_catalog_fetch_rebuilds_modal_with_error_state() {
let mut app = headless_app();
app.insert_resource(settings_with_server());
app.insert_resource(TokioRuntimeResource::new().expect("test runtime"));
app.world_mut()
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
.write(ThemeStoreOpenRequestEvent);
app.update();
// 127.0.0.1:1 refuses connections; the fetch task must resolve
// to an error within a bounded number of frames and the modal
// must survive the rebuild.
for _ in 0..200 {
app.update();
if matches!(
*app.world().resource::<CatalogState>(),
CatalogState::Error(_)
) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(matches!(
*app.world().resource::<CatalogState>(),
CatalogState::Error(_)
));
assert_eq!(screen_count(&mut app), 1);
}
}
@@ -28,8 +28,8 @@
use bevy::ecs::message::MessageReader;
use bevy::prelude::*;
use solitaire_core::KlondikePile;
use solitaire_core::Card;
use solitaire_core::KlondikePile;
use crate::card_plugin::CardEntity;
use crate::events::StateChangedEvent;

Some files were not shown because too many files have changed in this diff Show More