Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27cdf78ce0 | |||
| faa6c5efc4 | |||
| 487b99bbc9 | |||
| 53e3b816cf | |||
| 87275bf340 | |||
| 56647d7f0d | |||
| cbf2483028 | |||
| a54201e97b | |||
| 48e412177c | |||
| cd54ce1bb0 | |||
| 7a3032b74c | |||
| 89699a8a86 | |||
| 70165da103 | |||
| 8a5fa8751c | |||
| bf660df971 | |||
| 13a8a012ee | |||
| 02ababa65f | |||
| 9c36b49729 | |||
| 8e90574437 | |||
| 95fcdad5d2 |
@@ -716,11 +716,14 @@ pub struct AchievementDef {
|
||||
| `speed_and_skill` | ??? | Win < 90s without undo | Yes | Card back #4 |
|
||||
| `comeback` | ??? | Win after 3+ stock recycles | Yes | Background #4 |
|
||||
| `zen_winner` | ??? | Win in Zen Mode | Yes | Badge |
|
||||
| `cinephile` | Cinephile | Watch a saved replay all the way through | No | — |
|
||||
|
||||
### Evaluation Timing
|
||||
|
||||
Achievement conditions are evaluated by `AchievementPlugin` on every `GameWonEvent` and `StateChangedEvent`. The plugin calls `solitaire_core::check_achievements()` which returns a `Vec<AchievementDef>` of newly unlocked achievements. The plugin then fires `AchievementUnlockedEvent` for each, which the toast and persistence systems handle independently.
|
||||
|
||||
A small number of achievements are *event-driven* rather than condition-driven: their `AchievementDef::condition` always returns `false` and their unlock is written from a dedicated observer system instead. `cinephile` is the canonical example — it unlocks when `ReplayPlaybackState` transitions from `Playing` to `Completed` (a saved replay watched to its natural end). The Stop button transitions `Playing → Inactive` directly without entering `Completed`, so manual aborts do not unlock the achievement.
|
||||
|
||||
---
|
||||
|
||||
## 12. Progression System
|
||||
|
||||
+155
-1
@@ -8,6 +8,158 @@ project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
_Nothing yet._
|
||||
|
||||
## [0.17.0] — 2026-05-06
|
||||
|
||||
A short follow-up round on top of v0.16.0: the H-key hint is no
|
||||
longer a heuristic guess but the actual best first move suggested by
|
||||
the v0.15.0 solver, and the in-engine replay player now has a
|
||||
player-tunable playback rate.
|
||||
|
||||
### Added
|
||||
|
||||
- **Replay-rate slider** in Settings → Gameplay. Tunes
|
||||
`replay_move_interval_secs` from 0.10 s to 1.00 s in 0.05 s steps;
|
||||
default 0.45 s. `tick_replay_playback` reads the value from
|
||||
`SettingsResource` per frame so the slider takes effect on the
|
||||
next playback tick — no restart required.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Solver-driven hints.** Pressing **H** used to surface a
|
||||
heuristic-best move (foundation moves preferred, then
|
||||
tableau-to-tableau by depth-of-flip-revealed). It now asks the
|
||||
v0.15.0 solver for the actual provably-best first move via the
|
||||
new `solitaire_core::solver::try_solve_with_first_move` /
|
||||
`try_solve_from_state` APIs. When the solver returns inconclusive
|
||||
(rare deals where the bound runs out before a result), the old
|
||||
heuristic remains the fallback. Median 2 ms per H press.
|
||||
|
||||
### Stats
|
||||
|
||||
- 1208 passing tests (was 1196 at v0.16.0 close).
|
||||
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||
|
||||
## [0.16.0] — 2026-05-06
|
||||
|
||||
A modal-feel polish round. Every overlay screen now scrolls when its
|
||||
content overflows the 800×600 minimum window, every clickable button
|
||||
shows a hand cursor on hover, keyboard focus lands on the primary
|
||||
button on the same frame the modal opens, and read-only modals
|
||||
dismiss when the player clicks the scrim outside the card.
|
||||
|
||||
### Added
|
||||
|
||||
- **Pointer cursor on hover** for every interactive `Button` entity
|
||||
(modal buttons, HUD action bar, mode-launcher cards, settings
|
||||
toggles, Stats selectors). `update_cursor_icon` gains a fourth
|
||||
branch sitting between Grabbing (active drag) and Grab
|
||||
(draggable card hover): when no drag is active and any
|
||||
`Interaction::Hovered`/`Pressed` button is detected, the window
|
||||
cursor swaps to `SystemCursorIcon::Pointer`. A pure
|
||||
`pick_cursor_icon` helper makes the priority logic
|
||||
unit-testable.
|
||||
- **Click-outside-to-dismiss** for the six read-only modals: Stats,
|
||||
Achievements, Help, Profile, Leaderboard, Home. New
|
||||
`ScrimDismissible` marker on `ModalScrim` opts a modal in;
|
||||
`dismiss_modal_on_scrim_click` runs in `Update`, despawns the
|
||||
topmost dismissible scrim on a left-mouse press whose cursor
|
||||
lands on the scrim and outside every `ModalCard`. Bevy's
|
||||
hierarchy despawn cascades to the card and children.
|
||||
Settings, Onboarding, Pause, Forfeit confirm, and Confirm New
|
||||
Game intentionally don't opt in — they carry unsaved or
|
||||
destructive state.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Modal content scrolls when it overflows** (Achievements, Help,
|
||||
Stats, Profile, Leaderboard). Each modal's body Node now
|
||||
carries `Overflow::scroll_y()` plus a `max_height` constraint
|
||||
(`Val::Vh(70.0)` for most, `Val::Vh(50.0)` for the
|
||||
leaderboard's variable-length ranking section) and a marker
|
||||
component (`AchievementsScrollable`, `HelpScrollable`,
|
||||
`StatsScrollable`, `ProfileScrollable`,
|
||||
`LeaderboardScrollable`). A sibling `scroll_*_panel` system
|
||||
per modal routes `MouseWheel` events into the body's
|
||||
`ScrollPosition`. Mirrors the existing `SettingsPanelScrollable`
|
||||
pattern. Home modal intentionally not scrolled — its five
|
||||
mode cards + Cancel are sized to fit at 800×600 by design.
|
||||
- **Modal focus arrives on the same frame the modal opens.**
|
||||
Previously `attach_focusable_to_modal_buttons` and
|
||||
`auto_focus_on_modal_open` ran in `Update` alongside arbitrary
|
||||
click-handlers that spawn modals; with no ordering edge,
|
||||
Bevy's deferred `Commands` queued the new entities but the
|
||||
attach system couldn't see them on the same tick. Both systems
|
||||
moved to `PostUpdate` so the schedule boundary itself supplies
|
||||
the sync point — `FocusedButton` is always populated before
|
||||
`app.update()` returns. The very next Tab/Enter press lands on
|
||||
a populated resource instead of wasting itself moving focus
|
||||
from None to the primary.
|
||||
|
||||
### Stats
|
||||
|
||||
- 1196 passing tests (was 1178 at v0.15.0 close).
|
||||
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||
|
||||
## [0.15.0] — 2026-05-02
|
||||
|
||||
In-engine replay playback, the Klondike solver + "Winnable deals
|
||||
only" toggle, a 19th achievement, rolling replay history, and a
|
||||
significant build-time / binary-size win from disabling Bevy's
|
||||
default audio stack.
|
||||
|
||||
### Added
|
||||
|
||||
- **In-engine replay playback** for the Stats overlay's Watch Replay
|
||||
button. New `ReplayPlaybackPlugin` runs a state machine
|
||||
(Inactive / Playing / Completed) that resets the live game to the
|
||||
recorded deal and ticks through `replay.moves` at
|
||||
`REPLAY_MOVE_INTERVAL_SECS` (0.45 s) firing the canonical
|
||||
`MoveRequestEvent` / `DrawRequestEvent` per recorded move.
|
||||
Recording is suppressed during playback so replays don't re-record
|
||||
themselves.
|
||||
- **Replay overlay banner** (`ReplayOverlayPlugin`) anchored to the
|
||||
top of the window during playback. Shows "Replay" label, "Move N
|
||||
of M" progress, and a Stop button. Z-order leaves modals
|
||||
(Settings, Pause, Help) free to render on top so the player can
|
||||
adjust audio mid-replay.
|
||||
- **Rolling replay history** at `<data_dir>/replays.json` capped at
|
||||
8 entries. Replaces the single-slot `latest_replay.json` (legacy
|
||||
file is migrated forward on first launch via
|
||||
`migrate_legacy_latest_replay`). Stats overlay gains a Prev / Next
|
||||
selector and a "Replay N / M" caption so the player can revisit
|
||||
older wins.
|
||||
- **"Cinephile" achievement** (#19). Unlocks the first time
|
||||
`ReplayPlaybackState` transitions Playing → Completed (i.e. the
|
||||
replay played out to its end without the player pressing Stop).
|
||||
Stop transitions Playing → Inactive directly so it doesn't count.
|
||||
- **Klondike solver** in `solitaire_core::solver`. Iterative-DFS
|
||||
with memoisation on a 64-bit canonical state hash, two budget
|
||||
knobs (move_budget + state_budget) for pathological cases, and a
|
||||
three-state `SolverResult` (Winnable / Unwinnable / Inconclusive).
|
||||
Median solve time 2 ms; pathological inconclusives cap near
|
||||
120 ms. Pure logic — `solitaire_core` keeps no Bevy or I/O.
|
||||
- **"Winnable deals only" toggle** in Settings → Gameplay (default
|
||||
off). When on, `handle_new_game` walks seed N, N+1, N+2, …
|
||||
through `try_solve` until it finds Winnable or Inconclusive,
|
||||
capped at `SOLVER_DEAL_RETRY_CAP` (50) attempts. Daily
|
||||
challenges, replays, and explicit-seed requests bypass the
|
||||
solver — only random Classic deals are gated.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Bevy default-feature trim** (`bevy = { default-features = false,
|
||||
features = [...] }` in workspace Cargo.toml) drops 51 transitive
|
||||
crates including the `bevy_audio` → rodio → cpal 0.15 + symphonia
|
||||
chain that the project doesn't use (kira handles audio directly).
|
||||
The retained feature list is curated to exactly what the engine
|
||||
uses; `solitaire_wasm` is unaffected because it doesn't depend on
|
||||
bevy.
|
||||
|
||||
### Stats
|
||||
|
||||
- 1178 passing tests (was 1134 at v0.14.0 close).
|
||||
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||
|
||||
## [0.14.0] — 2026-05-02
|
||||
|
||||
Two threads land in v0.14.0: the second half of the post-v0.12.0 UX
|
||||
@@ -405,7 +557,9 @@ with no PNG artwork yet.
|
||||
CREDITS.md, persistent window geometry, mode-launcher Home repurpose,
|
||||
client-side sync round-trip integration tests.
|
||||
|
||||
[Unreleased]: https://github.com/funman300/Rusty_Solitaire/compare/v0.14.0...HEAD
|
||||
[Unreleased]: https://github.com/funman300/Rusty_Solitaire/compare/v0.16.0...HEAD
|
||||
[0.16.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.15.0...v0.16.0
|
||||
[0.15.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.14.0...v0.15.0
|
||||
[0.14.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.13.0...v0.14.0
|
||||
[0.13.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.12.0...v0.13.0
|
||||
[0.12.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.11.0...v0.12.0
|
||||
|
||||
Generated
+23
-1090
File diff suppressed because it is too large
Load Diff
+40
-1
@@ -36,7 +36,46 @@ solitaire_sync = { path = "solitaire_sync" }
|
||||
solitaire_data = { path = "solitaire_data" }
|
||||
solitaire_engine = { path = "solitaire_engine" }
|
||||
|
||||
bevy = "0.18"
|
||||
# Bevy with `default-features = false` to avoid the unused
|
||||
# `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain.
|
||||
# Audio is handled directly by `kira` in `audio_plugin.rs`, so the
|
||||
# `bevy_audio` feature is intentionally omitted. The features below
|
||||
# enumerate every leaf of the standard `2d` + `ui` meta-features that
|
||||
# we actually use; new features should only be added with a
|
||||
# corresponding use site.
|
||||
bevy = { version = "0.18", default-features = false, features = [
|
||||
# default_app
|
||||
"async_executor",
|
||||
"bevy_asset",
|
||||
"bevy_input_focus",
|
||||
"bevy_log",
|
||||
"bevy_state",
|
||||
"bevy_window",
|
||||
"custom_cursor",
|
||||
"reflect_auto_register",
|
||||
# default_platform (desktop subset; no android/wayland/webgl/gilrs/sysinfo)
|
||||
"std",
|
||||
"bevy_winit",
|
||||
"default_font",
|
||||
"multi_threaded",
|
||||
"x11",
|
||||
# common_api
|
||||
"bevy_color",
|
||||
"bevy_image",
|
||||
"bevy_mesh",
|
||||
"bevy_shader",
|
||||
"bevy_text",
|
||||
"png",
|
||||
# 2d rendering
|
||||
"bevy_camera",
|
||||
"bevy_render",
|
||||
"bevy_core_pipeline",
|
||||
"bevy_sprite",
|
||||
"bevy_sprite_render",
|
||||
# UI rendering
|
||||
"bevy_ui",
|
||||
"bevy_ui_render",
|
||||
] }
|
||||
kira = "0.12"
|
||||
|
||||
# SVG rasterisation pipeline for the runtime card-theme system.
|
||||
|
||||
@@ -22,7 +22,7 @@ optional self-hosted sync so your stats follow you across machines.
|
||||
move within picker rows, Enter activates; works across every modal and
|
||||
the HUD action bar
|
||||
- **Progression** — XP, levels, unlockable card backs and backgrounds
|
||||
- **18 Achievements** — including secret ones
|
||||
- **19 Achievements** — including secret ones
|
||||
- **Daily Challenge** — server-seeded so every player worldwide gets the
|
||||
same deal
|
||||
- **Leaderboard** — opt-in, powered by your own self-hosted server
|
||||
|
||||
+40
-69
@@ -1,24 +1,22 @@
|
||||
# Solitaire Quest — Session Handoff
|
||||
|
||||
**Last updated:** 2026-05-02 (session 9, post-v0.14.0 release prep) — v0.14.0 cut. The Quat bug fixes, the rest of the v0.13.0 candidate list, and the entire replay → upload → web-viewer pipeline are all bundled in this release. Direction now opens for the next round.
|
||||
**Last updated:** 2026-05-06 (post-v0.17.0) — v0.17.0 cut on top of v0.16.0 bundling the solver-driven hints (`87275bf`) and the replay-rate slider (`53e3b81`). An async-solver attempt earlier in the session was rolled back when an agent left 3 failing tests during interruption — flagged as carryover. Test-to-work ratio noted as a quality signal: future agent briefs scale back to behaviour-level tests only, not stdlib/serde-derive coverage.
|
||||
|
||||
## Status at pause
|
||||
|
||||
- **HEAD on origin:** v0.14.0's tag commit (CHANGELOG + handoff refresh).
|
||||
- **HEAD on origin:** v0.17.0's tag commit.
|
||||
- **Working tree:** clean apart from untracked `CARD_PLAN.md` (intentional).
|
||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
||||
- **Tests:** **1134 passed / 0 failed** across the workspace.
|
||||
- **Tags on origin:** `v0.9.0`, `v0.10.0`, `v0.11.0`, `v0.12.0`, `v0.13.0`, `v0.14.0`.
|
||||
- **Tests:** **1208 passed / 0 failed** across the workspace.
|
||||
- **Tags on origin:** `v0.9.0` through `v0.17.0`.
|
||||
|
||||
## Where we are
|
||||
|
||||
v0.14.0 is the largest release since the card-theme system. Three threads land together:
|
||||
v0.16.0 is the smallest meaningful release in a while — a focused round on how modals feel rather than what they contain. The originating bug was "I can't scroll on the Achievements list"; the sweep that followed found four other modals with the same problem plus three smaller modal-feel gaps (no pointer cursor on buttons, focus arriving a frame late, no click-outside-to-dismiss).
|
||||
|
||||
1. **The remaining v0.13.0-era UX candidates** — theme thumbnails, daily-challenge calendar, Time Attack auto-save, per-mode bests, time-bonus multiplier slider.
|
||||
2. **Quat smoke-test bug fixes** — multi-card move validation, softlock detection, deal-tween information leak.
|
||||
3. **The replay pipeline** — record on win, persist to disk, upload to server, view in browser via a new `solitaire_wasm` crate. The biggest single feature since the card-theme system.
|
||||
Every overlay screen now: scrolls if its content can overflow at 800×600, shows a hand cursor when you hover any button, has its primary auto-focused the moment the modal appears so the very first Tab/Enter is meaningful, and (for read-only screens) dismisses when you click outside the card.
|
||||
|
||||
The card-flight web animations and replay E2E test coverage close out the pipeline.
|
||||
The post-v0.15.0 next-round candidates are still mostly open — solver-driven hints, replay-rate slider, solver progress overlay, async solver, "won previously" indicator, replay sharing. Direction is open.
|
||||
|
||||
### Design direction (unchanged)
|
||||
|
||||
@@ -30,64 +28,38 @@ The card-flight web animations and replay E2E test coverage close out the pipeli
|
||||
|
||||
`github.com/funman300/Rusty_Solitaire` is the canonical repo. Always push there.
|
||||
|
||||
## Session 8 + 9 (shipped 2026-05-02) — v0.14.0
|
||||
|
||||
### v0.13.0-era UX candidates (had landed but missed v0.13.0's tag)
|
||||
## v0.17.0 (shipped 2026-05-06)
|
||||
|
||||
| Area | Commit | What landed |
|
||||
|---|---|---|
|
||||
| Theme thumbnails | `ba527de` | Each Settings → Cosmetic theme chip renders an Ace + back preview pair via `rasterize_svg`. Cached per theme. Missing-SVG themes show a transparent placeholder rather than crashing. |
|
||||
| Daily-challenge calendar | `1a10476` | 14-dot horizontal calendar in the Profile modal. Today is ringed, completed days fill `STATE_SUCCESS`, missed days fill `BG_ELEVATED`. Caption: "Current streak: N · Longest: M". `PlayerProgress` gains `daily_challenge_history` (capped at 365) and `daily_challenge_longest_streak`. |
|
||||
| Time Attack auto-save | `0001432` | New sibling `time_attack_session.json` next to `game_state.json`. Atomic .tmp + rename. 30 s auto-save while active + on `AppExit`. Sessions whose 10-min window expired in real time while the app was closed are discarded on load. |
|
||||
| Per-mode bests | `3984231` | StatsSnapshot gains six `#[serde(default)]` fields (Classic / Zen / Challenge × best_score + fastest_win_seconds). Stats screen renders a "Per-mode bests" section. Lifetime totals continue to roll all modes together. |
|
||||
| Time-bonus slider | `89c51ab` | Settings → Gameplay slider 0.0–2.0, default 1.0, "Off" at zero. Multiplies the time-bonus shown in the win modal. Cosmetic only — does NOT affect achievement unlock thresholds. |
|
||||
| Solver-driven hints | `87275bf` | The H-key hint asks the solver for the actual best first move via `try_solve_with_first_move` / `try_solve_from_state`. Heuristic stays as fallback. Median 2 ms per H press. |
|
||||
| Replay-rate slider | `53e3b81` | Settings → Gameplay slider tunes `replay_move_interval_secs` 0.10–1.00 s in 0.05 s steps; default 0.45 s. Read per frame from `SettingsResource`. |
|
||||
|
||||
### Quat smoke-test bug fixes
|
||||
## v0.16.0 (shipped 2026-05-06)
|
||||
|
||||
| Area | Commit | What landed |
|
||||
|---|---|---|
|
||||
| Move validation (#1) | `f1aeb24` | `solitaire_core::rules::is_valid_tableau_sequence(&[Card]) -> bool` checks every adjacent pair in a moved stack descends one rank with alternating colour. Wired into `move_cards`. Closes the bug where any multi-card lift could be dropped as long as the bottom landed legally. |
|
||||
| Deal-tween leak (#4) | `3eabc14` | New-game snaps every card sprite to the stock pile position before writing `StateChangedEvent`, so all 52 cards animate from a single deck point during the deal. Previously sprites started from previous-game positions, briefly revealing the prior deal. |
|
||||
| Softlock detection (#2) | `2716472` | `has_legal_moves` rewritten: walks every potential move source (every stock card, every waste card, the face-up top of every tableau column) against every foundation and every tableau. Previous heuristic returned `true` whenever stock had cards, hiding genuine softlocks. `GameOverScreen` now actually fires for true softlocks. |
|
||||
| End-game screen (#3) | — | Resolved as downstream of #2. The pre-existing `GameOverScreen` and `WinSummaryOverlay` already cover the close-out paths; the softlock screen just never spawned because the old `has_legal_moves` lied. |
|
||||
|
||||
### Replay pipeline (the major feature)
|
||||
|
||||
| Area | Commit | What landed |
|
||||
|---|---|---|
|
||||
| Replay storage | `42535f5` | `solitaire_data::replay::Replay` (seed + draw_mode + mode + score + time + recorded date + ordered move list) and atomic save/load helpers under `<data_dir>/latest_replay.json`. Schema v1; `load` returns None for any other version. |
|
||||
| Engine recording | `57d1c58` | `RecordingReplay` resource + `ReplayPath` settings. Every successful `MoveRequestEvent` / `DrawRequestEvent` appends to recording; `GameWonEvent` freezes the recording into a `Replay` and persists. Undo intentionally not recorded. New game clears the recording. |
|
||||
| Stats button | `d9f36bf` | Stats overlay surfaces a "Latest win:" caption + "Watch replay" button. Loads from disk via `LatestReplayResource`. (Full in-engine playback deferred — button currently fires an `InfoToastEvent` describing the replay.) |
|
||||
| Server upload + fetch | `93182fa` | `POST /api/replays` accepts a `Replay` JSON; `GET /api/replays/:id` returns it. JWT-gated. SQL migration for the new `replays` table. |
|
||||
| Engine sync | `23c9704` | Engine uploads winning replays automatically when the player has cloud sync configured. Re-uses the existing JWT/refresh-token flow. |
|
||||
| WASM crate | `5bed43e` | New workspace member `solitaire_wasm` compiles replay-relevant `solitaire_core` types to WebAssembly so a browser can re-execute a replay client-side. `wasm-bindgen` glue. |
|
||||
| Web viewer | `07b8ecd` | `GET /replays/:id` returns HTML + CSS + the wasm bundle. Browser fetches the replay JSON, rasterises a deal from the seed, and animates the recorded moves. |
|
||||
| E2E coverage | `3081505` | Server tests covering the full upload → fetch round-trip via `axum::test`. |
|
||||
| Web flight anim | `1fcd032` | Card-flight tweens on the web side so the browser viewer reads as a real game replay rather than a static dump. |
|
||||
| Modal scroll | `7a3032b` | Achievements / Help / Stats / Profile / Leaderboard bodies now carry `Overflow::scroll_y()` + a `max_height` constraint + a per-plugin `*Scrollable` marker. Sibling `scroll_*_panel` systems route `MouseWheel` into the body's `ScrollPosition`. Mirrors the existing `SettingsPanelScrollable` pattern. Home modal not scrolled — five mode cards + Cancel are sized to fit by design. |
|
||||
| Pointer cursor | `cd54ce1` | `update_cursor_icon` gains a fourth branch: `SystemCursorIcon::Pointer` whenever any `Interaction::Hovered`/`Pressed` button is detected and no card drag is active. Branch order Grabbing → Pointer → Grab → Default. Pure `pick_cursor_icon(is_dragging, any_button_hovered, any_card_hovered)` helper unit-tests the priority. |
|
||||
| Same-frame focus | `48e4121` | `attach_focusable_to_modal_buttons` and `auto_focus_on_modal_open` moved from `Update` to `PostUpdate`. The schedule boundary supplies the sync point so a click-handler in `Update` that spawns a modal has its `Commands` materialised before attach runs. `FocusedButton` is populated before `app.update()` returns; the very first Tab/Enter after open lands on a populated resource. |
|
||||
| Scrim dismiss core | `a54201e` | New `ScrimDismissible` marker on `ModalScrim` opts a modal into click-outside-to-close. `dismiss_modal_on_scrim_click` system in `ui_modal` despawns the topmost dismissible scrim on a left-mouse press whose cursor lands on the scrim and outside every `ModalCard`. Stats / Achievements / Help opted in. |
|
||||
| Scrim dismiss tail | `cbf2483` | One-line opt-in (capture scrim + insert marker) for Profile / Leaderboard / Home, completing all six read-only modals. |
|
||||
|
||||
## Open punch list
|
||||
|
||||
### Release prep
|
||||
1. **Smoke-test on the alex machine** after pulling — confirm Quat's three bug fixes hold up in real gameplay, and try the new replay button + web viewer end-to-end.
|
||||
2. **Desktop packaging** per `ARCHITECTURE.md §17`. The Arch PKGBUILD exists in `/home/manage/solitaire-quest-pkgbuild/` (separate repo). Pending: app icon, macOS `.icns` + notarisation cert, Windows `.ico` + Authenticode cert, AppImage recipe.
|
||||
|
||||
### UX iteration (next-round candidates)
|
||||
1. **Desktop packaging** per `ARCHITECTURE.md §17`. Arch PKGBUILD exists in `/home/manage/solitaire-quest-pkgbuild/` (separate repo). Pending: app icon, macOS `.icns` + notarisation cert, Windows `.ico` + Authenticode cert, AppImage recipe.
|
||||
|
||||
- **Solver-at-deal toggle** (Quat investigation #1, still deferred): add a Settings → Gameplay toggle "Winnable deals only" rather than baking solver-only into every deal. Lightest middle ground.
|
||||
- **Disable Bevy's default audio feature** (Quat investigation #2, still deferred): one-line `default-features = false` swap on the workspace `bevy =` line, re-enable explicitly the features the engine uses (`render`, `bevy_winit`, `2d`, `bevy_window`, `png`, `bevy_text`, `bevy_ui`, `bevy_log`, `bevy_asset`, `default_font`, `bevy_state`). Drops ~50 transitive crates including the rodio + symphonia stack the project doesn't use (kira handles audio).
|
||||
- **In-engine replay playback** — promote the "Watch replay" button from a stub toast to a real playback overlay that re-runs the recorded moves with `CardAnimation` tweens. The wasm crate already proves the playback math; the in-engine version reuses the same execute logic against the live game state.
|
||||
- **Per-replay history** — currently single-slot at `latest_replay.json`. A "best replay per mode" bucket or a recent-N rolling list would let players revisit notable wins.
|
||||
- **Solver-driven hint system** — extend the existing hint toggle so a deal-time solver provides higher-quality hints (currently a heuristic). Requires the solver from the toggle work above.
|
||||
- **Achievement: "won via replay path"** — track when a player wins a deal whose previously-saved replay also won the same deal. Mostly fun; trivial scope.
|
||||
### Process note (raised this session)
|
||||
|
||||
## Card-theme system (CARD_PLAN.md, fully shipped)
|
||||
Recent agent briefs reflexively asked for ≥3 tests per feature, which produced low-value coverage on trivial settings fields (default-value tests, serde-derive round-trips, clamp tests that just exercise stdlib `clamp`). Future agent briefs should ask only for tests that pin **behaviour contracts or regressions on real bugs** — not coverage of language/library mechanics.
|
||||
|
||||
Seven phases landed across `b8fb3fb` → `924a1e2` in v0.11.0; v0.13.0's `7ed4f2c` consumes the per-theme `back.svg`; v0.14.0's `ba527de` adds preview thumbnails. End-to-end:
|
||||
### Carryover candidates — still open
|
||||
|
||||
- **Bundled default theme** ships in the binary via `embedded://` — 52 hayeah/playing-cards-assets SVGs + a midnight-purple `back.svg`.
|
||||
- **User themes** under `themes://`. Drop a directory containing `theme.ron` + 53 SVGs.
|
||||
- **Importer** at `solitaire_engine::theme::import_theme(zip)` validates archives and atomically unpacks.
|
||||
- **Picker UI** in Settings → Cosmetic; thumbnails + the active theme's `back` override the legacy `back_N.png` picker when present.
|
||||
- **Solver-on-AsyncComputeTaskPool** — current solver runs synchronously on the main thread. Worst-case 50 attempts × 120 ms = 6 s of UI stall on pathological seeds. **An attempt this session was rolled back** when an agent was interrupted leaving 3 failing tests; redoing this needs more careful scoping (smaller pieces, real cancel-and-test flow, NOT a parallel agent split). Worth taking next.
|
||||
- **Per-deal "won previously" indicator** — the rolling replay history's seeds make this easy: when a new game starts on a seed the player has already won, surface a tiny indicator on the HUD.
|
||||
- **Replay sharing** — `replays.json` is per-machine. Allow a player to copy a replay's URL (already wired via `solitaire_server`) and post it elsewhere. The web-viewer already exists.
|
||||
|
||||
## Resume prompt
|
||||
|
||||
@@ -95,17 +67,18 @@ Seven phases landed across `b8fb3fb` → `924a1e2` in v0.11.0; v0.13.0's `7ed4f2
|
||||
You are a senior Rust + Bevy developer working on Solitaire Quest.
|
||||
Working directory: <Rusty_Solitaire clone path on this machine — local
|
||||
directory may still be named Rusty_Solitare from earlier; that's fine>.
|
||||
Branch: master. Direction is OPEN — v0.14.0 just shipped covering the
|
||||
Quat bug fixes, the v0.13.0 candidate tail, and the entire
|
||||
replay-pipeline feature.
|
||||
Branch: master. Direction is OPEN — v0.16.0 just shipped covering
|
||||
modal scroll fixes, pointer cursor, same-frame focus, and scrim-click
|
||||
dismiss across all six read-only modals.
|
||||
|
||||
State: HEAD at v0.14.0. Working tree clean apart from untracked
|
||||
CARD_PLAN.md (intentional).
|
||||
State: HEAD at v0.17.0 (solver hints + replay-rate slider on top
|
||||
of v0.16.0). Working tree clean apart from untracked CARD_PLAN.md
|
||||
(intentional).
|
||||
Build: cargo clippy --workspace --all-targets -- -D warnings clean.
|
||||
Tests: 1134 passed / 0 failed.
|
||||
Tests: 1208 passed / 0 failed.
|
||||
|
||||
READ FIRST (in order, before doing anything):
|
||||
1. SESSION_HANDOFF.md — v0.14.0 changelog + open punch list
|
||||
1. SESSION_HANDOFF.md — v0.16.0 changelog + open punch list
|
||||
2. CHANGELOG.md — release-by-release record
|
||||
3. CLAUDE.md — hard rules (UI-first, no panics, etc.)
|
||||
4. ARCHITECTURE.md — crate responsibilities + data flow
|
||||
@@ -114,16 +87,14 @@ READ FIRST (in order, before doing anything):
|
||||
may be missing on a fresh machine)
|
||||
|
||||
DECISION TO ASK THE PLAYER FIRST:
|
||||
A. Smoke-test v0.14.0 on the alex machine first to confirm the
|
||||
three Quat bug fixes hold up in real gameplay and the replay
|
||||
pipeline works end-to-end (record → upload → web viewer).
|
||||
B. Take the deferred Bevy-audio-feature trim (Quat investigation
|
||||
#2) — one-line workspace edit, ~50 fewer transitive crates.
|
||||
C. Take the deferred solver toggle (Quat investigation #1): add
|
||||
"Winnable deals only" Settings toggle. Larger.
|
||||
D. Promote the in-engine "Watch replay" button to real playback.
|
||||
E. Pick from the remaining "next-round candidates" in this doc.
|
||||
F. Take the deferred desktop-packaging item (needs artwork +
|
||||
A. Solver-on-AsyncComputeTaskPool with progress toast + cancel.
|
||||
A previous attempt was rolled back when an agent left 3
|
||||
failing tests; redoing it needs smaller pieces. Eliminates the
|
||||
worst-case 6 s UI stall — highest gameplay impact left.
|
||||
B. Per-deal "won previously" HUD indicator using the rolling
|
||||
replay history's seeds.
|
||||
C. Replay sharing — copyable URL via the existing web viewer.
|
||||
D. Take the deferred desktop-packaging item (needs artwork +
|
||||
signing certs from the user).
|
||||
|
||||
WORKFLOW NOTES:
|
||||
@@ -136,5 +107,5 @@ WORKFLOW NOTES:
|
||||
- Every commit must pass build / clippy / test before pushing.
|
||||
- Push to GitHub (origin) — that is the canonical remote.
|
||||
|
||||
OPEN AT THE START: ask which of A–F. Don't pick unilaterally.
|
||||
OPEN AT THE START: ask which of A–D. Don't pick unilaterally.
|
||||
```
|
||||
|
||||
@@ -10,7 +10,8 @@ use solitaire_engine::{
|
||||
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
||||
CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
|
||||
HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin,
|
||||
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin,
|
||||
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
|
||||
SelectionPlugin, SettingsPlugin, SplashPlugin,
|
||||
StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
||||
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
||||
};
|
||||
@@ -117,6 +118,8 @@ fn main() {
|
||||
.add_plugins(FeedbackAnimPlugin)
|
||||
.add_plugins(CardAnimationPlugin)
|
||||
.add_plugins(AutoCompletePlugin)
|
||||
.add_plugins(ReplayPlaybackPlugin)
|
||||
.add_plugins(ReplayOverlayPlugin)
|
||||
.add_plugins(StatsPlugin::default())
|
||||
.add_plugins(ProgressPlugin::default())
|
||||
.add_plugins(AchievementPlugin::default())
|
||||
|
||||
@@ -140,6 +140,16 @@ fn comeback(c: &AchievementContext) -> bool {
|
||||
fn zen_winner(c: &AchievementContext) -> bool {
|
||||
c.last_win_is_zen
|
||||
}
|
||||
/// Cinephile is event-driven: it unlocks when the engine observes a
|
||||
/// `ReplayPlaybackState` transition from `Playing` to `Completed`, not on
|
||||
/// any field of [`AchievementContext`]. The condition predicate therefore
|
||||
/// always returns false so [`check_achievements`] never unlocks it from a
|
||||
/// `GameWonEvent` / `StateChangedEvent` cycle — the unlock is driven by
|
||||
/// `AchievementUnlockedEvent` written directly from the engine's
|
||||
/// replay-playback observer.
|
||||
fn cinephile_never(_c: &AchievementContext) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// All currently-evaluable achievements. Order is stable so persistence files
|
||||
/// remain readable across versions (new achievements append).
|
||||
@@ -288,6 +298,18 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
|
||||
reward: Some(Reward::Badge),
|
||||
condition: zen_winner,
|
||||
},
|
||||
AchievementDef {
|
||||
id: "cinephile",
|
||||
name: "Cinephile",
|
||||
description: "Watch a saved replay all the way through",
|
||||
secret: false,
|
||||
reward: None,
|
||||
// Event-driven unlock: the engine's replay-playback observer fires
|
||||
// `AchievementUnlockedEvent("cinephile")` directly on a Playing →
|
||||
// Completed transition. `cinephile_never` keeps the condition path
|
||||
// a no-op so a `GameWonEvent` evaluation cycle cannot unlock it.
|
||||
condition: cinephile_never,
|
||||
},
|
||||
];
|
||||
|
||||
/// Return every `AchievementDef` whose condition is satisfied by `ctx`.
|
||||
@@ -721,6 +743,31 @@ mod tests {
|
||||
assert!(ids.contains(&"no_undo"), "no_undo must also unlock when perfectionist does");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cinephile_achievement_in_canonical_list() {
|
||||
let def = achievement_by_id("cinephile").expect("cinephile must be registered");
|
||||
assert_eq!(def.id, "cinephile");
|
||||
assert_eq!(def.name, "Cinephile");
|
||||
assert!(!def.secret, "cinephile is not a secret achievement");
|
||||
// Event-driven: the predicate is a sentinel that always returns
|
||||
// false. `check_achievements` must never unlock cinephile from a
|
||||
// GameWonEvent context, even one that satisfies every other gate.
|
||||
let mut c = ctx();
|
||||
c.games_won = 1;
|
||||
c.win_streak_current = 999;
|
||||
c.last_win_time_seconds = 1;
|
||||
c.last_win_used_undo = false;
|
||||
c.best_single_score = 99_999;
|
||||
c.lifetime_score = u64::MAX;
|
||||
c.last_win_is_zen = true;
|
||||
c.last_win_recycle_count = 99;
|
||||
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||
assert!(
|
||||
!ids.contains(&"cinephile"),
|
||||
"cinephile must never unlock via condition evaluation; got {ids:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfectionist_score_well_above_threshold_still_passes() {
|
||||
let mut c = ctx();
|
||||
|
||||
@@ -6,3 +6,4 @@ pub mod game_state;
|
||||
pub mod pile;
|
||||
pub mod rules;
|
||||
pub mod scoring;
|
||||
pub mod solver;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -141,9 +141,10 @@ pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
|
||||
pub mod settings;
|
||||
pub use settings::{
|
||||
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
|
||||
Theme, WindowGeometry, 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, REPLAY_MOVE_INTERVAL_MAX_SECS, REPLAY_MOVE_INTERVAL_MIN_SECS,
|
||||
REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, TIME_BONUS_MULTIPLIER_MAX,
|
||||
TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_MAX_SECS,
|
||||
TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
|
||||
};
|
||||
|
||||
pub mod auth_tokens;
|
||||
@@ -155,7 +156,10 @@ pub mod sync_client;
|
||||
pub use sync_client::{provider_for_backend, LocalOnlyProvider, SolitaireServerClient};
|
||||
|
||||
pub mod replay;
|
||||
#[allow(deprecated)]
|
||||
pub use replay::{latest_replay_path, load_latest_replay_from, save_latest_replay_to};
|
||||
pub use replay::{
|
||||
latest_replay_path, load_latest_replay_from, save_latest_replay_to, Replay, ReplayMove,
|
||||
REPLAY_SCHEMA_VERSION,
|
||||
append_replay_to_history, load_replay_history_from, migrate_legacy_latest_replay,
|
||||
replay_history_path, save_replay_history_to, Replay, ReplayHistory, ReplayMove,
|
||||
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION,
|
||||
};
|
||||
|
||||
@@ -31,6 +31,34 @@ use solitaire_core::pile::PileType;
|
||||
|
||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
||||
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
|
||||
|
||||
/// Maximum number of recent winning replays the rolling history retains.
|
||||
///
|
||||
/// When [`append_replay_to_history`] pushes a fresh entry past this cap,
|
||||
/// the oldest entry is dropped so the file never grows unbounded. The
|
||||
/// player can revisit any of the last [`REPLAY_HISTORY_CAP`] wins from
|
||||
/// the Stats overlay's replay selector — older wins age out silently.
|
||||
pub const REPLAY_HISTORY_CAP: usize = 8;
|
||||
|
||||
/// Save-file schema version for [`ReplayHistory`]. Bump when the on-disk
|
||||
/// shape of the wrapper changes incompatibly so [`load_replay_history_from`]
|
||||
/// returns `None` for older files (the player simply sees an empty
|
||||
/// history rather than a half-loaded broken one). Bumping
|
||||
/// [`REPLAY_SCHEMA_VERSION`] independently invalidates individual
|
||||
/// [`Replay`] payloads inside an otherwise-current history.
|
||||
///
|
||||
/// History:
|
||||
/// - v1 (current): initial release of the rolling history wrapper.
|
||||
pub const REPLAY_HISTORY_SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// Default value for [`ReplayHistory::schema_version`] when deserialising
|
||||
/// files that pre-date the field. Any value other than
|
||||
/// [`REPLAY_HISTORY_SCHEMA_VERSION`] causes [`load_replay_history_from`]
|
||||
/// to return `None`.
|
||||
fn history_schema_v0() -> u32 {
|
||||
0
|
||||
}
|
||||
|
||||
/// Save-file schema version for [`Replay`]. Increment when the on-disk
|
||||
/// representation changes incompatibly so [`load_latest_replay_from`] can
|
||||
@@ -138,17 +166,87 @@ impl Replay {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rolling history of the player's most recent winning replays.
|
||||
///
|
||||
/// Stored as a single JSON file at
|
||||
/// `<data_dir>/solitaire_quest/replays.json` (see
|
||||
/// [`replay_history_path`]). Capped at [`REPLAY_HISTORY_CAP`] entries —
|
||||
/// when [`append_replay_to_history`] pushes past the cap, the oldest
|
||||
/// entry is dropped so the file never grows unbounded.
|
||||
///
|
||||
/// `replays[0]` is always the most recent win; the Stats overlay's
|
||||
/// replay selector defaults to that entry and surfaces the older
|
||||
/// entries behind a small chooser so the player can revisit a memorable
|
||||
/// game even after a more recent win.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ReplayHistory {
|
||||
/// Schema version. See [`REPLAY_HISTORY_SCHEMA_VERSION`].
|
||||
#[serde(default = "history_schema_v0")]
|
||||
pub schema_version: u32,
|
||||
/// Most recent first. Capped at [`REPLAY_HISTORY_CAP`] entries —
|
||||
/// older entries drop off when the cap is hit.
|
||||
pub replays: Vec<Replay>,
|
||||
}
|
||||
|
||||
impl Default for ReplayHistory {
|
||||
/// An empty history at the current schema version. Used by callers
|
||||
/// that need a starting point before the first winning replay has
|
||||
/// ever been recorded.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
|
||||
replays: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ReplayHistory {
|
||||
/// Returns the most recent replay (`replays[0]`), or `None` when the
|
||||
/// history is empty. Convenience used by the Stats overlay's default
|
||||
/// selector position.
|
||||
pub fn most_recent(&self) -> Option<&Replay> {
|
||||
self.replays.first()
|
||||
}
|
||||
|
||||
/// Returns the number of replays currently retained.
|
||||
pub fn len(&self) -> usize {
|
||||
self.replays.len()
|
||||
}
|
||||
|
||||
/// Returns `true` when no replays have been recorded yet.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.replays.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the platform-specific path to `latest_replay.json`, or `None`
|
||||
/// if `dirs::data_dir()` is unavailable (e.g. minimal Linux containers).
|
||||
#[deprecated(
|
||||
note = "single-slot replay storage replaced by the rolling history at \
|
||||
replay_history_path(); kept for the one-shot legacy migration \
|
||||
in migrate_legacy_latest_replay"
|
||||
)]
|
||||
pub fn latest_replay_path() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME))
|
||||
}
|
||||
|
||||
/// Returns the platform-specific path to `replays.json`, the rolling
|
||||
/// history file, or `None` if `dirs::data_dir()` is unavailable (e.g.
|
||||
/// minimal Linux containers).
|
||||
pub fn replay_history_path() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(REPLAY_HISTORY_FILE_NAME))
|
||||
}
|
||||
|
||||
/// Save a [`Replay`] atomically to `path` using the standard `.tmp` →
|
||||
/// rename contract that the rest of `storage.rs` uses.
|
||||
///
|
||||
/// Overwrites any existing replay — only the most recent winning replay
|
||||
/// is retained on disk.
|
||||
#[deprecated(
|
||||
note = "single-slot replay storage replaced by the rolling history; \
|
||||
use append_replay_to_history instead. Kept for the one-shot \
|
||||
legacy migration."
|
||||
)]
|
||||
pub fn save_latest_replay_to(path: &Path, replay: &Replay) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
@@ -168,6 +266,11 @@ pub fn save_latest_replay_to(path: &Path, replay: &Replay) -> io::Result<()> {
|
||||
/// "No replay recorded yet" caption rather than a half-loaded broken
|
||||
/// replay. Bumping [`REPLAY_SCHEMA_VERSION`] therefore invalidates every
|
||||
/// older save without further migration code.
|
||||
#[deprecated(
|
||||
note = "single-slot replay storage replaced by the rolling history; \
|
||||
use load_replay_history_from instead. Kept for the one-shot \
|
||||
legacy migration."
|
||||
)]
|
||||
pub fn load_latest_replay_from(path: &Path) -> Option<Replay> {
|
||||
let data = fs::read(path).ok()?;
|
||||
let replay: Replay = serde_json::from_slice(&data).ok()?;
|
||||
@@ -177,7 +280,124 @@ pub fn load_latest_replay_from(path: &Path) -> Option<Replay> {
|
||||
Some(replay)
|
||||
}
|
||||
|
||||
/// Save a [`ReplayHistory`] atomically to `path` using the standard
|
||||
/// `.tmp` → rename contract.
|
||||
///
|
||||
/// The on-disk encoding is pretty-printed JSON; the file is intended to
|
||||
/// be small (≤ [`REPLAY_HISTORY_CAP`] entries, each carrying a few
|
||||
/// hundred move records at most) so the readability tradeoff is fine.
|
||||
pub fn save_replay_history_to(path: &Path, history: &ReplayHistory) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(history).map_err(io::Error::other)?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
fs::write(&tmp, json.as_bytes())?;
|
||||
fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a [`ReplayHistory`] from `path`, returning `None` when the file
|
||||
/// is missing, corrupt, or carries a [`schema_version`](ReplayHistory::schema_version)
|
||||
/// other than [`REPLAY_HISTORY_SCHEMA_VERSION`].
|
||||
///
|
||||
/// Individual [`Replay`] entries inside an otherwise-current history are
|
||||
/// filtered to only those carrying [`REPLAY_SCHEMA_VERSION`] — older
|
||||
/// entries are silently dropped so a future bump of the inner replay
|
||||
/// schema does not corrupt the wrapper.
|
||||
pub fn load_replay_history_from(path: &Path) -> Option<ReplayHistory> {
|
||||
let data = fs::read(path).ok()?;
|
||||
let history: ReplayHistory = serde_json::from_slice(&data).ok()?;
|
||||
if history.schema_version != REPLAY_HISTORY_SCHEMA_VERSION {
|
||||
return None;
|
||||
}
|
||||
let filtered: Vec<Replay> = history
|
||||
.replays
|
||||
.into_iter()
|
||||
.filter(|r| r.schema_version == REPLAY_SCHEMA_VERSION)
|
||||
.collect();
|
||||
Some(ReplayHistory {
|
||||
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
|
||||
replays: filtered,
|
||||
})
|
||||
}
|
||||
|
||||
/// Append `replay` to the front of the rolling history at `path`,
|
||||
/// dropping the oldest entry once [`REPLAY_HISTORY_CAP`] is exceeded,
|
||||
/// and persist the updated history atomically.
|
||||
///
|
||||
/// If `path` has no existing history (missing file, corrupt, or
|
||||
/// schema-mismatched) a fresh [`ReplayHistory::default`] is used as the
|
||||
/// starting point so the new replay is always saved. The returned
|
||||
/// [`ReplayHistory`] is the exact value written to disk so callers can
|
||||
/// update an in-memory mirror (e.g. the Stats overlay's
|
||||
/// `ReplayHistoryResource`) without a follow-up `load`.
|
||||
pub fn append_replay_to_history(
|
||||
path: &Path,
|
||||
replay: Replay,
|
||||
) -> io::Result<ReplayHistory> {
|
||||
let mut history = load_replay_history_from(path).unwrap_or_default();
|
||||
// Most recent first. Reserve the front slot; pop the oldest if we
|
||||
// exceed the cap so the file never grows unbounded.
|
||||
history.replays.insert(0, replay);
|
||||
if history.replays.len() > REPLAY_HISTORY_CAP {
|
||||
history.replays.truncate(REPLAY_HISTORY_CAP);
|
||||
}
|
||||
save_replay_history_to(path, &history)?;
|
||||
Ok(history)
|
||||
}
|
||||
|
||||
/// One-shot migration from the legacy single-slot
|
||||
/// `latest_replay.json` file to the rolling [`ReplayHistory`] stored at
|
||||
/// `history_path`.
|
||||
///
|
||||
/// Behaviour matrix:
|
||||
/// - `history_path` already exists → no-op (the rolling history wins).
|
||||
/// - `history_path` is absent and `latest_path` is absent → no-op.
|
||||
/// - `history_path` is absent and `latest_path` exists with a valid
|
||||
/// replay → seed a fresh history with that one replay and write it.
|
||||
/// - `history_path` is absent and `latest_path` exists but is corrupt /
|
||||
/// schema-mismatched → write an empty history (we know the player is
|
||||
/// on the new build and shouldn't keep being prompted to migrate).
|
||||
///
|
||||
/// The legacy `latest_replay.json` file is intentionally NOT deleted by
|
||||
/// this helper — keep it for one release as a safety net so a player
|
||||
/// rolling back to the previous build doesn't lose their last winning
|
||||
/// replay. The deletion is planned for the release after this one.
|
||||
pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
|
||||
if history_path.exists() {
|
||||
// Rolling history is authoritative once it exists.
|
||||
return;
|
||||
}
|
||||
if !latest_path.exists() {
|
||||
return;
|
||||
}
|
||||
// Use the deprecated loader directly — the migration is the one
|
||||
// place we still consult the legacy file shape on purpose.
|
||||
#[allow(deprecated)]
|
||||
let legacy = load_latest_replay_from(latest_path);
|
||||
let history = match legacy {
|
||||
Some(replay) => ReplayHistory {
|
||||
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
|
||||
replays: vec![replay],
|
||||
},
|
||||
None => ReplayHistory::default(),
|
||||
};
|
||||
if let Err(e) = save_replay_history_to(history_path, &history) {
|
||||
// Migration failure is non-fatal: on the next launch we'll just
|
||||
// try again. We log to stderr rather than panic so headless
|
||||
// tests stay quiet.
|
||||
eprintln!(
|
||||
"replay: failed to migrate legacy latest_replay.json into rolling history: {e}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
// The legacy single-slot tests still exercise `save_latest_replay_to` /
|
||||
// `load_latest_replay_from` on purpose — they're the round-trip
|
||||
// guardrails for the migration source format.
|
||||
#[allow(deprecated)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
@@ -294,4 +514,189 @@ mod tests {
|
||||
assert!(load_latest_replay_from(&path).is_none());
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ReplayHistory — rolling list of recent wins
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Build a [`Replay`] whose `final_score` carries `id` so tests can
|
||||
/// assert ordering / identity without writing a deep equality match.
|
||||
fn replay_with_id(id: i32) -> Replay {
|
||||
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
|
||||
Replay::new(
|
||||
id as u64,
|
||||
DrawMode::DrawOne,
|
||||
GameMode::Classic,
|
||||
60,
|
||||
id,
|
||||
date,
|
||||
vec![ReplayMove::StockClick],
|
||||
)
|
||||
}
|
||||
|
||||
/// Pushing past [`REPLAY_HISTORY_CAP`] must drop the oldest entries —
|
||||
/// the on-disk file (and the in-memory mirror returned by the helper)
|
||||
/// stays bounded so the user's data dir never grows unbounded.
|
||||
#[test]
|
||||
fn append_replay_to_history_caps_at_eight() {
|
||||
let path = tmp_path("history_cap");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
let mut last_returned = ReplayHistory::default();
|
||||
for i in 0..10 {
|
||||
last_returned = append_replay_to_history(&path, replay_with_id(i))
|
||||
.expect("append must succeed");
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
last_returned.replays.len(),
|
||||
REPLAY_HISTORY_CAP,
|
||||
"history must be capped at REPLAY_HISTORY_CAP entries",
|
||||
);
|
||||
// The most recent ten pushes were ids 0..=9; ids 9, 8, ..., 2
|
||||
// survive (newest first), ids 0 and 1 aged out.
|
||||
let ids: Vec<i32> = last_returned.replays.iter().map(|r| r.final_score).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec![9, 8, 7, 6, 5, 4, 3, 2],
|
||||
"newest entries must survive, oldest must age out",
|
||||
);
|
||||
|
||||
// The on-disk file must agree with the returned in-memory copy.
|
||||
let loaded = load_replay_history_from(&path).expect("load must succeed");
|
||||
assert_eq!(loaded, last_returned, "disk must mirror returned history");
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// `append_replay_to_history` must place new entries at index 0 so
|
||||
/// the Stats overlay's default selector (most recent) lands on the
|
||||
/// just-saved replay.
|
||||
#[test]
|
||||
fn append_replay_inserts_at_front() {
|
||||
let path = tmp_path("history_front");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
append_replay_to_history(&path, replay_with_id(1)).expect("append 1");
|
||||
append_replay_to_history(&path, replay_with_id(2)).expect("append 2");
|
||||
let history = append_replay_to_history(&path, replay_with_id(3)).expect("append 3");
|
||||
|
||||
let ids: Vec<i32> = history.replays.iter().map(|r| r.final_score).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec![3, 2, 1],
|
||||
"history must be reverse-chronological (newest first)",
|
||||
);
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// On first launch with the new code, a pre-existing
|
||||
/// `latest_replay.json` must seed the new rolling history so the
|
||||
/// player doesn't lose their last winning replay across the upgrade.
|
||||
#[test]
|
||||
fn legacy_latest_replay_migrates_to_history_on_first_launch() {
|
||||
let latest = tmp_path("legacy_migrate_latest");
|
||||
let history = tmp_path("legacy_migrate_history");
|
||||
let _ = fs::remove_file(&latest);
|
||||
let _ = fs::remove_file(&history);
|
||||
|
||||
// Seed the legacy file with a real replay.
|
||||
let legacy_replay = sample_replay();
|
||||
save_latest_replay_to(&latest, &legacy_replay).expect("seed legacy");
|
||||
assert!(!history.exists(), "history file must not exist pre-migration");
|
||||
|
||||
migrate_legacy_latest_replay(&latest, &history);
|
||||
|
||||
assert!(history.exists(), "migration must create the history file");
|
||||
let loaded = load_replay_history_from(&history)
|
||||
.expect("post-migration history must load");
|
||||
assert_eq!(loaded.replays.len(), 1, "history must hold exactly the legacy entry");
|
||||
assert_eq!(loaded.replays[0], legacy_replay, "entry must equal the legacy replay");
|
||||
// Legacy file is intentionally retained for one release as a
|
||||
// safety net — see `migrate_legacy_latest_replay` doc comment.
|
||||
assert!(latest.exists(), "legacy file must NOT be deleted by migration");
|
||||
|
||||
let _ = fs::remove_file(&latest);
|
||||
let _ = fs::remove_file(&history);
|
||||
}
|
||||
|
||||
/// When the rolling history file already exists, the migration must
|
||||
/// be a no-op — we never want to overwrite the player's accumulated
|
||||
/// history with a stale single-slot legacy entry.
|
||||
#[test]
|
||||
fn migrate_is_noop_when_history_already_exists() {
|
||||
let latest = tmp_path("legacy_noop_latest");
|
||||
let history = tmp_path("legacy_noop_history");
|
||||
let _ = fs::remove_file(&latest);
|
||||
let _ = fs::remove_file(&history);
|
||||
|
||||
save_latest_replay_to(&latest, &sample_replay()).expect("seed legacy");
|
||||
let pre_existing = ReplayHistory {
|
||||
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
|
||||
replays: vec![replay_with_id(42)],
|
||||
};
|
||||
save_replay_history_to(&history, &pre_existing).expect("seed history");
|
||||
|
||||
migrate_legacy_latest_replay(&latest, &history);
|
||||
|
||||
let loaded = load_replay_history_from(&history).expect("load");
|
||||
assert_eq!(loaded, pre_existing, "existing history must not be overwritten");
|
||||
|
||||
let _ = fs::remove_file(&latest);
|
||||
let _ = fs::remove_file(&history);
|
||||
}
|
||||
|
||||
/// A populated [`ReplayHistory`] must round-trip byte-identically
|
||||
/// through `save_replay_history_to` / `load_replay_history_from`.
|
||||
#[test]
|
||||
fn replay_history_round_trips_through_save_and_load() {
|
||||
let path = tmp_path("history_round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
let history = ReplayHistory {
|
||||
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
|
||||
replays: vec![replay_with_id(7), replay_with_id(3), sample_replay()],
|
||||
};
|
||||
save_replay_history_to(&path, &history).expect("save");
|
||||
let loaded = load_replay_history_from(&path).expect("load");
|
||||
assert_eq!(loaded, history, "round-trip must preserve every field");
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// A file written by an older history schema must be rejected so the
|
||||
/// player sees a clean empty history rather than a half-loaded one.
|
||||
#[test]
|
||||
fn replay_history_legacy_schema_version_falls_through_to_none() {
|
||||
let path = tmp_path("history_legacy_schema");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
// No `schema_version` key → defaults to 0 via `history_schema_v0()`.
|
||||
let v0_json = r#"{
|
||||
"replays": []
|
||||
}"#;
|
||||
fs::write(&path, v0_json).expect("write v0 fixture");
|
||||
|
||||
assert!(
|
||||
load_replay_history_from(&path).is_none(),
|
||||
"v0 history must be rejected (schema gate)",
|
||||
);
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// Atomic-write contract for the rolling history — `.tmp` must not be
|
||||
/// left behind after `save_replay_history_to` returns.
|
||||
#[test]
|
||||
fn replay_history_save_is_atomic() {
|
||||
let path = tmp_path("history_atomic");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
save_replay_history_to(&path, &ReplayHistory::default()).expect("save");
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +166,32 @@ pub struct Settings {
|
||||
/// `#[serde(default = "default_time_bonus_multiplier")]`.
|
||||
#[serde(default = "default_time_bonus_multiplier")]
|
||||
pub time_bonus_multiplier: f32,
|
||||
/// When `true`, the engine rejects new-game deals the
|
||||
/// [`solitaire_core::solver`] cannot prove winnable, retrying
|
||||
/// fresh seeds up to [`SOLVER_DEAL_RETRY_CAP`] attempts before
|
||||
/// giving up and using the last tried seed. Off by default —
|
||||
/// the solver adds a few hundred milliseconds of latency on the
|
||||
/// pathological deals that hit the budget cap, and not every
|
||||
/// player wants to wait. Older `settings.json` files written
|
||||
/// before this field existed deserialize cleanly to `false` via
|
||||
/// `#[serde(default)]`.
|
||||
///
|
||||
/// Scope: only random-seed Classic-mode deals are filtered.
|
||||
/// Daily challenges, replays, and explicit-seed requests skip the
|
||||
/// solver retry loop — see `solitaire_engine::handle_new_game`.
|
||||
#[serde(default)]
|
||||
pub winnable_deals_only: bool,
|
||||
/// Per-move duration during replay playback, in seconds. Range
|
||||
/// `[REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS]`;
|
||||
/// default mirrors `solitaire_engine::replay_playback::REPLAY_MOVE_INTERVAL_SECS`
|
||||
/// (0.45 s/move) so existing playback behaviour is unchanged for
|
||||
/// players who never touch the slider. Smaller values scrub
|
||||
/// faster through the recorded move list. Older `settings.json`
|
||||
/// files written before this field existed deserialize cleanly to
|
||||
/// the default via
|
||||
/// `#[serde(default = "default_replay_move_interval_secs")]`.
|
||||
#[serde(default = "default_replay_move_interval_secs")]
|
||||
pub replay_move_interval_secs: f32,
|
||||
}
|
||||
|
||||
fn default_draw_mode() -> DrawMode {
|
||||
@@ -223,6 +249,44 @@ fn default_time_bonus_multiplier() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
/// Default per-move duration during replay playback, in seconds.
|
||||
/// Mirrors `solitaire_engine::replay_playback::REPLAY_MOVE_INTERVAL_SECS`
|
||||
/// so legacy `settings.json` files load to the existing baseline and
|
||||
/// playback feels identical for players who never touch the slider.
|
||||
/// The constant is duplicated across the data and engine crates
|
||||
/// because `solitaire_data` cannot depend on the engine crate — keep
|
||||
/// the two values in sync when adjusting either.
|
||||
fn default_replay_move_interval_secs() -> f32 {
|
||||
0.45
|
||||
}
|
||||
|
||||
/// Lower bound of the player-tunable replay-playback per-move interval,
|
||||
/// in seconds. Below this the cards barely register visually before
|
||||
/// the next move fires; the cap keeps the playback legible.
|
||||
pub const REPLAY_MOVE_INTERVAL_MIN_SECS: f32 = 0.10;
|
||||
|
||||
/// Upper bound of the player-tunable replay-playback per-move interval,
|
||||
/// in seconds. One second per move is a comfortable upper limit for
|
||||
/// players who want to study a recorded game frame by frame.
|
||||
pub const REPLAY_MOVE_INTERVAL_MAX_SECS: f32 = 1.00;
|
||||
|
||||
/// Increment applied by the replay-playback decrement / increment
|
||||
/// buttons. 0.05 s gives 19 stops between MIN and MAX — fine-grained
|
||||
/// enough to land on any "round" speed (0.10 s, 0.25 s, 0.45 s, etc.)
|
||||
/// without making the slider feel stuck on the same value.
|
||||
pub const REPLAY_MOVE_INTERVAL_STEP_SECS: f32 = 0.05;
|
||||
|
||||
/// Maximum number of seed retries [`solitaire_engine::handle_new_game`]
|
||||
/// is willing to attempt before giving up and accepting the latest
|
||||
/// candidate seed when [`Settings::winnable_deals_only`] is on. If
|
||||
/// every retry comes back [`SolverResult::Unwinnable`] (which would
|
||||
/// be very unusual) we'd rather hand the player a possibly-unwinnable
|
||||
/// deal than spin forever on the main thread.
|
||||
///
|
||||
/// 50 attempts × ~50 ms median per solve = ~2.5 s worst-case stall —
|
||||
/// the upper bound on UI freeze when the toggle is on.
|
||||
pub const SOLVER_DEAL_RETRY_CAP: u32 = 50;
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -241,14 +305,17 @@ impl Default for Settings {
|
||||
shown_achievement_onboarding: false,
|
||||
tooltip_delay_secs: default_tooltip_delay(),
|
||||
time_bonus_multiplier: default_time_bonus_multiplier(),
|
||||
winnable_deals_only: false,
|
||||
replay_move_interval_secs: default_replay_move_interval_secs(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Clamps `sfx_volume`, `music_volume`, `tooltip_delay_secs`, and
|
||||
/// `time_bonus_multiplier` into their respective ranges after
|
||||
/// deserialization or hand-editing of `settings.json`.
|
||||
/// Clamps `sfx_volume`, `music_volume`, `tooltip_delay_secs`,
|
||||
/// `time_bonus_multiplier`, and `replay_move_interval_secs` into
|
||||
/// their respective ranges after deserialization or hand-editing of
|
||||
/// `settings.json`.
|
||||
pub fn sanitized(self) -> Self {
|
||||
Self {
|
||||
sfx_volume: self.sfx_volume.clamp(0.0, 1.0),
|
||||
@@ -259,6 +326,9 @@ impl Settings {
|
||||
time_bonus_multiplier: self
|
||||
.time_bonus_multiplier
|
||||
.clamp(TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX),
|
||||
replay_move_interval_secs: self
|
||||
.replay_move_interval_secs
|
||||
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS),
|
||||
..self
|
||||
}
|
||||
}
|
||||
@@ -297,6 +367,21 @@ impl Settings {
|
||||
self.time_bonus_multiplier = (raw * 10.0).round() / 10.0;
|
||||
self.time_bonus_multiplier
|
||||
}
|
||||
|
||||
/// Adjust the replay-playback per-move interval by `delta`
|
||||
/// seconds, clamped to
|
||||
/// `[REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS]`.
|
||||
/// The result is rounded to two decimal places so the readout
|
||||
/// stays clean across repeated `±` clicks at the 0.05 s step
|
||||
/// (avoids float drift like `0.45000003`). Returns the new value.
|
||||
pub fn adjust_replay_move_interval(&mut self, delta: f32) -> f32 {
|
||||
let raw = (self.replay_move_interval_secs + delta)
|
||||
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS);
|
||||
// Round to 2 decimal places — the slider step is 0.05, so this
|
||||
// collapses any FP drift introduced by repeated additions.
|
||||
self.replay_move_interval_secs = (raw * 100.0).round() / 100.0;
|
||||
self.replay_move_interval_secs
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the platform-specific path to `settings.json`, or `None` if
|
||||
@@ -428,6 +513,8 @@ mod tests {
|
||||
shown_achievement_onboarding: false,
|
||||
tooltip_delay_secs: default_tooltip_delay(),
|
||||
time_bonus_multiplier: default_time_bonus_multiplier(),
|
||||
winnable_deals_only: false,
|
||||
replay_move_interval_secs: default_replay_move_interval_secs(),
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
@@ -835,4 +922,146 @@ mod tests {
|
||||
s2.time_bonus_multiplier
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// winnable_deals_only — solver-backed deal filter toggle
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn settings_winnable_deals_only_default_is_false() {
|
||||
// Off by default — the solver adds latency we shouldn't impose
|
||||
// on every player without their consent.
|
||||
assert!(
|
||||
!Settings::default().winnable_deals_only,
|
||||
"default winnable_deals_only must be false"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_winnable_deals_only_round_trip() {
|
||||
let path = tmp_path("winnable_deals_only_round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
let s = Settings {
|
||||
winnable_deals_only: true,
|
||||
..Settings::default()
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
assert!(
|
||||
loaded.winnable_deals_only,
|
||||
"winnable_deals_only must survive serde round-trip"
|
||||
);
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_settings_without_winnable_deals_only_deserializes_to_false() {
|
||||
// A settings.json written before this field existed must
|
||||
// deserialize cleanly to `false` (the default-off behaviour)
|
||||
// rather than failing the whole load or surprising the player
|
||||
// by switching the toggle on.
|
||||
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||
assert!(
|
||||
!s.winnable_deals_only,
|
||||
"legacy settings.json missing winnable_deals_only must deserialize to false"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// replay_move_interval_secs — player-tunable replay playback speed
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn settings_replay_move_interval_default_is_zero_point_four_five() {
|
||||
// The pre-slider baseline is 0.45 s/move, matching
|
||||
// `solitaire_engine::replay_playback::REPLAY_MOVE_INTERVAL_SECS`.
|
||||
// The default must not regress for players who never touch
|
||||
// the slider.
|
||||
let s = Settings::default();
|
||||
assert!(
|
||||
(s.replay_move_interval_secs - 0.45).abs() < 1e-6,
|
||||
"replay_move_interval_secs default must be 0.45 (the pre-slider baseline), got {}",
|
||||
s.replay_move_interval_secs
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_replay_move_interval_round_trip() {
|
||||
let path = tmp_path("replay_move_interval_round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
let s = Settings {
|
||||
replay_move_interval_secs: 0.20,
|
||||
..Settings::default()
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
assert!(
|
||||
(loaded.replay_move_interval_secs - 0.20).abs() < 1e-6,
|
||||
"replay_move_interval_secs must survive serde round-trip; got {}",
|
||||
loaded.replay_move_interval_secs
|
||||
);
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_settings_without_replay_move_interval_deserializes_to_default() {
|
||||
// A settings.json written before this field existed must
|
||||
// deserialize cleanly to the existing 0.45 s baseline so old
|
||||
// players see no change to replay playback speed.
|
||||
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||
assert!(
|
||||
(s.replay_move_interval_secs - default_replay_move_interval_secs()).abs() < 1e-6,
|
||||
"legacy settings.json missing replay_move_interval_secs must deserialize to default ({}), got {}",
|
||||
default_replay_move_interval_secs(),
|
||||
s.replay_move_interval_secs
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_replay_move_interval_clamps_to_range() {
|
||||
// Negative or oversized values from a hand-edited file must be
|
||||
// clamped on load.
|
||||
let s = Settings {
|
||||
replay_move_interval_secs: 5.0,
|
||||
..Settings::default()
|
||||
}
|
||||
.sanitized();
|
||||
assert_eq!(s.replay_move_interval_secs, REPLAY_MOVE_INTERVAL_MAX_SECS);
|
||||
|
||||
let s2 = Settings {
|
||||
replay_move_interval_secs: -1.0,
|
||||
..Settings::default()
|
||||
}
|
||||
.sanitized();
|
||||
assert_eq!(s2.replay_move_interval_secs, REPLAY_MOVE_INTERVAL_MIN_SECS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_replay_move_interval_clamps_and_rounds() {
|
||||
let mut s = Settings { replay_move_interval_secs: 0.45, ..Default::default() };
|
||||
// Step down to 0.40.
|
||||
assert!((s.adjust_replay_move_interval(-0.05) - 0.40).abs() < 1e-6);
|
||||
// Big positive jump clamps to MAX.
|
||||
assert!(
|
||||
(s.adjust_replay_move_interval(99.0) - REPLAY_MOVE_INTERVAL_MAX_SECS).abs() < 1e-6
|
||||
);
|
||||
// Big negative jump clamps to MIN.
|
||||
assert!(
|
||||
(s.adjust_replay_move_interval(-99.0) - REPLAY_MOVE_INTERVAL_MIN_SECS).abs() < 1e-6
|
||||
);
|
||||
|
||||
// Repeated 0.05 steps must not drift past the 0.05 grid.
|
||||
let mut s2 = Settings { replay_move_interval_secs: 0.10, ..Default::default() };
|
||||
for _ in 0..6 {
|
||||
s2.adjust_replay_move_interval(0.05);
|
||||
}
|
||||
// After six +0.05 steps from 0.10, value should be exactly 0.40 (2 decimals).
|
||||
assert!(
|
||||
(s2.replay_move_interval_secs - 0.40).abs() < 1e-6,
|
||||
"rounding should pin repeated 0.05 steps to the decimal grid, got {}",
|
||||
s2.replay_move_interval_secs
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
use chrono::{Local, Timelike, Utc};
|
||||
use solitaire_core::achievement::{
|
||||
@@ -25,11 +26,13 @@ use crate::events::{
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate};
|
||||
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::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||
ScrimDismissible,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
|
||||
@@ -47,6 +50,19 @@ pub struct AchievementsScreen;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct AchievementRow;
|
||||
|
||||
/// Marker on the scrollable body Node inside the Achievements modal.
|
||||
///
|
||||
/// The Achievements list can grow to ~19 rows which overflows the modal at
|
||||
/// the 800x600 minimum window. This marker tags the inner container that
|
||||
/// carries `Overflow::scroll_y()` plus a `max_height` constraint so the
|
||||
/// content scrolls instead of clipping. Mirrors the
|
||||
/// `SettingsPanelScrollable` pattern in `settings_plugin`.
|
||||
///
|
||||
/// `scroll_achievements_panel` reads this marker to route mouse-wheel
|
||||
/// events into the body's `ScrollPosition`.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct AchievementsScrollable;
|
||||
|
||||
/// All per-player achievement records (one per known achievement).
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct AchievementsResource(pub Vec<AchievementRecord>);
|
||||
@@ -95,6 +111,11 @@ impl Plugin for AchievementPlugin {
|
||||
.add_message::<XpAwardedEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<ToggleAchievementsRequestEvent>()
|
||||
// `MouseWheel` is emitted by Bevy's input plugin under
|
||||
// `DefaultPlugins`; register it explicitly so the
|
||||
// achievements-scroll system also runs cleanly under
|
||||
// `MinimalPlugins` in tests.
|
||||
.add_message::<MouseWheel>()
|
||||
// Run after GameMutation (so GameWonEvent is available), after
|
||||
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
|
||||
// (so daily_challenge_streak is up to date for daily_devotee).
|
||||
@@ -116,7 +137,13 @@ impl Plugin for AchievementPlugin {
|
||||
.after(StatsUpdate),
|
||||
)
|
||||
.add_systems(Update, toggle_achievements_screen)
|
||||
.add_systems(Update, handle_achievements_close_button);
|
||||
.add_systems(Update, handle_achievements_close_button)
|
||||
.add_systems(Update, scroll_achievements_panel)
|
||||
// Event-driven unlock: observe `ReplayPlaybackState` and unlock
|
||||
// `cinephile` the first time playback runs to natural completion.
|
||||
// Reads the resource via `Option<Res<_>>` so headless tests that
|
||||
// omit `ReplayPlaybackPlugin` still build.
|
||||
.add_systems(Update, evaluate_cinephile_on_replay_completion);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +249,66 @@ fn evaluate_on_win(
|
||||
}
|
||||
}
|
||||
|
||||
/// Cinephile unlock observer.
|
||||
///
|
||||
/// Watches [`ReplayPlaybackState`] and unlocks the `cinephile` achievement
|
||||
/// the first time the resource transitions from `Playing` to `Completed` —
|
||||
/// i.e. the player watched a saved replay all the way through. The Stop
|
||||
/// button transitions `Playing` → `Inactive` directly (never via
|
||||
/// `Completed`), so manual aborts do not trigger the unlock.
|
||||
///
|
||||
/// Idempotent: once the record is unlocked, subsequent Playing → Completed
|
||||
/// transitions are a no-op (no extra `AchievementUnlockedEvent`, no extra
|
||||
/// disk write). The transition itself is debounced by tracking the
|
||||
/// previous frame's `is_playing()` state in a `Local<bool>` — without
|
||||
/// this, a freshly-spawned `Completed` state would re-fire each frame
|
||||
/// during the linger window.
|
||||
///
|
||||
/// Reads `ReplayPlaybackState` via `Option<Res<_>>` so achievement tests
|
||||
/// that omit `ReplayPlaybackPlugin` still build cleanly.
|
||||
fn evaluate_cinephile_on_replay_completion(
|
||||
state: Option<Res<ReplayPlaybackState>>,
|
||||
// `Local` collides with `chrono::Local` imported at the top of this
|
||||
// module — fully qualify so the Bevy system parameter resolves
|
||||
// correctly.
|
||||
mut last_was_playing: bevy::prelude::Local<bool>,
|
||||
mut achievements: ResMut<AchievementsResource>,
|
||||
mut unlocks: MessageWriter<AchievementUnlockedEvent>,
|
||||
path: Res<AchievementsStoragePath>,
|
||||
) {
|
||||
let Some(state) = state else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Detect the Playing → Completed transition: was playing last frame,
|
||||
// is now completed. Direct Playing → Inactive (Stop button) does not
|
||||
// satisfy this guard because it never enters `Completed`.
|
||||
let now_playing = state.is_playing();
|
||||
let now_completed = state.is_completed();
|
||||
let just_completed = *last_was_playing && now_completed;
|
||||
*last_was_playing = now_playing;
|
||||
|
||||
if !just_completed {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(record) = achievements.0.iter_mut().find(|r| r.id == "cinephile") else {
|
||||
return;
|
||||
};
|
||||
if record.unlocked {
|
||||
return;
|
||||
}
|
||||
record.unlock(Utc::now());
|
||||
record.reward_granted = true;
|
||||
unlocks.write(AchievementUnlockedEvent(record.clone()));
|
||||
|
||||
if let Some(target) = &path.0
|
||||
&& let Err(e) = save_achievements_to(target, &achievements.0)
|
||||
{
|
||||
warn!("failed to save achievements after cinephile unlock: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Achievement-onboarding cue.
|
||||
///
|
||||
/// On the player's very first win — and only their first — fires a single
|
||||
@@ -329,6 +416,38 @@ fn handle_achievements_close_button(
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes mouse-wheel events into the Achievements modal's scrollable body
|
||||
/// while the panel is open.
|
||||
///
|
||||
/// `offset_y` increases downward (0 = top). Scrolling down (`ev.y < 0`) adds
|
||||
/// to the offset; scrolling up subtracts. Clamped to >= 0 so the viewport
|
||||
/// never scrolls past the top. Mirrors `scroll_settings_panel` in
|
||||
/// `settings_plugin`. The query is empty when no `AchievementsScrollable`
|
||||
/// is in the world (modal closed) so this is a no-op outside the open
|
||||
/// state without an explicit gate resource.
|
||||
fn scroll_achievements_panel(
|
||||
mut scroll_evr: MessageReader<MouseWheel>,
|
||||
mut scrollables: Query<&mut ScrollPosition, With<AchievementsScrollable>>,
|
||||
) {
|
||||
if scrollables.is_empty() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_achievements_screen(
|
||||
commands: &mut Commands,
|
||||
records: &[AchievementRecord],
|
||||
@@ -355,16 +474,35 @@ fn spawn_achievements_screen(
|
||||
..default()
|
||||
};
|
||||
|
||||
spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| {
|
||||
let scrim = spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, header, font_res);
|
||||
|
||||
// Scrollable body — the achievements list grows to ~19 rows which
|
||||
// overflows the modal on the 800x600 minimum window. Wrapping the
|
||||
// row list in an `Overflow::scroll_y()` Node with a constrained
|
||||
// `max_height` keeps every row reachable. The Done button below
|
||||
// sits outside the scroll so it's always one click away. Mirrors
|
||||
// the `SettingsPanelScrollable` pattern.
|
||||
card.spawn((
|
||||
AchievementsScrollable,
|
||||
ScrollPosition::default(),
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_1,
|
||||
max_height: Val::Vh(70.0),
|
||||
overflow: Overflow::scroll_y(),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|body| {
|
||||
// Achievement rows — unlocked first, then locked alphabetical.
|
||||
let mut sorted: Vec<_> = records.iter().collect();
|
||||
sorted.sort_by_key(|r| (!r.unlocked, r.id.clone()));
|
||||
|
||||
for record in &sorted {
|
||||
let def = achievement_by_id(&record.id);
|
||||
let (name, description) = def.map_or((record.id.as_str(), ""), |d| (d.name, d.description));
|
||||
let (name, description) =
|
||||
def.map_or((record.id.as_str(), ""), |d| (d.name, d.description));
|
||||
|
||||
// Hide secret locked achievements so they remain a surprise.
|
||||
let is_secret = def.is_some_and(|d| d.secret);
|
||||
@@ -380,7 +518,7 @@ fn spawn_achievements_screen(
|
||||
|
||||
let tooltip_text = tooltip_for_row(record.unlocked, def);
|
||||
|
||||
card.spawn((
|
||||
body.spawn((
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_1,
|
||||
@@ -419,7 +557,7 @@ fn spawn_achievements_screen(
|
||||
});
|
||||
|
||||
// Subtle row separator — keeps the long list scannable.
|
||||
card.spawn((
|
||||
body.spawn((
|
||||
Node {
|
||||
height: Val::Px(1.0),
|
||||
..default()
|
||||
@@ -427,6 +565,7 @@ fn spawn_achievements_screen(
|
||||
BackgroundColor(BORDER_SUBTLE),
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
@@ -439,6 +578,9 @@ fn spawn_achievements_screen(
|
||||
);
|
||||
});
|
||||
});
|
||||
// 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 {
|
||||
@@ -829,6 +971,64 @@ mod tests {
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Scrollable body
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Spawning the modal must place exactly one `AchievementsScrollable`
|
||||
/// marker in the world so the row list scrolls instead of clipping at
|
||||
/// the 800x600 minimum window.
|
||||
#[test]
|
||||
fn achievements_modal_body_is_scrollable() {
|
||||
let mut app = headless_app();
|
||||
press(&mut app, KeyCode::KeyA);
|
||||
app.update();
|
||||
|
||||
let count = app
|
||||
.world_mut()
|
||||
.query::<&AchievementsScrollable>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(
|
||||
count, 1,
|
||||
"Achievements modal must spawn exactly one AchievementsScrollable body"
|
||||
);
|
||||
}
|
||||
|
||||
/// The scrollable body must constrain its `max_height` so the modal
|
||||
/// actually engages scrolling on tall content. Without this the inner
|
||||
/// flex column would expand to fit every row and `Overflow::scroll_y`
|
||||
/// would have nothing to clip.
|
||||
#[test]
|
||||
fn achievements_modal_body_has_max_height() {
|
||||
let mut app = headless_app();
|
||||
press(&mut app, KeyCode::KeyA);
|
||||
app.update();
|
||||
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Node, With<AchievementsScrollable>>();
|
||||
let nodes: Vec<&Node> = q.iter(app.world()).collect();
|
||||
assert_eq!(nodes.len(), 1, "expected exactly one scrollable body");
|
||||
let node = nodes[0];
|
||||
|
||||
// `Val::Auto` is the default; assert the body's `max_height` was
|
||||
// explicitly set to something else so scroll engages.
|
||||
assert_ne!(
|
||||
node.max_height,
|
||||
Val::Auto,
|
||||
"scrollable body must set a non-default max_height; got {:?}",
|
||||
node.max_height
|
||||
);
|
||||
// And the overflow axis must be y-scroll.
|
||||
assert_eq!(
|
||||
node.overflow,
|
||||
Overflow::scroll_y(),
|
||||
"scrollable body must use Overflow::scroll_y(); got {:?}",
|
||||
node.overflow
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// format_reward
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -1149,9 +1349,215 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Without any `GameWonEvent` arriving the system must be a no-op:
|
||||
/// no toast, no flag flip — even on update ticks where stats happen
|
||||
/// to read `games_won == 1`.
|
||||
// -----------------------------------------------------------------------
|
||||
// Cinephile (event-driven via ReplayPlaybackState)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
use crate::replay_playback::ReplayPlaybackState;
|
||||
use solitaire_data::{Replay, ReplayMove};
|
||||
use chrono::NaiveDate;
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
|
||||
/// Headless app variant that injects a default `ReplayPlaybackState`
|
||||
/// directly (no `ReplayPlaybackPlugin`) so we can drive the resource
|
||||
/// by hand. The achievement plugin's cinephile observer reads it via
|
||||
/// `Option<Res<_>>` so the absence of the playback plugin is safe.
|
||||
fn cinephile_app() -> App {
|
||||
let mut app = headless_app();
|
||||
app.init_resource::<ReplayPlaybackState>();
|
||||
app
|
||||
}
|
||||
|
||||
fn dummy_replay() -> Replay {
|
||||
Replay::new(
|
||||
1,
|
||||
DrawMode::DrawOne,
|
||||
GameMode::Classic,
|
||||
10,
|
||||
100,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![ReplayMove::StockClick],
|
||||
)
|
||||
}
|
||||
|
||||
fn cinephile_unlocked(app: &App) -> bool {
|
||||
app.world()
|
||||
.resource::<AchievementsResource>()
|
||||
.0
|
||||
.iter()
|
||||
.find(|r| r.id == "cinephile")
|
||||
.map(|r| r.unlocked)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn cinephile_unlocks_emitted(app: &App) -> usize {
|
||||
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
cursor
|
||||
.read(events)
|
||||
.filter(|e| e.0.id == "cinephile")
|
||||
.count()
|
||||
}
|
||||
|
||||
/// The cinephile record must be seeded on plugin init like every other
|
||||
/// achievement, so the observer can find and mutate it later.
|
||||
#[test]
|
||||
fn cinephile_record_seeded_by_plugin() {
|
||||
let app = cinephile_app();
|
||||
let records = &app.world().resource::<AchievementsResource>().0;
|
||||
assert!(
|
||||
records.iter().any(|r| r.id == "cinephile" && !r.unlocked),
|
||||
"cinephile record must be seeded as locked",
|
||||
);
|
||||
}
|
||||
|
||||
/// Drive Inactive → Playing → Completed and assert the cinephile
|
||||
/// achievement unlocks and exactly one `AchievementUnlockedEvent` is
|
||||
/// emitted.
|
||||
#[test]
|
||||
fn cinephile_unlocks_on_replay_completion() {
|
||||
let mut app = cinephile_app();
|
||||
|
||||
// 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,
|
||||
};
|
||||
app.update();
|
||||
assert!(
|
||||
!cinephile_unlocked(&app),
|
||||
"Playing alone must not unlock cinephile",
|
||||
);
|
||||
|
||||
// Frame 2: transition to Completed. The observer must detect
|
||||
// `last_was_playing = true && now_completed = true` and unlock.
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Completed;
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
cinephile_unlocked(&app),
|
||||
"cinephile must unlock on Playing → Completed transition",
|
||||
);
|
||||
assert_eq!(
|
||||
cinephile_unlocks_emitted(&app),
|
||||
1,
|
||||
"exactly one AchievementUnlockedEvent must fire for cinephile",
|
||||
);
|
||||
}
|
||||
|
||||
/// Stop button transitions Playing → Inactive directly (not via
|
||||
/// Completed). Drive that path and assert no cinephile unlock.
|
||||
#[test]
|
||||
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,
|
||||
};
|
||||
app.update();
|
||||
|
||||
// Direct Playing → Inactive — the path the Stop button takes via
|
||||
// `stop_replay_playback`. Must not unlock cinephile.
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Inactive;
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
!cinephile_unlocked(&app),
|
||||
"Stop button (Playing → Inactive) must not unlock cinephile",
|
||||
);
|
||||
assert_eq!(
|
||||
cinephile_unlocks_emitted(&app),
|
||||
0,
|
||||
"no AchievementUnlockedEvent for cinephile on a Stop transition",
|
||||
);
|
||||
}
|
||||
|
||||
/// A second Playing → Completed cycle on an already-unlocked record
|
||||
/// must be idempotent: no additional `AchievementUnlockedEvent`.
|
||||
#[test]
|
||||
fn cinephile_does_not_double_fire() {
|
||||
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,
|
||||
};
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Completed;
|
||||
app.update();
|
||||
assert!(cinephile_unlocked(&app), "precondition: first cycle must unlock");
|
||||
|
||||
// Drain the event queue so the next assertion doesn't double-count
|
||||
// the legitimate first-time unlock event.
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<AchievementUnlockedEvent>>()
|
||||
.clear();
|
||||
|
||||
// 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,
|
||||
};
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Completed;
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
cinephile_unlocks_emitted(&app),
|
||||
0,
|
||||
"cinephile must not re-fire on a second Playing → Completed cycle",
|
||||
);
|
||||
}
|
||||
|
||||
/// `Completed` lingers across multiple frames before the auto-clear
|
||||
/// transitions back to `Inactive`. The observer must fire exactly
|
||||
/// once during that linger window — not once per frame.
|
||||
#[test]
|
||||
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,
|
||||
};
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Completed;
|
||||
app.update();
|
||||
// Stay in Completed for a few more frames as the real auto-clear
|
||||
// does. Each subsequent frame the resource is still `Completed`
|
||||
// but the observer has already counted this transition.
|
||||
app.update();
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
cinephile_unlocks_emitted(&app),
|
||||
1,
|
||||
"cinephile must fire exactly once across the Completed linger window",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_win_event_means_no_achievement_onboarding_toast() {
|
||||
let mut app = onboarding_test_app();
|
||||
|
||||
@@ -2,9 +2,19 @@
|
||||
//!
|
||||
//! **Cursor icons** (`update_cursor_icon`)
|
||||
//! - Cards are being dragged → `Grabbing` (closed hand)
|
||||
//! - A UI `Button` entity is hovered (and no drag in progress) → `Pointer`
|
||||
//! (the hand-with-extended-index-finger icon). This telegraphs
|
||||
//! clickability for every modal button, HUD action, mode-launcher
|
||||
//! card, settings toggle, etc.
|
||||
//! - Cursor hovers over a face-up draggable card → `Grab` (open hand)
|
||||
//! - Otherwise → `Default` (arrow)
|
||||
//!
|
||||
//! Priority order: dragging > button-hover > card-hover > default. A
|
||||
//! button-overlapping-a-card edge case favours `Pointer` because UI
|
||||
//! elements take precedence over world-space cards; in practice
|
||||
//! buttons are always on UI nodes and cards are sprites, so they
|
||||
//! cannot occupy the same hit region simultaneously.
|
||||
//!
|
||||
//! **Drop-target highlights** (`update_drop_highlights`)
|
||||
//! While a drag is in progress every `PileMarker` sprite is tinted:
|
||||
//! - **Green** if the dragged stack can legally land there.
|
||||
@@ -70,6 +80,31 @@ impl Plugin for CursorPlugin {
|
||||
// #31 — Cursor icon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pure decision function for the cursor icon, separated from the Bevy
|
||||
/// system so it can be unit-tested without `PrimaryWindow` /
|
||||
/// `Camera` / `Time` plumbing.
|
||||
///
|
||||
/// Priority order (highest first):
|
||||
/// 1. `is_dragging` → `Grabbing`
|
||||
/// 2. `any_button_hovered` → `Pointer`
|
||||
/// 3. `any_card_hovered` → `Grab`
|
||||
/// 4. otherwise → `Default`
|
||||
fn pick_cursor_icon(
|
||||
is_dragging: bool,
|
||||
any_button_hovered: bool,
|
||||
any_card_hovered: bool,
|
||||
) -> SystemCursorIcon {
|
||||
if is_dragging {
|
||||
SystemCursorIcon::Grabbing
|
||||
} else if any_button_hovered {
|
||||
SystemCursorIcon::Pointer
|
||||
} else if any_card_hovered {
|
||||
SystemCursorIcon::Grab
|
||||
} else {
|
||||
SystemCursorIcon::Default
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the primary-window cursor icon based on drag state and hover.
|
||||
fn update_cursor_icon(
|
||||
drag: Res<DragState>,
|
||||
@@ -77,18 +112,27 @@ fn update_cursor_icon(
|
||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
button_q: Query<&Interaction, With<Button>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let Ok((win_entity, window)) = windows.single() else { return };
|
||||
|
||||
if !drag.is_idle() {
|
||||
commands
|
||||
.entity(win_entity)
|
||||
.insert(CursorIcon::from(SystemCursorIcon::Grabbing));
|
||||
return;
|
||||
}
|
||||
let is_dragging = !drag.is_idle();
|
||||
|
||||
let hovering = (|| {
|
||||
// A UI button is "hovered" if any `Button` entity has its
|
||||
// `Interaction` set to `Hovered` or `Pressed`. We include
|
||||
// `Pressed` so the pointer icon stays visible while a click is
|
||||
// being held, matching browser behaviour.
|
||||
let any_button_hovered = button_q
|
||||
.iter()
|
||||
.any(|i| matches!(i, Interaction::Hovered | Interaction::Pressed));
|
||||
|
||||
let any_card_hovered = if is_dragging || any_button_hovered {
|
||||
// No need to do the world-space hit test when a higher
|
||||
// priority branch already wins.
|
||||
false
|
||||
} else {
|
||||
(|| {
|
||||
let cursor = window.cursor_position()?;
|
||||
let (camera, cam_xf) = cameras.single().ok()?;
|
||||
let world = camera.viewport_to_world_2d(cam_xf, cursor).ok()?;
|
||||
@@ -96,13 +140,11 @@ fn update_cursor_icon(
|
||||
let game = game.as_ref()?;
|
||||
Some(cursor_over_draggable(world, &game.0, &layout))
|
||||
})()
|
||||
.unwrap_or(false);
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
commands.entity(win_entity).insert(CursorIcon::from(if hovering {
|
||||
SystemCursorIcon::Grab
|
||||
} else {
|
||||
SystemCursorIcon::Default
|
||||
}));
|
||||
let icon = pick_cursor_icon(is_dragging, any_button_hovered, any_card_hovered);
|
||||
commands.entity(win_entity).insert(CursorIcon::from(icon));
|
||||
}
|
||||
|
||||
/// Returns `true` if `cursor` (world-space) is over any face-up draggable card.
|
||||
@@ -482,6 +524,53 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// pick_cursor_icon priority-order tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn cursor_picks_grabbing_when_dragging_overrides_button_hover() {
|
||||
// Dragging always wins regardless of button or card hover state.
|
||||
assert!(matches!(
|
||||
pick_cursor_icon(true, true, true),
|
||||
SystemCursorIcon::Grabbing
|
||||
));
|
||||
assert!(matches!(
|
||||
pick_cursor_icon(true, false, false),
|
||||
SystemCursorIcon::Grabbing
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_picks_pointer_when_button_hovered_and_no_drag() {
|
||||
// Button hover beats card hover when not dragging.
|
||||
assert!(matches!(
|
||||
pick_cursor_icon(false, true, false),
|
||||
SystemCursorIcon::Pointer
|
||||
));
|
||||
assert!(matches!(
|
||||
pick_cursor_icon(false, true, true),
|
||||
SystemCursorIcon::Pointer
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_picks_grab_when_card_hovered_and_no_button() {
|
||||
// Card hover wins only when no drag and no button-hover.
|
||||
assert!(matches!(
|
||||
pick_cursor_icon(false, false, true),
|
||||
SystemCursorIcon::Grab
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_picks_default_when_nothing_hovered() {
|
||||
assert!(matches!(
|
||||
pick_cursor_icon(false, false, false),
|
||||
SystemCursorIcon::Default
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_over_draggable_returns_false_for_empty_game() {
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
|
||||
@@ -11,10 +11,16 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use chrono::Utc;
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_data::{delete_game_state_at, game_state_file_path, latest_replay_path,
|
||||
load_game_state_from, save_game_state_to, save_latest_replay_to, Replay, ReplayMove};
|
||||
use solitaire_core::solver::{try_solve, SolverConfig, SolverResult};
|
||||
use solitaire_data::{
|
||||
append_replay_to_history, delete_game_state_at, game_state_file_path, load_game_state_from,
|
||||
migrate_legacy_latest_replay, replay_history_path, save_game_state_to, Replay, ReplayMove,
|
||||
SOLVER_DEAL_RETRY_CAP,
|
||||
};
|
||||
#[allow(deprecated)]
|
||||
use solitaire_data::latest_replay_path;
|
||||
|
||||
use crate::events::{
|
||||
CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent, GameWonEvent, InfoToastEvent,
|
||||
@@ -54,7 +60,15 @@ pub struct GameMutation;
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct GameStatePath(pub Option<PathBuf>);
|
||||
|
||||
/// Persistence path for the most recent winning replay. `None` disables I/O.
|
||||
/// Persistence path for the rolling [`solitaire_data::ReplayHistory`]
|
||||
/// file (`replays.json`). `None` disables I/O — used by tests and on
|
||||
/// minimal Linux containers without `dirs::data_dir()`.
|
||||
///
|
||||
/// Each `GameWonEvent` appends the freshly-frozen [`Replay`] to the
|
||||
/// history at this path via
|
||||
/// [`solitaire_data::append_replay_to_history`], capping at
|
||||
/// [`solitaire_data::REPLAY_HISTORY_CAP`] so the file never grows
|
||||
/// unbounded.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct ReplayPath(pub Option<PathBuf>);
|
||||
|
||||
@@ -101,9 +115,27 @@ impl Plugin for GamePlugin {
|
||||
.and_then(load_game_state_from)
|
||||
.unwrap_or_else(|| GameState::new(seed_from_system_time(), DrawMode::DrawOne));
|
||||
|
||||
// One-shot migration from the legacy single-slot
|
||||
// `latest_replay.json` to the rolling history at `replays.json`.
|
||||
// Runs at plugin construction so the player's last winning
|
||||
// replay from a pre-history build is the first entry of the
|
||||
// new history file. The legacy file is intentionally left in
|
||||
// place for one release as a safety net (see
|
||||
// `migrate_legacy_latest_replay` doc comment).
|
||||
let history_path = replay_history_path();
|
||||
if let (Some(legacy), Some(history)) =
|
||||
(
|
||||
#[allow(deprecated)]
|
||||
latest_replay_path(),
|
||||
history_path.as_ref(),
|
||||
)
|
||||
{
|
||||
migrate_legacy_latest_replay(&legacy, history);
|
||||
}
|
||||
|
||||
app.insert_resource(GameStateResource(initial_state))
|
||||
.insert_resource(GameStatePath(path))
|
||||
.insert_resource(ReplayPath(latest_replay_path()))
|
||||
.insert_resource(ReplayPath(history_path))
|
||||
.init_resource::<RecordingReplay>()
|
||||
.init_resource::<DragState>()
|
||||
.init_resource::<SyncStatusResource>()
|
||||
@@ -188,6 +220,41 @@ fn seed_from_system_time() -> u64 {
|
||||
.map_or(0, |d| d.as_nanos() as u64)
|
||||
}
|
||||
|
||||
/// Walks forward from `initial_seed` (incrementing by 1 with wrapping
|
||||
/// arithmetic) until the [`solitaire_core::solver`] returns a verdict
|
||||
/// the engine accepts as winnable, or until [`SOLVER_DEAL_RETRY_CAP`]
|
||||
/// attempts have elapsed.
|
||||
///
|
||||
/// The solver classifies each deal as one of three verdicts:
|
||||
/// - [`SolverResult::Winnable`] — provably solvable; accept.
|
||||
/// - [`SolverResult::Inconclusive`] — budget exceeded, no proof
|
||||
/// either way; accept (we treat "we don't know" as winnable so
|
||||
/// the toggle never silently drops a player into the retry cap).
|
||||
/// - [`SolverResult::Unwinnable`] — provably dead; try the next seed.
|
||||
///
|
||||
/// If every seed in the retry window is `Unwinnable` (extremely
|
||||
/// unlikely on real inputs), the function returns the *last* tried
|
||||
/// seed so the player still gets a deal — better a possibly-unwinnable
|
||||
/// hand than an infinite loop.
|
||||
///
|
||||
/// Pure helper extracted for testability — `new_game_with_solver_*`
|
||||
/// engine tests in the same file exercise this path.
|
||||
pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: &DrawMode) -> u64 {
|
||||
let cfg = SolverConfig::default();
|
||||
let mut seed = initial_seed;
|
||||
for _ in 0..SOLVER_DEAL_RETRY_CAP {
|
||||
match try_solve(seed, draw_mode.clone(), &cfg) {
|
||||
SolverResult::Winnable | SolverResult::Inconclusive => return seed,
|
||||
SolverResult::Unwinnable => {
|
||||
seed = seed.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Retry cap exhausted — accept the latest tried seed rather than
|
||||
// recurring forever.
|
||||
seed
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_new_game(
|
||||
mut commands: Commands,
|
||||
@@ -229,7 +296,7 @@ fn handle_new_game(
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
|
||||
let seed = ev.seed.unwrap_or_else(seed_from_system_time);
|
||||
let initial_seed = ev.seed.unwrap_or_else(seed_from_system_time);
|
||||
// Prefer the draw mode from Settings when starting a fresh game.
|
||||
// Fall back to the current game's draw mode in headless/test contexts
|
||||
// where SettingsPlugin is not installed.
|
||||
@@ -237,7 +304,32 @@ fn handle_new_game(
|
||||
.as_ref()
|
||||
.map_or_else(|| game.0.draw_mode.clone(), |s| s.0.draw_mode.clone());
|
||||
let mode = ev.mode.unwrap_or(game.0.mode);
|
||||
game.0 = GameState::new_with_mode(seed, draw_mode, mode);
|
||||
|
||||
// Solver-backed retry: when the player has opted in to
|
||||
// "Winnable deals only" AND this is a random Classic deal
|
||||
// (no caller-supplied seed), reject deals the solver can
|
||||
// prove unwinnable and try the next seed. Capped at
|
||||
// [`SOLVER_DEAL_RETRY_CAP`] so a pathological run can't
|
||||
// hang the main thread — if every attempt is rejected we
|
||||
// fall through to the latest tried seed.
|
||||
//
|
||||
// **Scope** — the retry deliberately skips:
|
||||
// - Daily challenges and challenge-mode seeds (caller passes
|
||||
// `ev.seed = Some(...)` so the player gets the same deal as
|
||||
// everyone else).
|
||||
// - Replays (the replay's own seed is authoritative).
|
||||
// - Any other explicit seed request — the player asked for
|
||||
// that seed; honour it.
|
||||
let winnable_only = settings
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.0.winnable_deals_only);
|
||||
let chosen_seed = if winnable_only && mode == GameMode::Classic && ev.seed.is_none() {
|
||||
choose_winnable_seed(initial_seed, &draw_mode)
|
||||
} else {
|
||||
initial_seed
|
||||
};
|
||||
|
||||
game.0 = GameState::new_with_mode(chosen_seed, draw_mode, mode);
|
||||
// Reset the in-flight replay buffer — a fresh deal starts with
|
||||
// an empty move list. The previously saved replay on disk
|
||||
// (latest_replay.json) is preserved until the player wins again.
|
||||
@@ -557,14 +649,15 @@ fn handle_undo(
|
||||
|
||||
/// On every `GameWonEvent`, freeze the in-flight [`RecordingReplay`] into
|
||||
/// a [`Replay`] tagged with the deal seed/mode, the win's score and
|
||||
/// elapsed time, and today's date — then persist it atomically to
|
||||
/// `<data_dir>/solitaire_quest/latest_replay.json` (or to whichever path
|
||||
/// `ReplayPath` carries; tests inject a temp path).
|
||||
/// elapsed time, and today's date — then append it to the rolling
|
||||
/// [`solitaire_data::ReplayHistory`] at the path `ReplayPath` carries
|
||||
/// (tests inject a temp path).
|
||||
///
|
||||
/// Only the most recent winning replay is retained — the existing file is
|
||||
/// overwritten. The recording buffer is left intact after the win so a
|
||||
/// subsequent state-change does not erase the move list before the save
|
||||
/// completes; it gets cleared on the next `NewGameRequestEvent`.
|
||||
/// The history is capped at [`solitaire_data::REPLAY_HISTORY_CAP`]
|
||||
/// entries; older wins age out automatically when the cap is hit. The
|
||||
/// recording buffer is left intact after the win so a subsequent
|
||||
/// state-change does not erase the move list before the save completes;
|
||||
/// it gets cleared on the next `NewGameRequestEvent`.
|
||||
pub fn record_replay_on_win(
|
||||
mut wins: MessageReader<GameWonEvent>,
|
||||
game: Res<GameStateResource>,
|
||||
@@ -597,8 +690,8 @@ pub fn record_replay_on_win(
|
||||
// to inspect it without going through the disk.
|
||||
continue;
|
||||
};
|
||||
if let Err(e) = save_latest_replay_to(p, &replay) {
|
||||
warn!("replay: failed to save winning replay: {e}");
|
||||
if let Err(e) = append_replay_to_history(p, replay) {
|
||||
warn!("replay: failed to append winning replay to history: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1946,11 +2039,13 @@ mod tests {
|
||||
}
|
||||
|
||||
/// On `GameWonEvent`, the recording is frozen into a `Replay` and
|
||||
/// persisted. We point `ReplayPath` at a temp file, fake a win, and
|
||||
/// load the file back to assert the metadata + move list match.
|
||||
/// appended to the rolling [`solitaire_data::ReplayHistory`]. We
|
||||
/// point `ReplayPath` at a temp file, fake a win, and load the
|
||||
/// history back to assert the just-saved entry sits at the front
|
||||
/// with the metadata + move list intact.
|
||||
#[test]
|
||||
fn replay_recording_freezes_into_replay_on_game_won() {
|
||||
use solitaire_data::load_latest_replay_from;
|
||||
use solitaire_data::load_replay_history_from;
|
||||
|
||||
let path = std::env::temp_dir().join("engine_test_replay_freeze.json");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
@@ -1978,8 +2073,14 @@ mod tests {
|
||||
});
|
||||
app.update();
|
||||
|
||||
let loaded = load_latest_replay_from(&path)
|
||||
let history = load_replay_history_from(&path)
|
||||
.expect("a winning replay must be persisted to ReplayPath");
|
||||
assert_eq!(
|
||||
history.replays.len(),
|
||||
1,
|
||||
"fresh history must contain exactly the just-recorded win",
|
||||
);
|
||||
let loaded = &history.replays[0];
|
||||
assert_eq!(loaded.seed, 7654, "seed must match the live game state");
|
||||
assert_eq!(loaded.draw_mode, DrawMode::DrawOne, "draw_mode must be captured");
|
||||
assert_eq!(loaded.final_score, 4321, "final_score must come from the win event");
|
||||
@@ -1998,6 +2099,53 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// Successive `GameWonEvent`s must accumulate in the rolling
|
||||
/// history rather than overwriting one another. Pre-cap, every win
|
||||
/// joins the front of `history.replays`.
|
||||
#[test]
|
||||
fn replay_recording_appends_to_history_across_wins() {
|
||||
use solitaire_data::load_replay_history_from;
|
||||
|
||||
let path = std::env::temp_dir().join("engine_test_replay_history_append.json");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
let mut app = test_app(11);
|
||||
app.insert_resource(ReplayPath(Some(path.clone())));
|
||||
|
||||
// First win.
|
||||
{
|
||||
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
|
||||
recording.moves.clear();
|
||||
recording.moves.push(ReplayMove::StockClick);
|
||||
}
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 100,
|
||||
time_seconds: 60,
|
||||
});
|
||||
app.update();
|
||||
|
||||
// Second win — different score so we can distinguish.
|
||||
{
|
||||
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
|
||||
recording.moves.clear();
|
||||
recording.moves.push(ReplayMove::StockClick);
|
||||
recording.moves.push(ReplayMove::StockClick);
|
||||
}
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 200,
|
||||
time_seconds: 120,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let history = load_replay_history_from(&path).expect("history must exist");
|
||||
assert_eq!(history.replays.len(), 2, "both wins must be retained");
|
||||
// Newest first — second win lands at index 0.
|
||||
assert_eq!(history.replays[0].final_score, 200);
|
||||
assert_eq!(history.replays[1].final_score, 100);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// `GameWonEvent` with an empty recording must NOT touch disk.
|
||||
/// Without this guard, parallel-plugin tests that synthesise
|
||||
/// win events for XP / streak / weekly-goal logic (without
|
||||
@@ -2022,4 +2170,154 @@ mod tests {
|
||||
"no replay must be written when recording is empty",
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Solver-backed "Winnable deals only" toggle
|
||||
//
|
||||
// Exercises [`choose_winnable_seed`] and the wiring inside
|
||||
// `handle_new_game` that consults [`Settings::winnable_deals_only`].
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Inject a `SettingsResource` with the given `winnable_deals_only`
|
||||
/// flag. The handle_new_game system already reads this resource via
|
||||
/// `Option<Res<...>>`, so no `SettingsPlugin` boot is needed.
|
||||
fn insert_settings(app: &mut App, winnable_deals_only: bool) {
|
||||
let settings = solitaire_data::Settings {
|
||||
winnable_deals_only,
|
||||
..solitaire_data::Settings::default()
|
||||
};
|
||||
app.insert_resource(crate::settings_plugin::SettingsResource(settings));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_game_with_solver_toggle_off_uses_requested_seed() {
|
||||
// Toggle off — the engine must use the seed it was handed and
|
||||
// never invoke the solver. Seed 999 is just an arbitrary
|
||||
// deterministic seed; the test asserts the resulting deal
|
||||
// matches `GameState::new(999, DrawOne)`.
|
||||
let mut app = test_app(1);
|
||||
insert_settings(&mut app, false);
|
||||
|
||||
app.world_mut().write_message(NewGameRequestEvent {
|
||||
seed: Some(999),
|
||||
mode: None,
|
||||
confirmed: false,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let actual_seed = app.world().resource::<GameStateResource>().0.seed;
|
||||
assert_eq!(
|
||||
actual_seed, 999,
|
||||
"with solver toggle off, the requested seed must be honoured exactly"
|
||||
);
|
||||
// Cross-check: the dealt tableau must match GameState::new(999) byte-for-byte.
|
||||
let expected = GameState::new(999, DrawMode::DrawOne);
|
||||
for i in 0..7 {
|
||||
assert_eq!(
|
||||
app.world().resource::<GameStateResource>().0.piles[&PileType::Tableau(i)].cards,
|
||||
expected.piles[&PileType::Tableau(i)].cards,
|
||||
"tableau column {i} must match the unfiltered seed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_game_with_solver_toggle_off_random_seed_path() {
|
||||
// When seed is None and toggle is off, the engine uses a
|
||||
// system-time seed and skips the solver. We can't pin the
|
||||
// exact seed, but we can assert the seed is *not* the
|
||||
// sentinel zero (which would only happen if SystemTime is
|
||||
// before the epoch — practically impossible), AND that no
|
||||
// resource has been mutated to suggest the solver ran.
|
||||
// The strongest assertion is "the move runs to completion
|
||||
// without panicking", which the .update() call covers.
|
||||
let mut app = test_app(1);
|
||||
insert_settings(&mut app, false);
|
||||
|
||||
app.world_mut().write_message(NewGameRequestEvent {
|
||||
seed: None,
|
||||
mode: None,
|
||||
confirmed: false,
|
||||
});
|
||||
app.update();
|
||||
|
||||
// Game state was reseeded — move_count is 0 on the new game.
|
||||
assert_eq!(app.world().resource::<GameStateResource>().0.move_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_game_with_solver_toggle_on_skips_solver_for_specific_seed() {
|
||||
// Even with the toggle on, an *explicit* seed must be honoured:
|
||||
// daily challenges, replay seeding, and challenge-mode all
|
||||
// pass `Some(seed)` and must never be retried.
|
||||
let mut app = test_app(1);
|
||||
insert_settings(&mut app, true);
|
||||
|
||||
app.world_mut().write_message(NewGameRequestEvent {
|
||||
seed: Some(123),
|
||||
mode: None,
|
||||
confirmed: false,
|
||||
});
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world().resource::<GameStateResource>().0.seed,
|
||||
123,
|
||||
"explicit-seed requests must skip the solver retry loop",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choose_winnable_seed_skips_unwinnable_seed() {
|
||||
// Seed 394 was identified by the offline scan
|
||||
// (`solver::tests::find_unwinnable`) as the only Unwinnable
|
||||
// seed in 0..500 under the default solver budget. Seed 395
|
||||
// resolves as Inconclusive — the engine treats Inconclusive
|
||||
// as winnable (see `choose_winnable_seed` doc), so the
|
||||
// helper must return 395 when started at 394.
|
||||
let chosen = choose_winnable_seed(394, &DrawMode::DrawOne);
|
||||
assert_eq!(
|
||||
chosen, 395,
|
||||
"seed 394 is Unwinnable; the next seed (395, Inconclusive) must be accepted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_game_with_solver_toggle_on_retries_until_winnable() {
|
||||
// End-to-end: with the toggle on, fire a NewGameRequestEvent
|
||||
// with seed=None and *manually pre-seed* the system-time
|
||||
// path by clearing the GameStateResource so handle_new_game
|
||||
// takes the random branch. We can't easily inject the
|
||||
// system-time seed here, so we exercise the helper via a
|
||||
// separate call and assert the *resource* receives the
|
||||
// post-retry seed when the helper would have rejected.
|
||||
//
|
||||
// We test the integration by setting up an alternative
|
||||
// scenario: pass `seed: Some(394)` with toggle on. Our
|
||||
// implementation already documents that explicit seeds skip
|
||||
// the retry, so this *won't* trigger retry. The cleaner
|
||||
// integration is captured in `choose_winnable_seed_skips_*`.
|
||||
// Here we verify the default-seed path doesn't crash when
|
||||
// toggle is on — exercising the live solver call inside
|
||||
// handle_new_game without depending on the solver picking
|
||||
// a specific seed.
|
||||
let mut app = test_app(1);
|
||||
insert_settings(&mut app, true);
|
||||
|
||||
app.world_mut().write_message(NewGameRequestEvent {
|
||||
seed: None,
|
||||
mode: None,
|
||||
confirmed: false,
|
||||
});
|
||||
app.update();
|
||||
|
||||
// The chosen seed is non-deterministic (system time),
|
||||
// but the new game must have been started cleanly:
|
||||
// move_count back to 0, undo stack empty.
|
||||
assert_eq!(app.world().resource::<GameStateResource>().0.move_count, 0);
|
||||
assert_eq!(
|
||||
app.world().resource::<GameStateResource>().0.undo_stack_len(),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
//! is an optional accelerator. Listed shortcuts are grouped by intent —
|
||||
//! gameplay, modes, and overlays.
|
||||
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::events::HelpRequestEvent;
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||
ScrimDismissible,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
Z_MODAL_PANEL, BORDER_SUBTLE, RADIUS_SM, SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
|
||||
@@ -24,6 +26,16 @@ pub struct HelpScreen;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HelpCloseButton;
|
||||
|
||||
/// Marker on the scrollable body Node inside the Help modal.
|
||||
///
|
||||
/// The controls reference is six sections totalling ~28 rows, which
|
||||
/// overflows the modal on the 800x600 minimum window. This marker tags
|
||||
/// the inner container that carries `Overflow::scroll_y()` plus a
|
||||
/// `max_height` constraint so every row stays reachable. Mirrors the
|
||||
/// `SettingsPanelScrollable` pattern.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HelpScrollable;
|
||||
|
||||
/// Spawns and despawns the help / controls overlay shown when the player
|
||||
/// clicks the "Help" HUD button or presses `F1`. All hotkeys and gesture
|
||||
/// guides live here.
|
||||
@@ -32,7 +44,14 @@ pub struct HelpPlugin;
|
||||
impl Plugin for HelpPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_message::<HelpRequestEvent>()
|
||||
.add_systems(Update, (toggle_help_screen, handle_help_close_button));
|
||||
// `MouseWheel` is emitted by Bevy's input plugin under
|
||||
// `DefaultPlugins`; register it explicitly so the help-scroll
|
||||
// system also runs cleanly under `MinimalPlugins` in tests.
|
||||
.add_message::<MouseWheel>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(toggle_help_screen, handle_help_close_button, scroll_help_panel),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +90,32 @@ fn handle_help_close_button(
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes mouse-wheel events into the Help modal's scrollable body while
|
||||
/// the panel is open. No-op when no `HelpScrollable` exists in the world
|
||||
/// (modal closed). Mirrors `scroll_settings_panel`.
|
||||
fn scroll_help_panel(
|
||||
mut scroll_evr: MessageReader<MouseWheel>,
|
||||
mut scrollables: Query<&mut ScrollPosition, With<HelpScrollable>>,
|
||||
) {
|
||||
if scrollables.is_empty() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Each entry in the controls reference table.
|
||||
struct ControlRow {
|
||||
keys: &'static str,
|
||||
@@ -165,12 +210,29 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||
..default()
|
||||
};
|
||||
|
||||
spawn_modal(commands, HelpScreen, Z_MODAL_PANEL, |card| {
|
||||
let scrim = spawn_modal(commands, HelpScreen, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, "Controls", font_res);
|
||||
|
||||
// Scrollable body — the controls reference is six sections totalling
|
||||
// ~28 rows, which overflows the modal on the 800x600 minimum
|
||||
// window. Wrapping in an `Overflow::scroll_y()` Node with a
|
||||
// constrained `max_height` keeps every row reachable; the Done
|
||||
// button below stays fixed outside the scroll.
|
||||
card.spawn((
|
||||
HelpScrollable,
|
||||
ScrollPosition::default(),
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_2,
|
||||
max_height: Val::Vh(70.0),
|
||||
overflow: Overflow::scroll_y(),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|body| {
|
||||
for section in CONTROL_SECTIONS {
|
||||
// Section title in muted text — distinguishes from row content.
|
||||
card.spawn((
|
||||
body.spawn((
|
||||
Text::new(section.title),
|
||||
font_section.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
@@ -178,7 +240,7 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||
|
||||
// Each row is a flex-row: kbd-style chip + description.
|
||||
for row in section.rows {
|
||||
card.spawn(Node {
|
||||
body.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_3,
|
||||
@@ -216,11 +278,12 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||
|
||||
// Section spacer — small empty box. Keeps each section
|
||||
// visually grouped.
|
||||
card.spawn(Node {
|
||||
body.spawn(Node {
|
||||
height: Val::Px(SPACE_2),
|
||||
..default()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
@@ -233,6 +296,9 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||
);
|
||||
});
|
||||
});
|
||||
// Help is read-only — clicking the scrim outside the card dismisses
|
||||
// alongside the existing F1 / Esc / Done paths.
|
||||
commands.entity(scrim).insert(ScrimDismissible);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -264,6 +330,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_modal_body_is_scrollable() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::F1);
|
||||
app.update();
|
||||
|
||||
let count = app
|
||||
.world_mut()
|
||||
.query::<&HelpScrollable>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(
|
||||
count, 1,
|
||||
"Help modal must spawn exactly one HelpScrollable body"
|
||||
);
|
||||
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Node, With<HelpScrollable>>();
|
||||
let nodes: Vec<&Node> = q.iter(app.world()).collect();
|
||||
assert_ne!(
|
||||
nodes[0].max_height,
|
||||
Val::Auto,
|
||||
"scrollable body must set a non-default max_height"
|
||||
);
|
||||
assert_eq!(nodes[0].overflow, Overflow::scroll_y());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_f1_twice_closes_help_screen() {
|
||||
let mut app = headless_app();
|
||||
|
||||
@@ -26,6 +26,7 @@ use crate::progress_plugin::ProgressResource;
|
||||
use crate::ui_focus::{Disabled, FocusGroup, Focusable};
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||
ScrimDismissible,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_STRONG, BORDER_SUBTLE, RADIUS_MD, STATE_INFO,
|
||||
@@ -359,7 +360,7 @@ fn handle_home_digit_keys(
|
||||
|
||||
/// Spawns the Home modal with five mode cards plus a Cancel button.
|
||||
fn spawn_home_screen(commands: &mut Commands, level: u32, font_res: Option<&FontResource>) {
|
||||
spawn_modal(commands, HomeScreen, Z_MODAL_PANEL, |card| {
|
||||
let scrim = spawn_modal(commands, HomeScreen, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, "Choose a Mode", font_res);
|
||||
|
||||
for mode in [
|
||||
@@ -383,6 +384,8 @@ fn spawn_home_screen(commands: &mut Commands, level: u32, font_res: Option<&Font
|
||||
);
|
||||
});
|
||||
});
|
||||
// Home is read-only — opt into click-outside-to-dismiss.
|
||||
commands.entity(scrim).insert(ScrimDismissible);
|
||||
}
|
||||
|
||||
/// Tab-walk order for each mode card, matching the visual top-to-bottom
|
||||
|
||||
@@ -54,6 +54,16 @@ use crate::time_attack_plugin::TimeAttackResource;
|
||||
/// Z-depth used for cards while being dragged — above all resting cards.
|
||||
const DRAG_Z: f32 = 500.0;
|
||||
|
||||
/// Solver budgets used by the H-key hint system.
|
||||
///
|
||||
/// Wraps `solitaire_core::solver::SolverConfig` as a Bevy resource so
|
||||
/// tests can inject tighter budgets to exercise the heuristic-fallback
|
||||
/// path. Production initialises this to `SolverConfig::default()` (100k
|
||||
/// move / 200k state budgets, the same numbers the new-game retry loop
|
||||
/// uses).
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
pub struct HintSolverConfig(pub solitaire_core::solver::SolverConfig);
|
||||
|
||||
/// Shared countdown state for the new-game double-press confirmation
|
||||
/// flow.
|
||||
///
|
||||
@@ -89,6 +99,7 @@ pub struct InputPlugin;
|
||||
impl Plugin for InputPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<HintCycleIndex>()
|
||||
.init_resource::<HintSolverConfig>()
|
||||
.init_resource::<KeyboardConfirmState>()
|
||||
.add_message::<NewGameConfirmEvent>()
|
||||
.add_message::<StartZenRequestEvent>()
|
||||
@@ -236,20 +247,34 @@ fn handle_keyboard_core(
|
||||
// Esc is handled by `PausePlugin` (overlay toggle + paused flag).
|
||||
}
|
||||
|
||||
/// Handles the H key: cycles through all available hints, highlighting the
|
||||
/// source card yellow for 2 s and showing a descriptive toast.
|
||||
/// Handles the H key: surface the solver's provably-best first move when
|
||||
/// the position is winnable; otherwise fall back to cycling through the
|
||||
/// heuristic hints.
|
||||
///
|
||||
/// The hint index wraps around once all hints have been cycled through. When no
|
||||
/// moves are available a "No hints available" toast is shown instead.
|
||||
/// The solver (`solitaire_core::solver::try_solve_from_state`) is run
|
||||
/// synchronously on each H press — median ~2 ms on real positions, with a
|
||||
/// hard cap from `SolverConfig::default()`'s budgets. When the verdict is
|
||||
/// `Winnable`, the returned `first_move` is shown as a single, stable hint
|
||||
/// (no cycling — the optimal move doesn't change between identical
|
||||
/// presses). When the verdict is `Unwinnable` or `Inconclusive`, the
|
||||
/// handler falls back to the legacy heuristic in `all_hints`, which still
|
||||
/// cycles through every legal move.
|
||||
///
|
||||
/// When no moves are available a "No hints available" toast is shown
|
||||
/// instead. The H key always produces a hint when any legal move exists.
|
||||
///
|
||||
/// TODO: if profiling ever shows >100 ms solver calls in practice, move
|
||||
/// the solver call to `AsyncComputeTaskPool` to keep input latency low.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_keyboard_hint(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
solver_config: Res<HintSolverConfig>,
|
||||
mut hint_cycle: ResMut<HintCycleIndex>,
|
||||
mut commands: Commands,
|
||||
mut card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
|
||||
card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
|
||||
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||
mut hint_visual: MessageWriter<HintVisualEvent>,
|
||||
) {
|
||||
@@ -269,6 +294,25 @@ fn handle_keyboard_hint(
|
||||
|
||||
let Some(_layout_res) = layout else { return };
|
||||
|
||||
// First pass: ask the solver for the provably-best move. The
|
||||
// solver is deterministic, so repeated H presses on the same
|
||||
// position keep showing the same hint (cycling is reserved for
|
||||
// the heuristic fallback path).
|
||||
use solitaire_core::solver::{try_solve_from_state, SolverResult};
|
||||
let outcome = try_solve_from_state(&g.0, &solver_config.0);
|
||||
if outcome.result == SolverResult::Winnable
|
||||
&& let Some(mv) = outcome.first_move
|
||||
{
|
||||
let from = mv.source.clone();
|
||||
let to = mv.dest.clone();
|
||||
emit_hint_visuals(&g.0, &from, &to, &mut commands, card_entities, &mut info_toast, &mut hint_visual);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: heuristic cycling hint. Used when the solver verdict
|
||||
// is `Unwinnable` (no legal winning path — but a legal *move* may
|
||||
// still exist, e.g. drawing from stock) or `Inconclusive` (budget
|
||||
// exhausted on a complex mid-game position).
|
||||
let hints = all_hints(&g.0);
|
||||
if hints.is_empty() {
|
||||
info_toast.write(InfoToastEvent("No hints available".to_string()));
|
||||
@@ -278,14 +322,29 @@ fn handle_keyboard_hint(
|
||||
// Pick the hint at the current cycle index (wrapping) and advance.
|
||||
let idx = hint_cycle.0 % hints.len();
|
||||
hint_cycle.0 = hint_cycle.0.wrapping_add(1);
|
||||
let (from, to, _count) = &hints[idx];
|
||||
let (from, to, _count) = hints[idx].clone();
|
||||
emit_hint_visuals(&g.0, &from, &to, &mut commands, card_entities, &mut info_toast, &mut hint_visual);
|
||||
}
|
||||
|
||||
/// Apply the visual + toast effects for a single chosen hint move.
|
||||
///
|
||||
/// Shared between the solver-driven and heuristic-driven hint paths so
|
||||
/// both produce identical player-facing feedback.
|
||||
fn emit_hint_visuals(
|
||||
game: &GameState,
|
||||
from: &PileType,
|
||||
to: &PileType,
|
||||
commands: &mut Commands,
|
||||
mut card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
|
||||
info_toast: &mut MessageWriter<InfoToastEvent>,
|
||||
hint_visual: &mut MessageWriter<HintVisualEvent>,
|
||||
) {
|
||||
// When the hint points at the stock (draw suggestion) there is no
|
||||
// face-up card to highlight — show a toast instead.
|
||||
// If the stock is empty, pressing D will recycle the waste rather
|
||||
// than draw a card, so the toast text must reflect that.
|
||||
if *from == PileType::Stock {
|
||||
let stock_empty = g.0.piles
|
||||
let stock_empty = game.piles
|
||||
.get(&PileType::Stock)
|
||||
.is_some_and(|p| p.cards.is_empty());
|
||||
let msg = if stock_empty {
|
||||
@@ -298,7 +357,7 @@ fn handle_keyboard_hint(
|
||||
}
|
||||
|
||||
// Find the top face-up card in the source pile and highlight it.
|
||||
let top_card_id = g.0.piles.get(from)
|
||||
let top_card_id = game.piles.get(from)
|
||||
.and_then(|p| p.cards.last().filter(|c| c.face_up))
|
||||
.map(|c| c.id);
|
||||
if let Some(card_id) = top_card_id {
|
||||
@@ -327,7 +386,7 @@ fn handle_keyboard_hint(
|
||||
// player keeps thinking in suit terms; otherwise fall back to "foundation".
|
||||
let msg = match to {
|
||||
PileType::Foundation(_) => {
|
||||
let claimed = g.0.piles.get(to).and_then(|p| p.claimed_suit());
|
||||
let claimed = game.piles.get(to).and_then(|p| p.claimed_suit());
|
||||
if let Some(suit) = claimed {
|
||||
let suit_name = match suit {
|
||||
Suit::Clubs => "Clubs",
|
||||
@@ -2125,5 +2184,194 @@ mod tests {
|
||||
anim.end_z
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Hint system — solver promotion (v0.16.0+)
|
||||
//
|
||||
// The H-key hint is now backed by `solitaire_core::solver::try_solve_from_state`.
|
||||
// When the solver proves the position winnable, the hint is the
|
||||
// first move on the solver's solution path. When the solver returns
|
||||
// Inconclusive (budget exhausted) or Unwinnable, the legacy
|
||||
// heuristic in `all_hints` supplies the hint instead so the H key
|
||||
// always produces feedback while any legal move exists.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Build a minimal Bevy app that registers only the resources and
|
||||
/// messages needed to drive `handle_keyboard_hint` end-to-end.
|
||||
/// Skips every other input system — the test only exercises the hint
|
||||
/// path and we want the assertions to be unaffected by other handlers.
|
||||
fn hint_test_app() -> App {
|
||||
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::<ButtonInput<KeyCode>>();
|
||||
// Layout: a fixed 1280x800 layout — `handle_keyboard_hint` only
|
||||
// checks the resource is present, never reads coordinates.
|
||||
app.insert_resource(crate::layout::LayoutResource(
|
||||
crate::layout::compute_layout(Vec2::new(1280.0, 800.0)),
|
||||
));
|
||||
app.add_systems(Update, handle_keyboard_hint);
|
||||
app
|
||||
}
|
||||
|
||||
/// Helper: simulate "the player just pressed H this frame".
|
||||
fn press_h(app: &mut App) {
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release(KeyCode::KeyH);
|
||||
input.clear();
|
||||
input.press(KeyCode::KeyH);
|
||||
}
|
||||
|
||||
/// Build a near-finished `GameState`: foundations hold A..Q for each
|
||||
/// suit, four Kings sit on tableau columns 0..3, stock and waste
|
||||
/// empty. Solver-side equivalent of the `near_finished_game_state`
|
||||
/// helper in `solitaire_core::solver::tests`.
|
||||
fn near_finished_game_state() -> GameState {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
for slot in 0..4_u8 {
|
||||
game.piles
|
||||
.get_mut(&PileType::Foundation(slot))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
game.piles
|
||||
.get_mut(&PileType::Tableau(i))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
let suit_for_slot = [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,
|
||||
];
|
||||
for (slot, suit) in suit_for_slot.iter().enumerate() {
|
||||
let pile = game
|
||||
.piles
|
||||
.get_mut(&PileType::Foundation(slot as u8))
|
||||
.unwrap();
|
||||
for (i, rank) in ranks_below_king.iter().enumerate() {
|
||||
pile.cards.push(Card {
|
||||
id: (slot as u32) * 13 + i as u32,
|
||||
suit: *suit,
|
||||
rank: *rank,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (col, suit) in suit_for_slot.iter().enumerate() {
|
||||
game.piles
|
||||
.get_mut(&PileType::Tableau(col))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(Card {
|
||||
id: 100 + col as u32,
|
||||
suit: *suit,
|
||||
rank: Rank::King,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
game
|
||||
}
|
||||
|
||||
/// When the solver verdict is Winnable, the hint must come from the
|
||||
/// solver: in our near-finished fixture, four Tableau→Foundation
|
||||
/// moves are legal and the solver returns one of them. The
|
||||
/// `HintVisualEvent` source card must be one of the four Kings and
|
||||
/// the destination must be a foundation slot.
|
||||
#[test]
|
||||
fn hint_uses_solver_when_winnable() {
|
||||
use solitaire_core::card::Rank;
|
||||
let mut app = hint_test_app();
|
||||
let game = near_finished_game_state();
|
||||
// Track the 4 King ids so we can assert the hint source matches.
|
||||
let king_ids: Vec<u32> = (0..4_u8)
|
||||
.map(|c| {
|
||||
game.piles
|
||||
.get(&PileType::Tableau(c as usize))
|
||||
.unwrap()
|
||||
.cards
|
||||
.last()
|
||||
.filter(|c| c.rank == Rank::King)
|
||||
.map(|c| c.id)
|
||||
.expect("each tableau col 0..3 has a King on top")
|
||||
})
|
||||
.collect();
|
||||
|
||||
app.insert_resource(GameStateResource(game));
|
||||
press_h(&mut app);
|
||||
app.update();
|
||||
|
||||
// Read out the messages via the standard cursor API.
|
||||
let messages = app.world().resource::<Messages<HintVisualEvent>>();
|
||||
let mut cursor = messages.get_cursor();
|
||||
let collected: Vec<HintVisualEvent> = cursor.read(messages).cloned().collect();
|
||||
assert_eq!(
|
||||
collected.len(), 1,
|
||||
"exactly one HintVisualEvent must fire on a winnable solver verdict"
|
||||
);
|
||||
let event = &collected[0];
|
||||
assert!(
|
||||
king_ids.contains(&event.source_card_id),
|
||||
"solver hint must point at one of the four Kings; got id {}",
|
||||
event.source_card_id
|
||||
);
|
||||
assert!(
|
||||
matches!(event.dest_pile, PileType::Foundation(_)),
|
||||
"solver hint destination must be a foundation slot; got {:?}",
|
||||
event.dest_pile
|
||||
);
|
||||
}
|
||||
|
||||
/// When the solver returns Inconclusive (e.g. tight budgets force an
|
||||
/// early bail), the heuristic fallback must still produce a hint
|
||||
/// event so the H key never feels broken.
|
||||
///
|
||||
/// We force the solver inconclusive by setting both budgets to 0 —
|
||||
/// the search bails on the very first iteration, returning
|
||||
/// `SolverResult::Inconclusive`. The heuristic fallback then runs on
|
||||
/// the fresh deal and finds at least one legal move.
|
||||
#[test]
|
||||
fn hint_falls_back_to_heuristic_when_solver_inconclusive() {
|
||||
use solitaire_core::solver::SolverConfig;
|
||||
let mut app = hint_test_app();
|
||||
// Force solver to bail before exploring anything.
|
||||
app.insert_resource(HintSolverConfig(SolverConfig {
|
||||
move_budget: 0,
|
||||
state_budget: 0,
|
||||
}));
|
||||
// A fresh seeded deal — guaranteed to have at least one legal
|
||||
// move (the standard Klondike opening always has draws available
|
||||
// even if no immediate tableau move exists).
|
||||
let game = GameState::new(42, DrawMode::DrawOne);
|
||||
app.insert_resource(GameStateResource(game));
|
||||
press_h(&mut app);
|
||||
app.update();
|
||||
|
||||
let world = app.world();
|
||||
let visuals = world.resource::<Messages<HintVisualEvent>>();
|
||||
let mut visual_cursor = visuals.get_cursor();
|
||||
let collected: Vec<HintVisualEvent> = visual_cursor.read(visuals).cloned().collect();
|
||||
// Either a card-move hint (most fresh deals) or a draw suggestion.
|
||||
// A draw suggestion fires no `HintVisualEvent` (only an
|
||||
// `InfoToastEvent`), so we accept zero-or-one HintVisualEvent so
|
||||
// long as at least one feedback signal was emitted overall.
|
||||
let toasts = world.resource::<Messages<InfoToastEvent>>();
|
||||
let mut toast_cursor = toasts.get_cursor();
|
||||
let toast_count = toast_cursor.read(toasts).count();
|
||||
assert!(
|
||||
!collected.is_empty() || toast_count > 0,
|
||||
"heuristic fallback must produce a hint signal (visual or toast)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
//! When the provider does not support leaderboards (e.g. `LocalOnlyProvider`)
|
||||
//! the panel shows "Not available" immediately.
|
||||
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||
use solitaire_data::settings::SyncBackend;
|
||||
@@ -20,10 +21,11 @@ use crate::settings_plugin::SettingsResource;
|
||||
use crate::sync_plugin::SyncProviderResource;
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||
ScrimDismissible,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_INFO, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
|
||||
TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_4, Z_MODAL_PANEL,
|
||||
TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_4, Z_MODAL_PANEL,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -66,6 +68,18 @@ struct LeaderboardFetchTask(Option<Task<Result<Vec<LeaderboardEntry>, String>>>)
|
||||
#[derive(Component, Debug)]
|
||||
pub struct LeaderboardScreen;
|
||||
|
||||
/// Marker on the scrollable body Node inside the Leaderboard modal.
|
||||
///
|
||||
/// The leaderboard caps at the top 10 entries today, but rendering the
|
||||
/// caption + opt-in/opt-out row + 10 data rows on the 800x600 minimum
|
||||
/// window is right at the edge of overflowing — long display names or
|
||||
/// future row-count expansion would cut off entries below the fold.
|
||||
/// Wrapping the data section in an `Overflow::scroll_y()` Node with a
|
||||
/// constrained `max_height` keeps every row reachable. Mirrors the
|
||||
/// `SettingsPanelScrollable` pattern.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct LeaderboardScrollable;
|
||||
|
||||
/// Marker on the "Opt In" button inside the leaderboard panel.
|
||||
#[derive(Component, Debug)]
|
||||
struct LeaderboardOptInButton;
|
||||
@@ -98,6 +112,11 @@ impl Plugin for LeaderboardPlugin {
|
||||
.init_resource::<OptInTask>()
|
||||
.init_resource::<OptOutTask>()
|
||||
.add_message::<ToggleLeaderboardRequestEvent>()
|
||||
// `MouseWheel` is emitted by Bevy's input plugin under
|
||||
// `DefaultPlugins`; register it explicitly so the
|
||||
// leaderboard-scroll system also runs cleanly under
|
||||
// `MinimalPlugins` in tests.
|
||||
.add_message::<MouseWheel>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -112,7 +131,8 @@ impl Plugin for LeaderboardPlugin {
|
||||
poll_opt_out_task,
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
)
|
||||
.add_systems(Update, scroll_leaderboard_panel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +242,33 @@ fn update_leaderboard_panel(
|
||||
}
|
||||
|
||||
/// Click handler for the modal's "Done" button — despawns the overlay.
|
||||
/// Routes mouse-wheel events into the Leaderboard modal's scrollable
|
||||
/// data body while the panel is open. No-op when no
|
||||
/// `LeaderboardScrollable` exists in the world (modal closed). Mirrors
|
||||
/// `scroll_settings_panel`.
|
||||
fn scroll_leaderboard_panel(
|
||||
mut scroll_evr: MessageReader<MouseWheel>,
|
||||
mut scrollables: Query<&mut ScrollPosition, With<LeaderboardScrollable>>,
|
||||
) {
|
||||
if scrollables.is_empty() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_leaderboard_close_button(
|
||||
mut commands: Commands,
|
||||
close_buttons: Query<&Interaction, (With<LeaderboardCloseButton>, Changed<Interaction>)>,
|
||||
@@ -346,7 +393,7 @@ fn spawn_leaderboard_screen(
|
||||
remote_available: bool,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
spawn_modal(commands, LeaderboardScreen, Z_MODAL_PANEL, |card| {
|
||||
let scrim = spawn_modal(commands, LeaderboardScreen, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, "Leaderboard", font_res);
|
||||
|
||||
// Subhead — what the screen does + what the buttons control.
|
||||
@@ -420,23 +467,40 @@ fn spawn_leaderboard_screen(
|
||||
BackgroundColor(BORDER_SUBTLE),
|
||||
));
|
||||
|
||||
// Scrollable data section — caps at top 10 rows today, but on the
|
||||
// 800x600 minimum window the header + caption + opt-in row + 10
|
||||
// entries crowds the modal. Wrapping in `Overflow::scroll_y()`
|
||||
// with a `max_height` keeps every entry reachable and survives
|
||||
// any future expansion of the row cap.
|
||||
card.spawn((
|
||||
LeaderboardScrollable,
|
||||
ScrollPosition::default(),
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_2,
|
||||
max_height: Val::Vh(50.0),
|
||||
overflow: Overflow::scroll_y(),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|body| {
|
||||
match data {
|
||||
LeaderboardResource::Idle => {
|
||||
card.spawn((
|
||||
body.spawn((
|
||||
Text::new("Fetching\u{2026}"),
|
||||
font_status.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
}
|
||||
LeaderboardResource::Error(_) => {
|
||||
card.spawn((
|
||||
body.spawn((
|
||||
Text::new("Couldn't reach the leaderboard. Try again later."),
|
||||
font_status.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
LeaderboardResource::Loaded(rows) if rows.is_empty() => {
|
||||
card.spawn((
|
||||
body.spawn((
|
||||
Text::new("No entries yet \u{2014} sync and opt in to appear here."),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
@@ -444,7 +508,7 @@ fn spawn_leaderboard_screen(
|
||||
}
|
||||
LeaderboardResource::Loaded(rows) => {
|
||||
// Column headers
|
||||
card.spawn(Node {
|
||||
body.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: VAL_SPACE_4,
|
||||
..default()
|
||||
@@ -476,7 +540,7 @@ fn spawn_leaderboard_screen(
|
||||
.best_score
|
||||
.map_or_else(|| "-".to_string(), |s| s.to_string());
|
||||
|
||||
card.spawn(Node {
|
||||
body.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: VAL_SPACE_4,
|
||||
..default()
|
||||
@@ -490,6 +554,7 @@ fn spawn_leaderboard_screen(
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
@@ -502,6 +567,8 @@ fn spawn_leaderboard_screen(
|
||||
);
|
||||
});
|
||||
});
|
||||
// Leaderboard is read-only — opt into click-outside-to-dismiss.
|
||||
commands.entity(scrim).insert(ScrimDismissible);
|
||||
}
|
||||
|
||||
fn header_cell(parent: &mut ChildSpawnerCommands, text: &str, width: f32, font: &TextFont) {
|
||||
@@ -646,6 +713,34 @@ mod tests {
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaderboard_modal_body_is_scrollable() {
|
||||
let mut app = headless_app();
|
||||
press(&mut app, KeyCode::KeyL);
|
||||
app.update();
|
||||
|
||||
let count = app
|
||||
.world_mut()
|
||||
.query::<&LeaderboardScrollable>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(
|
||||
count, 1,
|
||||
"Leaderboard modal must spawn exactly one LeaderboardScrollable body"
|
||||
);
|
||||
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Node, With<LeaderboardScrollable>>();
|
||||
let nodes: Vec<&Node> = q.iter(app.world()).collect();
|
||||
assert_ne!(
|
||||
nodes[0].max_height,
|
||||
Val::Auto,
|
||||
"scrollable body must set a non-default max_height"
|
||||
);
|
||||
assert_eq!(nodes[0].overflow, Overflow::scroll_y());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_l_twice_dismisses_screen() {
|
||||
let mut app = headless_app();
|
||||
|
||||
@@ -24,6 +24,8 @@ pub mod onboarding_plugin;
|
||||
pub mod pause_plugin;
|
||||
pub mod profile_plugin;
|
||||
pub mod radial_menu;
|
||||
pub mod replay_overlay;
|
||||
pub mod replay_playback;
|
||||
pub mod settings_plugin;
|
||||
pub mod progress_plugin;
|
||||
pub mod resources;
|
||||
@@ -112,6 +114,14 @@ pub use radial_menu::{
|
||||
legal_destinations_for_card, radial_anchor_for_index, radial_hovered_index, RadialIcon,
|
||||
RadialMenuPlugin, RightClickRadialState, Z_RADIAL_MENU,
|
||||
};
|
||||
pub use replay_overlay::{
|
||||
ReplayOverlayBannerText, ReplayOverlayPlugin, ReplayOverlayProgressText, ReplayOverlayRoot,
|
||||
ReplayStopButton, Z_REPLAY_OVERLAY,
|
||||
};
|
||||
pub use replay_playback::{
|
||||
start_replay_playback, stop_replay_playback, ReplayPlaybackPlugin, ReplayPlaybackState,
|
||||
REPLAY_COMPLETION_LINGER_SECS, REPLAY_MOVE_INTERVAL_SECS,
|
||||
};
|
||||
pub use settings_plugin::{
|
||||
PendingWindowGeometry, SettingsChangedEvent, SettingsPlugin, SettingsResource, SettingsScreen,
|
||||
SFX_STEP, WINDOW_GEOMETRY_DEBOUNCE_SECS,
|
||||
@@ -123,7 +133,8 @@ pub use selection_plugin::{
|
||||
};
|
||||
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
|
||||
pub use stats_plugin::{
|
||||
format_replay_caption, LatestReplayPath, LatestReplayResource, StatsPlugin, StatsResource,
|
||||
format_replay_caption, LatestReplayPath, ReplayHistoryResource, ReplayNextButton,
|
||||
ReplayPrevButton, ReplaySelectorCaption, SelectedReplayIndex, StatsPlugin, StatsResource,
|
||||
StatsScreen, StatsUpdate, WatchReplayButton,
|
||||
};
|
||||
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! summary in a single scrollable panel. Spawned on the first `P` keypress and
|
||||
//! despawned on the second.
|
||||
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
use chrono::{Duration, Local, NaiveDate};
|
||||
@@ -19,6 +20,7 @@ use crate::settings_plugin::SettingsResource;
|
||||
use crate::stats_plugin::{format_fastest_win, format_win_rate, StatsResource};
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||
ScrimDismissible,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, SPACE_1, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY,
|
||||
@@ -60,10 +62,60 @@ pub struct ProfilePlugin;
|
||||
#[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
|
||||
/// calendar), every unlocked achievement (up to ~18), and a stats
|
||||
/// summary, which can overflow the modal on the 800x600 minimum window
|
||||
/// once a player has unlocked several achievements. This marker tags
|
||||
/// the inner container that carries `Overflow::scroll_y()` plus a
|
||||
/// `max_height` constraint. Mirrors the `SettingsPanelScrollable`
|
||||
/// pattern.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ProfileScrollable;
|
||||
|
||||
impl Plugin for ProfilePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_message::<ToggleProfileRequestEvent>()
|
||||
.add_systems(Update, (toggle_profile_screen, handle_profile_close_button));
|
||||
// `MouseWheel` is emitted by Bevy's input plugin under
|
||||
// `DefaultPlugins`; register it explicitly so the
|
||||
// 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes mouse-wheel events into the Profile modal's scrollable body
|
||||
/// while the panel is open. No-op when no `ProfileScrollable` exists in
|
||||
/// the world (modal closed). Mirrors `scroll_settings_panel`.
|
||||
fn scroll_profile_panel(
|
||||
mut scroll_evr: MessageReader<MouseWheel>,
|
||||
mut scrollables: Query<&mut ScrollPosition, With<ProfileScrollable>>,
|
||||
) {
|
||||
if scrollables.is_empty() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,9 +185,27 @@ fn spawn_profile_screen(
|
||||
..default()
|
||||
};
|
||||
|
||||
spawn_modal(commands, ProfileScreen, Z_MODAL_PANEL, |card| {
|
||||
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
|
||||
// overflow the modal on the 800x600 minimum window once the
|
||||
// player has unlocked several achievements. The Done action
|
||||
// stays fixed outside the scroll.
|
||||
card.spawn((
|
||||
ProfileScrollable,
|
||||
ScrollPosition::default(),
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_1,
|
||||
max_height: Val::Vh(70.0),
|
||||
overflow: Overflow::scroll_y(),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|body| {
|
||||
// First-launch welcome — only when the player has zero XP and
|
||||
// zero daily streak, so the profile doesn't read as a wall of
|
||||
// zeros to a brand-new player.
|
||||
@@ -143,7 +213,7 @@ fn spawn_profile_screen(
|
||||
&& p.0.total_xp == 0
|
||||
&& p.0.daily_challenge_streak == 0
|
||||
{
|
||||
card.spawn((
|
||||
body.spawn((
|
||||
Text::new("Welcome! Play games to earn XP and unlock achievements."),
|
||||
font_section.clone(),
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
@@ -158,14 +228,14 @@ fn spawn_profile_screen(
|
||||
}
|
||||
|
||||
// ── Sync section ────────────────────────────────────────────
|
||||
card.spawn((
|
||||
body.spawn((
|
||||
Text::new("Sync"),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
if let Some(s) = settings {
|
||||
let (backend_name, username) = sync_info(&s.0.sync_backend);
|
||||
card.spawn((
|
||||
body.spawn((
|
||||
Text::new(format!("Account: {username} | Backend: {backend_name}")),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
@@ -180,7 +250,7 @@ fn spawn_profile_screen(
|
||||
}
|
||||
SyncStatus::Error(e) => format!("Sync error: {e}"),
|
||||
};
|
||||
card.spawn((
|
||||
body.spawn((
|
||||
Text::new(status_text),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
@@ -188,8 +258,8 @@ fn spawn_profile_screen(
|
||||
}
|
||||
|
||||
// ── Progression section ─────────────────────────────────────
|
||||
spawn_spacer(card, VAL_SPACE_2);
|
||||
card.spawn((
|
||||
spawn_spacer(body, VAL_SPACE_2);
|
||||
body.spawn((
|
||||
Text::new("Progression"),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
@@ -202,7 +272,7 @@ fn spawn_profile_screen(
|
||||
} else {
|
||||
xp_done.saturating_mul(100).checked_div(xp_span).unwrap_or(100)
|
||||
};
|
||||
card.spawn((
|
||||
body.spawn((
|
||||
Text::new(format!(
|
||||
"Level {} \u{2014} {} XP ({}/{} to next, {}%)",
|
||||
prog.level, prog.total_xp, xp_done, xp_span, pct
|
||||
@@ -210,7 +280,7 @@ fn spawn_profile_screen(
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
card.spawn((
|
||||
body.spawn((
|
||||
Text::new(format!(
|
||||
"Daily streak: {} | Card backs: {} | Backgrounds: {}",
|
||||
prog.daily_challenge_streak,
|
||||
@@ -223,7 +293,7 @@ fn spawn_profile_screen(
|
||||
|
||||
// 14-day daily-challenge calendar row.
|
||||
spawn_daily_calendar(
|
||||
card,
|
||||
body,
|
||||
&prog.daily_challenge_history,
|
||||
prog.daily_challenge_streak,
|
||||
prog.daily_challenge_longest_streak,
|
||||
@@ -233,8 +303,8 @@ fn spawn_profile_screen(
|
||||
}
|
||||
|
||||
// ── Achievements section ────────────────────────────────────
|
||||
spawn_spacer(card, VAL_SPACE_2);
|
||||
card.spawn((
|
||||
spawn_spacer(body, VAL_SPACE_2);
|
||||
body.spawn((
|
||||
Text::new("Achievements"),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
@@ -242,7 +312,7 @@ fn spawn_profile_screen(
|
||||
if let Some(ar) = achievements {
|
||||
let records = &ar.0;
|
||||
let unlocked_count = records.iter().filter(|r| r.unlocked).count();
|
||||
card.spawn((
|
||||
body.spawn((
|
||||
Text::new(format!("{unlocked_count} / 18 unlocked")),
|
||||
font_row.clone(),
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
@@ -264,14 +334,14 @@ fn spawn_profile_screen(
|
||||
Some(dt) => format!(" ({})", dt.format("%Y-%m-%d")),
|
||||
None => String::new(),
|
||||
};
|
||||
card.spawn((
|
||||
body.spawn((
|
||||
Text::new(format!(" [x] {name}{date_str}")),
|
||||
font_row.clone(),
|
||||
TextColor(STATE_SUCCESS),
|
||||
));
|
||||
}
|
||||
if !any_unlocked {
|
||||
card.spawn((
|
||||
body.spawn((
|
||||
Text::new(" No achievements unlocked yet."),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
@@ -280,8 +350,8 @@ fn spawn_profile_screen(
|
||||
}
|
||||
|
||||
// ── Statistics summary section ──────────────────────────────
|
||||
spawn_spacer(card, VAL_SPACE_2);
|
||||
card.spawn((
|
||||
spawn_spacer(body, VAL_SPACE_2);
|
||||
body.spawn((
|
||||
Text::new("Statistics Summary"),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
@@ -293,7 +363,7 @@ fn spawn_profile_screen(
|
||||
} else {
|
||||
s.best_single_score.to_string()
|
||||
};
|
||||
card.spawn((
|
||||
body.spawn((
|
||||
Text::new(format!(
|
||||
"Played: {} | Won: {} | Win rate: {} | Best time: {}",
|
||||
s.games_played,
|
||||
@@ -304,7 +374,7 @@ fn spawn_profile_screen(
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
card.spawn((
|
||||
body.spawn((
|
||||
Text::new(format!(
|
||||
"Win streak: {} current, {} best | Best score: {}",
|
||||
s.win_streak_current, s.win_streak_best, best_score_str,
|
||||
@@ -313,6 +383,7 @@ fn spawn_profile_screen(
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
@@ -325,6 +396,8 @@ fn spawn_profile_screen(
|
||||
);
|
||||
});
|
||||
});
|
||||
// Profile is read-only — opt into click-outside-to-dismiss.
|
||||
commands.entity(scrim).insert(ScrimDismissible);
|
||||
}
|
||||
|
||||
/// Spawn a fixed-height vertical spacer node.
|
||||
@@ -503,6 +576,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_modal_body_is_scrollable() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyP);
|
||||
app.update();
|
||||
|
||||
let count = app
|
||||
.world_mut()
|
||||
.query::<&ProfileScrollable>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(
|
||||
count, 1,
|
||||
"Profile modal must spawn exactly one ProfileScrollable body"
|
||||
);
|
||||
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Node, With<ProfileScrollable>>();
|
||||
let nodes: Vec<&Node> = q.iter(app.world()).collect();
|
||||
assert_ne!(
|
||||
nodes[0].max_height,
|
||||
Val::Auto,
|
||||
"scrollable body must set a non-default max_height"
|
||||
);
|
||||
assert_eq!(nodes[0].overflow, Overflow::scroll_y());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_p_twice_closes_profile_screen() {
|
||||
let mut app = headless_app();
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
//! On-screen overlay shown while a recorded [`Replay`] plays back.
|
||||
//!
|
||||
//! The overlay is a thin top-of-window banner with three pieces of UI:
|
||||
//!
|
||||
//! - A "Replay" label on the left so the player knows the surface is
|
||||
//! under playback control rather than live input.
|
||||
//! - A "Move N of M" progress indicator in the centre, recomputed every
|
||||
//! frame the cursor advances.
|
||||
//! - A "Stop" button on the right that aborts playback and returns
|
||||
//! control to the player.
|
||||
//!
|
||||
//! When playback finishes ([`ReplayPlaybackState::Completed`]) the banner
|
||||
//! label swaps to "Replay complete" and stays visible until the playback
|
||||
//! core auto-clears the resource back to [`ReplayPlaybackState::Inactive`]
|
||||
//! a few seconds later, at which point the overlay despawns.
|
||||
//!
|
||||
//! The overlay sits at z-layer [`Z_REPLAY_OVERLAY`] — above gameplay but
|
||||
//! below every modal layer ([`Z_MODAL_SCRIM`] and up). That ordering lets
|
||||
//! the player still open Settings, Pause, and Help during a replay; those
|
||||
//! modals will render on top of the banner as expected.
|
||||
//!
|
||||
//! [`Replay`]: solitaire_data::Replay
|
||||
//! [`Z_MODAL_SCRIM`]: crate::ui_theme::Z_MODAL_SCRIM
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::replay_playback::{stop_replay_playback, ReplayPlaybackState};
|
||||
use crate::ui_modal::{spawn_modal_button, ButtonVariant};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BG_ELEVATED_HI, TEXT_PRIMARY, TYPE_BODY, TYPE_HEADLINE, VAL_SPACE_2,
|
||||
VAL_SPACE_4, Z_DROP_OVERLAY,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Z-index — see `ui_theme::Z_MODAL_SCRIM` (200) for the next layer above.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `bevy::ui` `ZIndex` value for the replay overlay banner.
|
||||
///
|
||||
/// Numeric value is `Z_DROP_OVERLAY as i32 + 5 = 55`; chosen so the banner
|
||||
/// sits clearly above the HUD top layer (`Z_HUD_TOP = 60` is intentionally
|
||||
/// **below** modals, but the overlay needs to be above HUD readouts) yet
|
||||
/// well below `Z_MODAL_SCRIM = 200` so Settings, Pause, and Help modals
|
||||
/// continue to render on top of the overlay during a replay.
|
||||
///
|
||||
/// The `Z_DROP_OVERLAY + 5` formula in the spec is reproduced here as an
|
||||
/// integer because `Z_DROP_OVERLAY` itself is a `f32` Sprite-space z used
|
||||
/// for the drop-target overlay sprites — UI nodes use `i32` `ZIndex`, so
|
||||
/// we materialise a separate constant rather than reuse the `f32` value.
|
||||
pub const Z_REPLAY_OVERLAY: i32 = Z_DROP_OVERLAY as i32 + 5;
|
||||
|
||||
/// Total height of the banner in pixels. Thin enough to leave the
|
||||
/// gameplay surface visible underneath, tall enough to comfortably fit
|
||||
/// the headline-sized "Replay" label.
|
||||
const BANNER_HEIGHT: f32 = 48.0;
|
||||
|
||||
/// Background colour alpha for the banner. `BG_ELEVATED_HI` at this alpha
|
||||
/// reads as a clear "this is a UI strip" callout while still letting the
|
||||
/// felt show through enough to anchor the banner to the play surface.
|
||||
const BANNER_ALPHA: f32 = 0.92;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Marker components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Marker on the banner's root `Node`. Used by the spawn / despawn /
|
||||
/// progress-update systems to find the overlay.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayOverlayRoot;
|
||||
|
||||
/// Marker on the left-hand banner label `Text`. Carries either "Replay"
|
||||
/// (during playback) or "Replay complete" (once finished); the
|
||||
/// completion-text-update system swaps the contents in place.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayOverlayBannerText;
|
||||
|
||||
/// Marker on the centre progress `Text`. Updated every frame to reflect
|
||||
/// the current `(cursor, total)` returned by
|
||||
/// [`ReplayPlaybackState::progress`].
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayOverlayProgressText;
|
||||
|
||||
/// Marker on the right-hand "Stop" button. Click handler queries for this
|
||||
/// and calls [`stop_replay_playback`] when an `Interaction::Pressed`
|
||||
/// transition is seen.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayStopButton;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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.
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
react_to_state_change,
|
||||
update_banner_label,
|
||||
update_progress_text,
|
||||
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.
|
||||
fn react_to_state_change(
|
||||
mut commands: Commands,
|
||||
state: Res<ReplayPlaybackState>,
|
||||
existing: Query<Entity, With<ReplayOverlayRoot>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
if !state.is_changed() {
|
||||
return;
|
||||
}
|
||||
|
||||
let should_be_visible = state.is_playing() || state.is_completed();
|
||||
let already_spawned = existing.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 {
|
||||
for entity in &existing {
|
||||
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.
|
||||
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();
|
||||
|
||||
let banner_label = if state.is_completed() {
|
||||
"Replay complete"
|
||||
} else {
|
||||
"Replay"
|
||||
};
|
||||
let progress_label = format_progress(state);
|
||||
|
||||
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,
|
||||
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),
|
||||
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()
|
||||
},
|
||||
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| {
|
||||
// Left: "Replay" label in the loud yellow accent so it reads
|
||||
// unmistakably as a non-gameplay surface.
|
||||
banner.spawn((
|
||||
ReplayOverlayBannerText,
|
||||
Text::new(banner_label),
|
||||
TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_HEADLINE,
|
||||
..default()
|
||||
},
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
));
|
||||
|
||||
// Centre: progress readout — neutral primary text colour so
|
||||
// the eye treats it as data, not a callout.
|
||||
banner.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" yellow accent owns that slot. `spawn_modal_button`
|
||||
// gives us hover / press paint and focus rings for free via
|
||||
// the existing `UiModalPlugin` paint system.
|
||||
banner
|
||||
.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_2,
|
||||
..default()
|
||||
})
|
||||
.with_children(|wrap| {
|
||||
spawn_modal_button(
|
||||
wrap,
|
||||
ReplayStopButton,
|
||||
"Stop",
|
||||
None,
|
||||
ButtonVariant::Tertiary,
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-frame text updates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Overwrites the banner label whenever the resource changes — covers the
|
||||
/// `Playing → Completed` transition by swapping "Replay" for
|
||||
/// "Replay complete" in place without despawning the overlay.
|
||||
fn update_banner_label(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
mut q: Query<&mut Text, With<ReplayOverlayBannerText>>,
|
||||
) {
|
||||
if !state.is_changed() {
|
||||
return;
|
||||
}
|
||||
let label = if state.is_completed() {
|
||||
"Replay complete"
|
||||
} else if state.is_playing() {
|
||||
"Replay"
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
for mut text in &mut q {
|
||||
**text = label.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// Repaints the "Move N of M" centre readout every frame the cursor moves.
|
||||
/// Cheap — early-exits if the resource has not changed since the last
|
||||
/// frame so idle replays don't churn the text mesh.
|
||||
fn update_progress_text(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
mut q: Query<&mut Text, With<ReplayOverlayProgressText>>,
|
||||
) {
|
||||
if !state.is_changed() {
|
||||
return;
|
||||
}
|
||||
let label = format_progress(&state);
|
||||
for mut text in &mut q {
|
||||
**text = label.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure helper — formats the centre progress readout for the given state.
|
||||
/// Exposed at module scope so the spawn path and the per-frame update
|
||||
/// path produce the exact same string.
|
||||
fn format_progress(state: &ReplayPlaybackState) -> String {
|
||||
match state.progress() {
|
||||
Some((cursor, total)) => format!("Move {cursor} of {total}"),
|
||||
None if state.is_completed() => "Replay complete".to_string(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stop button handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Watches the Stop button for `Interaction::Pressed` transitions. On a
|
||||
/// click, calls [`stop_replay_playback`] which resets the state to
|
||||
/// `Inactive`; the next frame's `react_to_state_change` then despawns
|
||||
/// the overlay.
|
||||
fn handle_stop_button(
|
||||
mut commands: Commands,
|
||||
mut state: ResMut<ReplayPlaybackState>,
|
||||
buttons: Query<&Interaction, (With<ReplayStopButton>, Changed<Interaction>)>,
|
||||
) {
|
||||
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||
return;
|
||||
}
|
||||
stop_replay_playback(&mut commands, &mut state);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::NaiveDate;
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_data::{Replay, ReplayMove};
|
||||
|
||||
/// Build a minimal but well-formed [`Replay`] with `move_count` no-op
|
||||
/// `StockClick` entries. Tests only ever read `replay.moves.len()`
|
||||
/// (denominator of the progress indicator), so the move kind is
|
||||
/// irrelevant beyond producing the right count.
|
||||
fn synthetic_replay(move_count: usize) -> Replay {
|
||||
Replay::new(
|
||||
42,
|
||||
DrawMode::DrawOne,
|
||||
GameMode::Classic,
|
||||
120,
|
||||
1_000,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
|
||||
(0..move_count).map(|_| ReplayMove::StockClick).collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a test app that has the overlay plugin but **not** the
|
||||
/// playback plugin — tests insert `ReplayPlaybackState` manually so
|
||||
/// they can drive every state transition deterministically.
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(ReplayOverlayPlugin);
|
||||
app.init_resource::<ReplayPlaybackState>();
|
||||
app
|
||||
}
|
||||
|
||||
/// Count `ReplayOverlayRoot` entities in the world — the overlay's
|
||||
/// presence/absence is the spawn-test's primary observable.
|
||||
fn overlay_root_count(app: &mut App) -> usize {
|
||||
app.world_mut()
|
||||
.query::<&ReplayOverlayRoot>()
|
||||
.iter(app.world())
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Read the current text content of the unique progress-text entity.
|
||||
fn progress_text(app: &mut App) -> String {
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Text, With<ReplayOverlayProgressText>>();
|
||||
q.iter(app.world())
|
||||
.next()
|
||||
.map(|t| t.0.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Read the current text content of the unique banner-label entity.
|
||||
fn banner_text(app: &mut App) -> String {
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Text, With<ReplayOverlayBannerText>>();
|
||||
q.iter(app.world())
|
||||
.next()
|
||||
.map(|t| t.0.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Set the playback resource without going through the playback core.
|
||||
fn set_state(app: &mut App, state: ReplayPlaybackState) {
|
||||
app.world_mut().insert_resource(state);
|
||||
}
|
||||
|
||||
/// Find the unique `ReplayStopButton` entity for the click-handler
|
||||
/// test. There must be exactly one.
|
||||
fn stop_button_entity(app: &mut App) -> Entity {
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<ReplayStopButton>>();
|
||||
q.iter(app.world())
|
||||
.next()
|
||||
.expect("Stop button must exist while overlay is spawned")
|
||||
}
|
||||
|
||||
/// Going `Inactive → Playing` spawns exactly one overlay root and
|
||||
/// the banner label reads "Replay".
|
||||
#[test]
|
||||
fn overlay_spawns_when_playback_starts() {
|
||||
let mut app = headless_app();
|
||||
// First update with the default `Inactive` resource — overlay
|
||||
// must not exist yet.
|
||||
app.update();
|
||||
assert_eq!(overlay_root_count(&mut app), 0);
|
||||
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
},
|
||||
);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
overlay_root_count(&mut app),
|
||||
1,
|
||||
"exactly one ReplayOverlayRoot must spawn on Inactive → Playing",
|
||||
);
|
||||
assert_eq!(banner_text(&mut app), "Replay");
|
||||
}
|
||||
|
||||
/// The progress-text entity reads `"Move {cursor} of {total}"` for a
|
||||
/// well-formed `Playing` state.
|
||||
#[test]
|
||||
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,
|
||||
},
|
||||
);
|
||||
app.update();
|
||||
|
||||
assert_eq!(progress_text(&mut app), "Move 5 of 10");
|
||||
}
|
||||
|
||||
/// Pressing the Stop button resets the state back to `Inactive` and
|
||||
/// the next frame's `react_to_state_change` despawns the overlay.
|
||||
/// Mirrors the synthetic `Interaction::Pressed` insertion pattern
|
||||
/// used elsewhere in the engine for headless click tests.
|
||||
#[test]
|
||||
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,
|
||||
},
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(overlay_root_count(&mut app), 1);
|
||||
|
||||
let stop = stop_button_entity(&mut app);
|
||||
app.world_mut()
|
||||
.entity_mut(stop)
|
||||
.insert(Interaction::Pressed);
|
||||
// Tick once: the click handler runs late in the frame and resets
|
||||
// the state to `Inactive`.
|
||||
app.update();
|
||||
|
||||
// State must be back to Inactive.
|
||||
let state = app.world().resource::<ReplayPlaybackState>();
|
||||
assert!(
|
||||
matches!(state, ReplayPlaybackState::Inactive),
|
||||
"Stop click must reset ReplayPlaybackState to Inactive; got {state:?}",
|
||||
);
|
||||
|
||||
// One more tick — `react_to_state_change` sees the resource
|
||||
// change to Inactive and despawns the overlay.
|
||||
app.update();
|
||||
assert_eq!(
|
||||
overlay_root_count(&mut app),
|
||||
0,
|
||||
"overlay must despawn the frame after state returns to Inactive",
|
||||
);
|
||||
}
|
||||
|
||||
/// Manually flipping the resource back to `Inactive` (e.g. via the
|
||||
/// playback core's auto-clear after `Completed`) tears the overlay
|
||||
/// down without any further input.
|
||||
#[test]
|
||||
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,
|
||||
},
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(overlay_root_count(&mut app), 1);
|
||||
|
||||
set_state(&mut app, ReplayPlaybackState::Inactive);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
overlay_root_count(&mut app),
|
||||
0,
|
||||
"overlay must despawn on Playing → Inactive transition",
|
||||
);
|
||||
}
|
||||
|
||||
/// On `Playing → Completed` the banner label updates in place rather
|
||||
/// than respawning. The overlay must still be present, and the label
|
||||
/// must read "Replay complete".
|
||||
#[test]
|
||||
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,
|
||||
},
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(banner_text(&mut app), "Replay");
|
||||
|
||||
set_state(&mut app, ReplayPlaybackState::Completed);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
overlay_root_count(&mut app),
|
||||
1,
|
||||
"overlay must remain spawned while in Completed state",
|
||||
);
|
||||
assert_eq!(
|
||||
banner_text(&mut app),
|
||||
"Replay complete",
|
||||
"banner label must swap on Playing → Completed",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,833 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! ## Public surface
|
||||
//!
|
||||
//! - [`ReplayPlaybackState`] — single source of truth for whether
|
||||
//! playback is live, how far through the move list we've ticked, and
|
||||
//! how long until the next advance.
|
||||
//! - [`start_replay_playback`] — public entry point; the Stats
|
||||
//! "Watch replay" button calls this. Resets the game to the recorded
|
||||
//! deal and transitions the state machine to
|
||||
//! [`ReplayPlaybackState::Playing`].
|
||||
//! - [`stop_replay_playback`] — interrupts playback at any time. Safe to
|
||||
//! call when [`ReplayPlaybackState::Inactive`].
|
||||
//! - [`ReplayPlaybackPlugin`] — registers the resource and the tick /
|
||||
//! linger systems.
|
||||
//!
|
||||
//! ## Coordination note
|
||||
//!
|
||||
//! This module is built in parallel with the Stats-side overlay. The
|
||||
//! resource shape, helper signatures, and plugin marker match the
|
||||
//! contract the overlay agent reads against — see also the docs on the
|
||||
//! enum variants.
|
||||
//!
|
||||
//! ## Recording is paused during playback
|
||||
//!
|
||||
//! Playback fires the same [`MoveRequestEvent`] / [`DrawRequestEvent`]
|
||||
//! the live engine handles. Without intervention, [`RecordingReplay`]
|
||||
//! would re-record those events and a replay would re-record itself
|
||||
//! indefinitely. To prevent that, [`record_replay_skip_during_playback`]
|
||||
//! snapshots the recording's length at the start of playback and
|
||||
//! truncates the buffer back to that length every frame. This keeps
|
||||
//! the recording contract opaque to `game_plugin` — no event-source
|
||||
//! flag is threaded through, no every-callsite gate is added.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_data::{Replay, ReplayMove};
|
||||
|
||||
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent};
|
||||
use crate::game_plugin::{GameMutation, RecordingReplay};
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
|
||||
/// Default per-move duration during playback, in seconds. Acts as the
|
||||
/// fallback when `SettingsResource` is absent — i.e. in headless test
|
||||
/// fixtures that don't install [`crate::settings_plugin::SettingsPlugin`].
|
||||
/// In production the live value is read from
|
||||
/// [`solitaire_data::Settings::replay_move_interval_secs`] every frame
|
||||
/// so Settings adjustments take effect on the next playback tick.
|
||||
///
|
||||
/// Kept in sync with `solitaire_data::settings::default_replay_move_interval_secs`
|
||||
/// (the data crate cannot depend on this engine crate, so the constant
|
||||
/// is duplicated). The
|
||||
/// `settings_replay_move_interval_default_matches_engine_constant`
|
||||
/// test in `solitaire_engine::settings_plugin` enforces equality.
|
||||
pub const REPLAY_MOVE_INTERVAL_SECS: f32 = 0.45;
|
||||
|
||||
/// Helper: returns the live per-move replay interval. Reads
|
||||
/// [`SettingsResource::replay_move_interval_secs`] when the resource is
|
||||
/// installed, falling back to [`REPLAY_MOVE_INTERVAL_SECS`] otherwise.
|
||||
/// Also clamps below by `f32::EPSILON` so a hand-edited 0.0 cannot
|
||||
/// busy-loop the playback tick.
|
||||
fn current_move_interval_secs(settings: Option<&SettingsResource>) -> f32 {
|
||||
let raw = settings
|
||||
.map(|s| s.0.replay_move_interval_secs)
|
||||
.unwrap_or(REPLAY_MOVE_INTERVAL_SECS);
|
||||
raw.max(f32::EPSILON)
|
||||
}
|
||||
|
||||
/// How long the [`ReplayPlaybackState::Completed`] state lingers before
|
||||
/// the auto-clear system transitions it back to
|
||||
/// [`ReplayPlaybackState::Inactive`]. Gives the overlay UI time to
|
||||
/// display "Replay complete" before dismissing.
|
||||
pub const REPLAY_COMPLETION_LINGER_SECS: f32 = 5.0;
|
||||
|
||||
/// Lifecycle state of an in-flight replay playback.
|
||||
///
|
||||
/// The default state is [`Inactive`](Self::Inactive) — no replay is
|
||||
/// running. The overlay (and any other consumer) reads this resource to
|
||||
/// decide whether the "Replay" banner should be visible and what
|
||||
/// progress to display.
|
||||
///
|
||||
/// Lifecycle:
|
||||
/// 1. Default state is [`Inactive`](Self::Inactive).
|
||||
/// 2. [`start_replay_playback`] transitions to
|
||||
/// [`Playing`](Self::Playing) and resets the live `GameState` to the
|
||||
/// replay's recorded deal.
|
||||
/// 3. The tick system [`tick_replay_playback`] advances `cursor` once
|
||||
/// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event
|
||||
/// for each [`ReplayMove`].
|
||||
/// 4. When `cursor == replay.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
|
||||
/// [`Inactive`](Self::Inactive).
|
||||
/// 5. [`stop_replay_playback`] interrupts at any time and forces the
|
||||
/// state back to [`Inactive`](Self::Inactive).
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub enum ReplayPlaybackState {
|
||||
/// No replay is being played back. The overlay despawns itself when
|
||||
/// the resource transitions back to this variant.
|
||||
#[default]
|
||||
Inactive,
|
||||
/// A replay is currently being played back. The overlay reads
|
||||
/// `replay.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()]`.
|
||||
cursor: usize,
|
||||
/// Seconds remaining until the next move is dispatched.
|
||||
secs_to_next: f32,
|
||||
},
|
||||
/// The replay finished playing back. The overlay swaps the banner
|
||||
/// label to "Replay complete" until [`auto_clear_completed_replay`]
|
||||
/// transitions back to [`Inactive`](Self::Inactive) a few seconds
|
||||
/// later.
|
||||
Completed,
|
||||
}
|
||||
|
||||
impl ReplayPlaybackState {
|
||||
/// Returns `true` when a replay is currently being played back.
|
||||
pub fn is_playing(&self) -> bool {
|
||||
matches!(self, Self::Playing { .. })
|
||||
}
|
||||
|
||||
/// Returns `true` when the replay has finished but the resource has
|
||||
/// not yet been auto-cleared back to [`Self::Inactive`].
|
||||
pub fn is_completed(&self) -> bool {
|
||||
matches!(self, Self::Completed)
|
||||
}
|
||||
|
||||
/// Returns `(cursor, total)` when a replay is in progress so the
|
||||
/// overlay can render `"Move N of M"`. Returns `None` while
|
||||
/// [`Inactive`](Self::Inactive) or [`Completed`](Self::Completed) —
|
||||
/// the replay is consumed when transitioning out of `Playing`, so
|
||||
/// 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::Inactive | Self::Completed => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Public entry point — call from the Stats "Watch replay" button
|
||||
/// handler.
|
||||
///
|
||||
/// Resets the live [`GameStateResource`] to a fresh deal seeded from
|
||||
/// `replay.seed` / `replay.draw_mode` / `replay.mode` (via
|
||||
/// [`Commands::insert_resource`]), then transitions the state machine
|
||||
/// to [`ReplayPlaybackState::Playing`] with `cursor: 0` and
|
||||
/// `secs_to_next: REPLAY_MOVE_INTERVAL_SECS`.
|
||||
///
|
||||
/// `commands` is used to overwrite [`GameStateResource`] in a deferred
|
||||
/// flush — equivalent to what `handle_new_game` does, minus the
|
||||
/// [`crate::events::NewGameRequestEvent`] round-trip and the
|
||||
/// abandon-current-game confirmation modal (which would block playback
|
||||
/// indefinitely). Using `Commands` rather than [`crate::events::NewGameRequestEvent`]
|
||||
/// also sidesteps the fact that `NewGameRequestEvent` has no
|
||||
/// `draw_mode_override` field — `handle_new_game` always reads
|
||||
/// `draw_mode` from `Settings`, which would silently coerce a Draw-1
|
||||
/// replay into a Draw-3 game (or vice versa) when the player's
|
||||
/// settings disagree with the recording.
|
||||
///
|
||||
/// Safe to call from any state — if a replay is already playing it is
|
||||
/// dropped and the new one starts immediately.
|
||||
pub fn start_replay_playback(
|
||||
commands: &mut Commands,
|
||||
state: &mut ResMut<ReplayPlaybackState>,
|
||||
replay: Replay,
|
||||
) {
|
||||
use solitaire_core::game_state::GameState;
|
||||
|
||||
let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode.clone(), replay.mode);
|
||||
commands.insert_resource(GameStateResource(fresh));
|
||||
|
||||
// Initial `secs_to_next` uses the constant rather than reading
|
||||
// `SettingsResource` because this entry point takes `Commands` /
|
||||
// `ResMut<ReplayPlaybackState>` only. The first-tick latency may
|
||||
// therefore lag the configured interval by up to ~0.45 s on an
|
||||
// unusually short setting; subsequent ticks read the live setting
|
||||
// every frame via [`tick_replay_playback`].
|
||||
**state = ReplayPlaybackState::Playing {
|
||||
replay,
|
||||
cursor: 0,
|
||||
secs_to_next: REPLAY_MOVE_INTERVAL_SECS,
|
||||
};
|
||||
}
|
||||
|
||||
/// Aborts an in-flight replay playback and resets
|
||||
/// [`ReplayPlaybackState`] back to [`ReplayPlaybackState::Inactive`].
|
||||
///
|
||||
/// Safe to call from any state — when already
|
||||
/// [`ReplayPlaybackState::Inactive`] it simply re-asserts inactivity.
|
||||
///
|
||||
/// The current [`GameStateResource`] is left as-is: the player sees the
|
||||
/// replay's most-recently-applied state until they start a fresh game
|
||||
/// manually. This avoids forcing an extra deal animation in their face
|
||||
/// the moment they cancel.
|
||||
///
|
||||
/// `commands` is currently unused but accepted to match the
|
||||
/// [`start_replay_playback`] signature — leaves room to hook in
|
||||
/// cleanup (e.g. despawning playback-only overlays) without a future
|
||||
/// API break.
|
||||
pub fn stop_replay_playback(
|
||||
_commands: &mut Commands,
|
||||
state: &mut ResMut<ReplayPlaybackState>,
|
||||
) {
|
||||
**state = ReplayPlaybackState::Inactive;
|
||||
}
|
||||
|
||||
/// Tick system. Runs every frame; only does work when
|
||||
/// [`ReplayPlaybackState::is_playing`].
|
||||
///
|
||||
/// 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
|
||||
/// [`ReplayPlaybackState::Completed`].
|
||||
///
|
||||
/// The advance loop is a `while`, not an `if`, so coarse time steps
|
||||
/// (e.g. test-driven 200 ms ticks against a 450 ms interval) still
|
||||
/// fire the right number of events — accumulated debt is paid off
|
||||
/// across as many advances as needed in the same frame. In normal
|
||||
/// gameplay frame deltas are well below `REPLAY_MOVE_INTERVAL_SECS`,
|
||||
/// so the loop runs at most once per frame.
|
||||
fn tick_replay_playback(
|
||||
time: Res<Time>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
mut state: ResMut<ReplayPlaybackState>,
|
||||
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
||||
mut draws_writer: MessageWriter<DrawRequestEvent>,
|
||||
) {
|
||||
let dt = time.delta_secs();
|
||||
let interval = current_move_interval_secs(settings.as_deref());
|
||||
let mut transition_to_completed = false;
|
||||
|
||||
if let ReplayPlaybackState::Playing {
|
||||
replay,
|
||||
cursor,
|
||||
secs_to_next,
|
||||
} = state.as_mut()
|
||||
{
|
||||
*secs_to_next -= dt;
|
||||
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
|
||||
match &replay.moves[*cursor] {
|
||||
ReplayMove::Move { from, to, count } => {
|
||||
moves_writer.write(MoveRequestEvent {
|
||||
from: from.clone(),
|
||||
to: to.clone(),
|
||||
count: *count,
|
||||
});
|
||||
}
|
||||
ReplayMove::StockClick => {
|
||||
draws_writer.write(DrawRequestEvent);
|
||||
}
|
||||
}
|
||||
*cursor += 1;
|
||||
*secs_to_next += interval;
|
||||
}
|
||||
|
||||
if *cursor >= replay.moves.len() {
|
||||
transition_to_completed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if transition_to_completed {
|
||||
*state = ReplayPlaybackState::Completed;
|
||||
}
|
||||
}
|
||||
|
||||
/// Local timer for the [`ReplayPlaybackState::Completed`] linger.
|
||||
/// Resets to zero whenever the state transitions out of
|
||||
/// [`ReplayPlaybackState::Completed`].
|
||||
#[derive(Default)]
|
||||
struct CompletionLinger(f32);
|
||||
|
||||
/// Auto-clear system. While [`ReplayPlaybackState::Completed`],
|
||||
/// accumulates time and transitions back to
|
||||
/// [`ReplayPlaybackState::Inactive`] once
|
||||
/// [`REPLAY_COMPLETION_LINGER_SECS`] has elapsed.
|
||||
fn auto_clear_completed_replay(
|
||||
time: Res<Time>,
|
||||
mut state: ResMut<ReplayPlaybackState>,
|
||||
mut linger: Local<CompletionLinger>,
|
||||
) {
|
||||
if state.is_completed() {
|
||||
linger.0 += time.delta_secs();
|
||||
if linger.0 >= REPLAY_COMPLETION_LINGER_SECS {
|
||||
*state = ReplayPlaybackState::Inactive;
|
||||
linger.0 = 0.0;
|
||||
}
|
||||
} else {
|
||||
// Reset whenever we're not in Completed so the next completion
|
||||
// measures from zero rather than accumulating across cycles.
|
||||
linger.0 = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Local cache of the recording buffer's length at the start of
|
||||
/// playback. Lets us roll back any growth during playback without
|
||||
/// touching `game_plugin`'s recording call sites.
|
||||
#[derive(Default)]
|
||||
struct RecordingSnapshot {
|
||||
/// `Some(len)` while playback is active. The recording is
|
||||
/// truncated back to this length every frame so playback-driven
|
||||
/// events leak no entries into the recorded move list. `None`
|
||||
/// when not playing — recording behaves normally.
|
||||
snapshot_len: Option<usize>,
|
||||
}
|
||||
|
||||
/// Recording-pause system. While [`ReplayPlaybackState::is_playing`],
|
||||
/// snapshots the recording's length on entry and truncates the
|
||||
/// recording back to that length every frame. This keeps the live
|
||||
/// [`RecordingReplay`] opaque to `game_plugin`'s `handle_move` /
|
||||
/// `handle_draw` — those still push unconditionally; we just wipe the
|
||||
/// playback-driven entries before any other system can read them.
|
||||
///
|
||||
/// Implemented this way because [`RecordingReplay`] is mutated inside
|
||||
/// the [`GameMutation`] system set (the schedule set that owns
|
||||
/// `handle_move` / `handle_draw`). We schedule this system
|
||||
/// `.after(GameMutation)` so the truncation runs each frame *after*
|
||||
/// the unconditional push, removing the same entry the playback tick
|
||||
/// caused.
|
||||
fn record_replay_skip_during_playback(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
mut recording: ResMut<RecordingReplay>,
|
||||
mut snap: Local<RecordingSnapshot>,
|
||||
) {
|
||||
// Treat `Playing` and `Completed` identically for the purpose of
|
||||
// recording suppression. The tick system's final advance fires
|
||||
// its event in the same frame it transitions to `Completed`; the
|
||||
// event is then consumed by `handle_move` / `handle_draw` either
|
||||
// this frame (race-dependent on system order) or the next. By
|
||||
// suppressing recording growth across both states, we close that
|
||||
// window cleanly: the snapshot survives until the resource is
|
||||
// back to `Inactive` (auto-cleared after
|
||||
// `REPLAY_COMPLETION_LINGER_SECS`).
|
||||
if state.is_playing() || state.is_completed() {
|
||||
let baseline = match snap.snapshot_len {
|
||||
Some(n) => n,
|
||||
None => {
|
||||
let n = recording.moves.len();
|
||||
snap.snapshot_len = Some(n);
|
||||
n
|
||||
}
|
||||
};
|
||||
if recording.moves.len() > baseline {
|
||||
recording.moves.truncate(baseline);
|
||||
}
|
||||
} else {
|
||||
// Drop the snapshot when neither playing nor completed so
|
||||
// the next playback cycle re-anchors to whatever the
|
||||
// recording is at that point.
|
||||
snap.snapshot_len = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// On-completion side effect: fire a single [`StateChangedEvent`] when
|
||||
/// playback transitions from `Playing` to `Completed` so any UI that
|
||||
/// listens for state mutations refreshes one final time. Cheap and
|
||||
/// idempotent — `StateChangedEvent` is a one-shot signal.
|
||||
fn fire_state_changed_on_completion(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
mut last_was_completed: Local<bool>,
|
||||
mut writer: MessageWriter<StateChangedEvent>,
|
||||
) {
|
||||
let now_completed = state.is_completed();
|
||||
if now_completed && !*last_was_completed {
|
||||
writer.write(StateChangedEvent);
|
||||
}
|
||||
*last_was_completed = now_completed;
|
||||
}
|
||||
|
||||
/// Bevy plugin that initialises [`ReplayPlaybackState`] and drives
|
||||
/// playback ticks, completion linger, and the recording-pause guard.
|
||||
///
|
||||
/// Register this in the main app alongside [`crate::game_plugin::GamePlugin`].
|
||||
/// Tests can install it under [`MinimalPlugins`] to exercise the public
|
||||
/// API without spinning up the full client.
|
||||
pub struct ReplayPlaybackPlugin;
|
||||
|
||||
impl Plugin for ReplayPlaybackPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<ReplayPlaybackState>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
tick_replay_playback,
|
||||
auto_clear_completed_replay,
|
||||
fire_state_changed_on_completion,
|
||||
)
|
||||
.chain(),
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
record_replay_skip_during_playback.after(GameMutation),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use bevy::time::TimeUpdateStrategy;
|
||||
use chrono::NaiveDate;
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_core::pile::PileType;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
|
||||
/// `ReplayPlaybackPlugin`. `GamePlugin` brings the canonical
|
||||
/// `MoveRequestEvent` / `DrawRequestEvent` registrations along with
|
||||
/// `RecordingReplay` so the recording-pause test can read it.
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(GamePlugin::headless())
|
||||
.add_plugins(ReplayPlaybackPlugin);
|
||||
// Disable game-state persistence so tests don't touch the
|
||||
// real ~/.local/share/solitaire_quest/game_state.json.
|
||||
app.insert_resource(crate::game_plugin::GameStatePath(None));
|
||||
app.insert_resource(crate::game_plugin::ReplayPath(None));
|
||||
// Tick once so any startup systems flush before the first
|
||||
// assertion.
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
/// `Time<Virtual>` clamps each tick to `max_delta` (default 250 ms),
|
||||
/// so we drive 200 ms steps and call `update` enough times to pass
|
||||
/// the requested duration.
|
||||
fn advance_by(app: &mut App, total_secs: f32) {
|
||||
app.insert_resource(TimeUpdateStrategy::ManualDuration(
|
||||
Duration::from_secs_f32(0.2),
|
||||
));
|
||||
let ticks = (total_secs / 0.2).ceil() as usize + 1;
|
||||
for _ in 0..ticks {
|
||||
app.update();
|
||||
}
|
||||
}
|
||||
|
||||
/// A 3-move replay covering both `Move` and `StockClick` variants.
|
||||
/// Seed 12345 is arbitrary — the test asserts on event counts and
|
||||
/// move shapes, not on board positions.
|
||||
fn sample_replay_three_moves() -> Replay {
|
||||
Replay::new(
|
||||
12345,
|
||||
DrawMode::DrawOne,
|
||||
GameMode::Classic,
|
||||
60,
|
||||
500,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![
|
||||
ReplayMove::StockClick,
|
||||
ReplayMove::Move {
|
||||
from: PileType::Waste,
|
||||
to: PileType::Tableau(3),
|
||||
count: 1,
|
||||
},
|
||||
ReplayMove::StockClick,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// Scoped helper to invoke `start_replay_playback` from within the
|
||||
/// app's `World` (the public API takes `Commands`, which only
|
||||
/// exists inside systems). We use a one-shot system to obtain the
|
||||
/// `Commands`.
|
||||
fn start_playback(app: &mut App, replay: Replay) {
|
||||
#[derive(Resource)]
|
||||
struct ReplayInbox(Option<Replay>);
|
||||
app.insert_resource(ReplayInbox(Some(replay)));
|
||||
|
||||
fn run(
|
||||
mut commands: Commands,
|
||||
mut state: ResMut<ReplayPlaybackState>,
|
||||
mut inbox: ResMut<ReplayInbox>,
|
||||
) {
|
||||
if let Some(replay) = inbox.0.take() {
|
||||
start_replay_playback(&mut commands, &mut state, replay);
|
||||
}
|
||||
}
|
||||
let id = app.world_mut().register_system(run);
|
||||
app.world_mut()
|
||||
.run_system(id)
|
||||
.expect("one-shot start_playback");
|
||||
}
|
||||
|
||||
fn stop_playback(app: &mut App) {
|
||||
fn run(mut commands: Commands, mut state: ResMut<ReplayPlaybackState>) {
|
||||
stop_replay_playback(&mut commands, &mut state);
|
||||
}
|
||||
let id = app.world_mut().register_system(run);
|
||||
app.world_mut()
|
||||
.run_system(id)
|
||||
.expect("one-shot stop_playback");
|
||||
}
|
||||
|
||||
/// Fresh state must be `Inactive`. After `start_replay_playback`
|
||||
/// the state must be `Playing { cursor: 0, .. }` carrying the
|
||||
/// supplied replay.
|
||||
#[test]
|
||||
fn start_replay_playback_transitions_inactive_to_playing() {
|
||||
let mut app = headless_app();
|
||||
assert!(matches!(
|
||||
*app.world().resource::<ReplayPlaybackState>(),
|
||||
ReplayPlaybackState::Inactive
|
||||
));
|
||||
|
||||
let replay = sample_replay_three_moves();
|
||||
start_playback(&mut app, replay.clone());
|
||||
// Apply the deferred Commands flush.
|
||||
app.update();
|
||||
|
||||
let state = app.world().resource::<ReplayPlaybackState>();
|
||||
match state {
|
||||
ReplayPlaybackState::Playing {
|
||||
cursor,
|
||||
replay: r,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(*cursor, 0);
|
||||
assert_eq!(r.seed, replay.seed);
|
||||
assert_eq!(r.moves.len(), 3);
|
||||
}
|
||||
other => panic!("expected Playing, got {other:?}"),
|
||||
}
|
||||
assert_eq!(state.progress(), Some((0, 3)));
|
||||
}
|
||||
|
||||
/// One full interval (plus a small margin to clear the boundary)
|
||||
/// must advance the cursor by at least one.
|
||||
#[test]
|
||||
fn tick_advances_cursor_after_interval() {
|
||||
let mut app = headless_app();
|
||||
start_playback(&mut app, sample_replay_three_moves());
|
||||
app.update();
|
||||
|
||||
// Drive virtual time forward by one interval.
|
||||
advance_by(&mut app, REPLAY_MOVE_INTERVAL_SECS + 0.05);
|
||||
|
||||
let state = app.world().resource::<ReplayPlaybackState>();
|
||||
match state {
|
||||
ReplayPlaybackState::Playing { cursor, .. } => {
|
||||
assert!(
|
||||
*cursor >= 1,
|
||||
"expected cursor advanced past one move, got {cursor}",
|
||||
);
|
||||
}
|
||||
other => panic!("expected Playing, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Driving past `n * REPLAY_MOVE_INTERVAL_SECS` must produce
|
||||
/// `n` events that match the recorded move kinds. We register a
|
||||
/// pair of accumulator systems that drain `MoveRequestEvent` /
|
||||
/// `DrawRequestEvent` into resources every frame — using a
|
||||
/// detached cursor across many `app.update()` calls is unreliable
|
||||
/// because Bevy's `Messages` double-buffer drops events older
|
||||
/// than two frames.
|
||||
#[test]
|
||||
fn tick_fires_canonical_event_for_each_move() {
|
||||
#[derive(Resource, Default)]
|
||||
struct CapturedMoves(Vec<MoveRequestEvent>);
|
||||
#[derive(Resource, Default)]
|
||||
struct CapturedDraws(usize);
|
||||
|
||||
fn collect_moves(
|
||||
mut events: MessageReader<MoveRequestEvent>,
|
||||
mut sink: ResMut<CapturedMoves>,
|
||||
) {
|
||||
for ev in events.read() {
|
||||
sink.0.push(ev.clone());
|
||||
}
|
||||
}
|
||||
fn collect_draws(
|
||||
mut events: MessageReader<DrawRequestEvent>,
|
||||
mut sink: ResMut<CapturedDraws>,
|
||||
) {
|
||||
for _ in events.read() {
|
||||
sink.0 += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut app = headless_app();
|
||||
app.init_resource::<CapturedMoves>()
|
||||
.init_resource::<CapturedDraws>()
|
||||
.add_systems(Update, (collect_moves, collect_draws));
|
||||
|
||||
start_playback(&mut app, sample_replay_three_moves());
|
||||
app.update();
|
||||
|
||||
// Drive through 3 intervals. Add a small margin to ensure the
|
||||
// last firing isn't sitting exactly on the boundary.
|
||||
advance_by(&mut app, REPLAY_MOVE_INTERVAL_SECS * 3.0 + 0.1);
|
||||
|
||||
let captured_moves = app.world().resource::<CapturedMoves>();
|
||||
let captured_draws = app.world().resource::<CapturedDraws>();
|
||||
|
||||
// Sample replay: StockClick, Move { Waste -> Tableau(3), 1 }, StockClick.
|
||||
assert_eq!(
|
||||
captured_draws.0, 2,
|
||||
"expected 2 DrawRequestEvent (two StockClicks)",
|
||||
);
|
||||
assert_eq!(
|
||||
captured_moves.0.len(),
|
||||
1,
|
||||
"expected 1 MoveRequestEvent (the single Move variant)",
|
||||
);
|
||||
let m = &captured_moves.0[0];
|
||||
assert!(matches!(m.from, PileType::Waste));
|
||||
assert!(matches!(m.to, PileType::Tableau(3)));
|
||||
assert_eq!(m.count, 1);
|
||||
}
|
||||
|
||||
/// Driving past one interval on a single-move replay must
|
||||
/// transition to `Completed`.
|
||||
#[test]
|
||||
fn playback_completes_when_cursor_reaches_end() {
|
||||
let mut app = headless_app();
|
||||
let one_move = Replay::new(
|
||||
42,
|
||||
DrawMode::DrawOne,
|
||||
GameMode::Classic,
|
||||
10,
|
||||
100,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![ReplayMove::StockClick],
|
||||
);
|
||||
start_playback(&mut app, one_move);
|
||||
app.update();
|
||||
|
||||
advance_by(&mut app, REPLAY_MOVE_INTERVAL_SECS + 0.1);
|
||||
|
||||
let state = app.world().resource::<ReplayPlaybackState>();
|
||||
assert!(
|
||||
state.is_completed(),
|
||||
"expected Completed after consuming the only move, got {state:?}",
|
||||
);
|
||||
}
|
||||
|
||||
/// `stop_replay_playback` must force the state back to `Inactive`
|
||||
/// even mid-playback.
|
||||
#[test]
|
||||
fn stop_replay_playback_returns_to_inactive() {
|
||||
let mut app = headless_app();
|
||||
start_playback(&mut app, sample_replay_three_moves());
|
||||
app.update();
|
||||
// Tick once so the state is well and truly `Playing`.
|
||||
advance_by(&mut app, 0.1);
|
||||
assert!(app.world().resource::<ReplayPlaybackState>().is_playing());
|
||||
|
||||
stop_playback(&mut app);
|
||||
app.update();
|
||||
|
||||
assert!(matches!(
|
||||
*app.world().resource::<ReplayPlaybackState>(),
|
||||
ReplayPlaybackState::Inactive
|
||||
));
|
||||
}
|
||||
|
||||
/// Recording must remain frozen during playback. Pre-populate the
|
||||
/// recording with one entry, start playback, and assert the
|
||||
/// recording's move list is unchanged after several ticks.
|
||||
#[test]
|
||||
fn recording_paused_during_playback() {
|
||||
let mut app = headless_app();
|
||||
// Pre-populate the recording with one entry that should
|
||||
// survive playback unchanged. Mirrors the situation where the
|
||||
// player partway through a game opens stats and clicks Watch
|
||||
// Replay — their in-flight recording must not get clobbered.
|
||||
{
|
||||
let mut rec = app.world_mut().resource_mut::<RecordingReplay>();
|
||||
rec.moves.push(ReplayMove::StockClick);
|
||||
}
|
||||
start_playback(&mut app, sample_replay_three_moves());
|
||||
app.update();
|
||||
|
||||
let baseline_len = app.world().resource::<RecordingReplay>().moves.len();
|
||||
assert_eq!(
|
||||
baseline_len, 1,
|
||||
"preconditions: recording starts with one entry",
|
||||
);
|
||||
|
||||
// Drive playback through every move in the replay. Each move
|
||||
// would normally append to `RecordingReplay`; the pause
|
||||
// system must clamp the recording back to `baseline_len` on
|
||||
// every frame.
|
||||
advance_by(&mut app, REPLAY_MOVE_INTERVAL_SECS * 4.0 + 0.1);
|
||||
|
||||
let after_len = app.world().resource::<RecordingReplay>().moves.len();
|
||||
assert_eq!(
|
||||
after_len, baseline_len,
|
||||
"recording must not grow while playback is active",
|
||||
);
|
||||
}
|
||||
|
||||
/// With `SettingsResource::replay_move_interval_secs` set to 0.10 s
|
||||
/// (well below the 0.45 s default), playback over a fixed
|
||||
/// wall-clock window must dispatch strictly more moves than the
|
||||
/// same fixture would at the 0.45 s default. This is the
|
||||
/// regression check that the tick reads from the live Settings
|
||||
/// value rather than the hardcoded
|
||||
/// [`REPLAY_MOVE_INTERVAL_SECS`] constant.
|
||||
///
|
||||
/// The follow-up assertion exercises the boundary condition: at
|
||||
/// the 0.10 s/move setting, exactly six 0.10 s ticks must yield
|
||||
/// fewer moves than six 0.20 s ticks (because the latter doubles
|
||||
/// the per-update advance and pays off two intervals each tick).
|
||||
#[test]
|
||||
fn replay_playback_tick_uses_settings_interval() {
|
||||
use solitaire_data::Settings;
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
struct CapturedDraws(usize);
|
||||
|
||||
fn collect_draws(
|
||||
mut events: MessageReader<DrawRequestEvent>,
|
||||
mut sink: ResMut<CapturedDraws>,
|
||||
) {
|
||||
for _ in events.read() {
|
||||
sink.0 += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Long replay so the fast cadence has plenty of moves to
|
||||
// chew through and the 0.45 s vs 0.10 s difference is easy
|
||||
// to observe.
|
||||
fn ten_draws_replay() -> Replay {
|
||||
Replay::new(
|
||||
7,
|
||||
DrawMode::DrawOne,
|
||||
GameMode::Classic,
|
||||
10,
|
||||
100,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![ReplayMove::StockClick; 10],
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Run 1: 0.10 s/move (Settings override) ----
|
||||
let mut fast_app = headless_app();
|
||||
fast_app.insert_resource(SettingsResource(Settings {
|
||||
replay_move_interval_secs: 0.10,
|
||||
..Settings::default()
|
||||
}));
|
||||
fast_app
|
||||
.init_resource::<CapturedDraws>()
|
||||
.add_systems(Update, collect_draws);
|
||||
|
||||
start_playback(&mut fast_app, ten_draws_replay());
|
||||
fast_app.update();
|
||||
// 1.0 s of virtual time at 0.10 s/move dispatches ~5 moves
|
||||
// after the default 0.45 s startup interval is consumed.
|
||||
advance_by(&mut fast_app, 1.0);
|
||||
let fast_count = fast_app.world().resource::<CapturedDraws>().0;
|
||||
|
||||
// ---- Run 2: 0.45 s/move (default — no SettingsResource) ----
|
||||
let mut slow_app = headless_app();
|
||||
// `tick_replay_playback` falls back to `REPLAY_MOVE_INTERVAL_SECS`
|
||||
// (0.45 s) when `SettingsResource` is absent.
|
||||
slow_app
|
||||
.init_resource::<CapturedDraws>()
|
||||
.add_systems(Update, collect_draws);
|
||||
|
||||
start_playback(&mut slow_app, ten_draws_replay());
|
||||
slow_app.update();
|
||||
advance_by(&mut slow_app, 1.0);
|
||||
let slow_count = slow_app.world().resource::<CapturedDraws>().0;
|
||||
|
||||
assert!(
|
||||
fast_count > slow_count,
|
||||
"at 0.10 s/move the tick must dispatch strictly more moves \
|
||||
than at the 0.45 s default over the same wall-clock window: \
|
||||
fast={fast_count}, slow={slow_count}",
|
||||
);
|
||||
|
||||
// ---- Boundary: a 0.05 s/tick cadence over the same window
|
||||
// dispatches NO MORE moves than a 0.10 s/tick cadence, because
|
||||
// 0.05 s < 0.10 s configured interval — the secs_to_next clock
|
||||
// never crosses the threshold inside a single tick. ----
|
||||
//
|
||||
// We don't assert "exactly zero" because the leading update()
|
||||
// after `start_playback` may run before the strategy is
|
||||
// applied (cf. comments on `tick_advances_cursor_after_interval`),
|
||||
// but the count must not exceed what we'd get with one-tick
|
||||
// advances at the same total wall-clock window.
|
||||
fn count_after_window(interval_secs: f32, tick_secs: f32, total_secs: f32) -> usize {
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(SettingsResource(Settings {
|
||||
replay_move_interval_secs: interval_secs,
|
||||
..Settings::default()
|
||||
}));
|
||||
app.init_resource::<CapturedDraws>()
|
||||
.add_systems(Update, collect_draws);
|
||||
start_playback(&mut app, ten_draws_replay());
|
||||
app.update();
|
||||
app.insert_resource(TimeUpdateStrategy::ManualDuration(
|
||||
Duration::from_secs_f32(tick_secs),
|
||||
));
|
||||
let ticks = (total_secs / tick_secs).ceil() as usize + 1;
|
||||
for _ in 0..ticks {
|
||||
app.update();
|
||||
}
|
||||
app.world().resource::<CapturedDraws>().0
|
||||
}
|
||||
|
||||
let count_at_05 = count_after_window(0.10, 0.05, 1.0);
|
||||
let count_at_20 = count_after_window(0.10, 0.20, 1.0);
|
||||
assert!(
|
||||
count_at_05 <= count_at_20,
|
||||
"0.05 s ticks (strictly less than the 0.10 s interval) must \
|
||||
dispatch no more moves than 0.20 s ticks over the same \
|
||||
wall-clock window: count_at_05={count_at_05}, count_at_20={count_at_20}",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,8 @@ use bevy::window::{WindowMoved, WindowResized};
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_data::{
|
||||
load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings,
|
||||
WindowGeometry, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_STEP_SECS,
|
||||
WindowGeometry, REPLAY_MOVE_INTERVAL_STEP_SECS, TIME_BONUS_MULTIPLIER_STEP,
|
||||
TOOLTIP_DELAY_STEP_SECS,
|
||||
};
|
||||
|
||||
use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent};
|
||||
@@ -132,6 +133,17 @@ struct TooltipDelayText;
|
||||
#[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 scrollable inner card so the mouse-wheel system can target it.
|
||||
#[derive(Component, Debug)]
|
||||
struct SettingsPanelScrollable;
|
||||
@@ -174,8 +186,19 @@ enum SettingsButton {
|
||||
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::winnable_deals_only`] flag. When on, new
|
||||
/// random Classic-mode deals are filtered through
|
||||
/// [`solitaire_core::solver::try_solve`] until one is provably
|
||||
/// winnable (or the retry cap is hit). Off by default.
|
||||
ToggleWinnableDealsOnly,
|
||||
SyncNow,
|
||||
Done,
|
||||
/// Select a specific card-back by index from the picker row.
|
||||
@@ -203,13 +226,18 @@ impl SettingsButton {
|
||||
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,
|
||||
// Cosmetic section
|
||||
SettingsButton::ToggleTheme => 50,
|
||||
SettingsButton::ToggleTheme => 55,
|
||||
SettingsButton::ToggleColorBlind => 60,
|
||||
// Picker rows — every swatch in a row shares the row's
|
||||
// priority so entity-index tiebreaking yields left → right.
|
||||
@@ -299,6 +327,8 @@ impl Plugin for SettingsPlugin {
|
||||
update_color_blind_text,
|
||||
update_tooltip_delay_text,
|
||||
update_time_bonus_multiplier_text,
|
||||
update_replay_move_interval_text,
|
||||
update_winnable_deals_only_text,
|
||||
attach_focusable_to_settings_buttons,
|
||||
scroll_focus_into_view,
|
||||
),
|
||||
@@ -549,6 +579,21 @@ fn update_color_blind_text(
|
||||
}
|
||||
}
|
||||
|
||||
/// Refreshes the live "Winnable deals only" toggle value in the
|
||||
/// Gameplay section whenever `SettingsResource` changes (button click,
|
||||
/// hand-edited `settings.json` reload, etc.).
|
||||
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 tooltip-delay value in the Gameplay section
|
||||
/// whenever `SettingsResource` changes (slider buttons, hand-edited
|
||||
/// settings.json reload, etc.).
|
||||
@@ -578,6 +623,21 @@ fn update_time_bonus_multiplier_text(
|
||||
}
|
||||
}
|
||||
|
||||
/// Refreshes the live replay-playback per-move-interval value in the
|
||||
/// Gameplay section whenever `SettingsResource` changes (slider buttons,
|
||||
/// hand-edited settings.json reload, etc.).
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
fn card_back_label(idx: usize) -> String {
|
||||
if idx == 0 {
|
||||
"Default".to_string()
|
||||
@@ -738,6 +798,29 @@ fn handle_settings_buttons(
|
||||
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,
|
||||
@@ -758,6 +841,13 @@ fn handle_settings_buttons(
|
||||
**t = color_blind_label(settings.0.color_blind_mode);
|
||||
}
|
||||
}
|
||||
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::SelectCardBack(idx) => {
|
||||
settings.0.selected_card_back = *idx;
|
||||
persist(&path, &settings.0);
|
||||
@@ -812,6 +902,13 @@ fn color_blind_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.
|
||||
fn winnable_deals_only_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"`).
|
||||
@@ -835,6 +932,14 @@ fn time_bonus_label(value: f32) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn replay_move_interval_label(secs: f32) -> String {
|
||||
format!("{secs:.2} s/move")
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -1158,6 +1263,16 @@ fn spawn_settings_panel(
|
||||
"Switch between Draw 1 and Draw 3. Takes effect next deal.",
|
||||
font_res,
|
||||
);
|
||||
toggle_row(
|
||||
body,
|
||||
"Winnable deals only",
|
||||
WinnableDealsOnlyText,
|
||||
winnable_deals_only_label(settings.winnable_deals_only),
|
||||
SettingsButton::ToggleWinnableDealsOnly,
|
||||
"When on, fresh Classic deals are filtered through a solver \
|
||||
(may take a moment when on).",
|
||||
font_res,
|
||||
);
|
||||
toggle_row(
|
||||
body,
|
||||
"Anim Speed",
|
||||
@@ -1177,6 +1292,11 @@ fn spawn_settings_panel(
|
||||
settings.time_bonus_multiplier,
|
||||
font_res,
|
||||
);
|
||||
replay_move_interval_row(
|
||||
body,
|
||||
settings.replay_move_interval_secs,
|
||||
font_res,
|
||||
);
|
||||
|
||||
// --- Cosmetic ---
|
||||
section_label(body, "Cosmetic", font_res);
|
||||
@@ -1411,6 +1531,56 @@ fn time_bonus_multiplier_row(
|
||||
});
|
||||
}
|
||||
|
||||
/// `Replay speed 0.45 s/move [−] [+]` — slider row for the
|
||||
/// player-tunable replay-playback per-move interval. Mirrors
|
||||
/// [`tooltip_delay_row`] (label, current value, decrement, increment)
|
||||
/// but formats the value via [`replay_move_interval_label`] as
|
||||
/// `"{n:.2} s/move"`. The decrement button speeds playback up
|
||||
/// (smaller interval); the increment slows it down — same direction
|
||||
/// convention as the tooltip-delay slider.
|
||||
fn replay_move_interval_row(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
value_secs: f32,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let label_font = label_text_font(font_res);
|
||||
let value_font = value_text_font(font_res);
|
||||
parent
|
||||
.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_2,
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
row.spawn((
|
||||
Text::new("Replay speed".to_string()),
|
||||
label_font,
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
row.spawn((
|
||||
ReplayMoveIntervalText,
|
||||
Text::new(replay_move_interval_label(value_secs)),
|
||||
value_font,
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
icon_button(
|
||||
row,
|
||||
"−",
|
||||
SettingsButton::ReplayMoveIntervalDown,
|
||||
"Speed up replay playback (shorter per-move interval).",
|
||||
font_res,
|
||||
);
|
||||
icon_button(
|
||||
row,
|
||||
"+",
|
||||
SettingsButton::ReplayMoveIntervalUp,
|
||||
"Slow down replay playback (longer per-move interval).",
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// `Label Value [⇄]` — used for cycle/toggle rows (draw mode, theme,
|
||||
/// anim speed, colour-blind).
|
||||
///
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_data::{
|
||||
latest_replay_path, load_latest_replay_from, load_stats_from, save_stats_to, stats_file_path,
|
||||
PlayerProgress, Replay, StatsExt, StatsSnapshot, WEEKLY_GOALS,
|
||||
load_replay_history_from, load_stats_from, replay_history_path, save_stats_to,
|
||||
stats_file_path, PlayerProgress, Replay, ReplayHistory, StatsExt, StatsSnapshot, WEEKLY_GOALS,
|
||||
};
|
||||
|
||||
use crate::auto_complete_plugin::AutoCompleteState;
|
||||
@@ -28,6 +29,7 @@ use crate::resources::GameStateResource;
|
||||
use crate::time_attack_plugin::TimeAttackResource;
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||
ScrimDismissible,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, STATE_WARNING, STREAK_MILESTONES,
|
||||
@@ -58,30 +60,57 @@ pub struct StatsScreen;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct StatsCell;
|
||||
|
||||
/// Resource holding the most recently loaded winning [`Replay`], if any.
|
||||
/// Resource holding the rolling [`ReplayHistory`] of recent winning
|
||||
/// replays.
|
||||
///
|
||||
/// Populated from `<data_dir>/solitaire_quest/latest_replay.json` at
|
||||
/// startup and refreshed in-place whenever the engine writes a new
|
||||
/// winning replay (the path the Stats UI calls into is unchanged so a
|
||||
/// re-open of the modal sees the latest record).
|
||||
/// Populated from `<data_dir>/solitaire_quest/replays.json` at startup
|
||||
/// and refreshed in-place whenever the engine writes a new winning
|
||||
/// replay so the Stats overlay's selector always reflects the current
|
||||
/// on-disk history.
|
||||
///
|
||||
/// The Stats overlay reads this to decide whether to render the
|
||||
/// "Watch replay" call-to-action or the "No replay recorded yet"
|
||||
/// caption.
|
||||
/// `replays[0]` is the most recent win — the Stats overlay's selector
|
||||
/// defaults to that entry and lets the player step backwards through
|
||||
/// up to [`solitaire_data::REPLAY_HISTORY_CAP`] older entries.
|
||||
#[derive(Resource, Debug, Default, Clone)]
|
||||
pub struct LatestReplayResource(pub Option<Replay>);
|
||||
pub struct ReplayHistoryResource(pub ReplayHistory);
|
||||
|
||||
/// Persistence path for the latest winning replay file. `None` disables
|
||||
/// I/O — used by tests and by `StatsPlugin::headless`.
|
||||
/// Currently-selected index into [`ReplayHistoryResource::0`].`replays`.
|
||||
///
|
||||
/// `0` is the most recent win and is the default on every modal open.
|
||||
/// The Prev / Next chips wrap-around within the bounds of the current
|
||||
/// history so the selector is always sat on a valid replay (or on `0`
|
||||
/// when the history is empty — the chips paint disabled in that case).
|
||||
#[derive(Resource, Debug, Default, Clone, Copy)]
|
||||
pub struct SelectedReplayIndex(pub usize);
|
||||
|
||||
/// Persistence path for the rolling replay history file
|
||||
/// (`replays.json`). `None` disables I/O — used by tests and by
|
||||
/// `StatsPlugin::headless`.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct LatestReplayPath(pub Option<PathBuf>);
|
||||
|
||||
/// Marker on the "Watch replay" button inside the Stats modal. Clicking
|
||||
/// it currently fires an [`InfoToastEvent`] indicating playback ships
|
||||
/// in a future build — see [`handle_watch_replay_button`].
|
||||
/// it starts in-engine playback of the selected replay — see
|
||||
/// [`handle_watch_replay_button`].
|
||||
#[derive(Component, Debug)]
|
||||
pub struct WatchReplayButton;
|
||||
|
||||
/// Marker on the selector's "Previous replay" chip — steps the
|
||||
/// selection backwards (toward older replays) within
|
||||
/// [`ReplayHistoryResource`].
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayPrevButton;
|
||||
|
||||
/// Marker on the selector's "Next replay" chip — steps the selection
|
||||
/// forwards (toward more recent replays).
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayNextButton;
|
||||
|
||||
/// Marker on the selector's `"Replay N / M"` caption text node so the
|
||||
/// repaint system can rewrite the label as the selection changes.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplaySelectorCaption;
|
||||
|
||||
/// Marker component on each per-mode bests row in the stats overlay.
|
||||
///
|
||||
/// One row per supported [`solitaire_core::game_state::GameMode`] (Classic,
|
||||
@@ -91,6 +120,18 @@ pub struct WatchReplayButton;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct PerModeBestsRow;
|
||||
|
||||
/// Marker on the scrollable body Node inside the Stats modal.
|
||||
///
|
||||
/// The Stats panel renders an 8-cell primary grid, three per-mode bests
|
||||
/// rows, a five-cell progression grid, weekly goals, an unlocks line,
|
||||
/// optional Time Attack readout, and the latest replay caption — enough
|
||||
/// content to overflow the modal on the 800x600 minimum window. This
|
||||
/// marker tags the inner container that carries `Overflow::scroll_y()`
|
||||
/// plus a `max_height` constraint. Mirrors the `SettingsPanelScrollable`
|
||||
/// pattern.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct StatsScrollable;
|
||||
|
||||
/// Registers stats resources, update systems, and the UI toggle.
|
||||
pub struct StatsPlugin {
|
||||
/// Where to persist stats. `None` disables all file I/O (for tests).
|
||||
@@ -123,14 +164,16 @@ impl Plugin for StatsPlugin {
|
||||
// Replay file lives next to stats.json — when the StatsPlugin
|
||||
// is in headless mode (storage_path = None), we mirror that
|
||||
// policy and disable replay I/O too. Otherwise resolve the
|
||||
// platform-default path via `latest_replay_path()`.
|
||||
let replay_path = self.storage_path.as_ref().and(latest_replay_path());
|
||||
let initial_replay = replay_path
|
||||
// platform-default path via `replay_history_path()`.
|
||||
let replay_path = self.storage_path.as_ref().and(replay_history_path());
|
||||
let initial_history = replay_path
|
||||
.as_deref()
|
||||
.and_then(load_latest_replay_from);
|
||||
.and_then(load_replay_history_from)
|
||||
.unwrap_or_default();
|
||||
app.insert_resource(StatsResource(loaded))
|
||||
.insert_resource(StatsStoragePath(self.storage_path.clone()))
|
||||
.insert_resource(LatestReplayResource(initial_replay))
|
||||
.insert_resource(ReplayHistoryResource(initial_history))
|
||||
.init_resource::<SelectedReplayIndex>()
|
||||
.insert_resource(LatestReplayPath(replay_path))
|
||||
.add_message::<GameWonEvent>()
|
||||
.add_message::<NewGameRequestEvent>()
|
||||
@@ -138,6 +181,10 @@ impl Plugin for StatsPlugin {
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<ToggleStatsRequestEvent>()
|
||||
.add_message::<WinStreakMilestoneEvent>()
|
||||
// `MouseWheel` is emitted by Bevy's input plugin under
|
||||
// `DefaultPlugins`; register it explicitly so the stats-scroll
|
||||
// system also runs cleanly under `MinimalPlugins` in tests.
|
||||
.add_message::<MouseWheel>()
|
||||
// record_abandoned must read `move_count` BEFORE handle_new_game
|
||||
// clobbers it with a fresh game. These are NOT in StatsUpdate because
|
||||
// StatsUpdate (as a set) is ordered after GameMutation by external
|
||||
@@ -160,19 +207,52 @@ impl Plugin for StatsPlugin {
|
||||
.add_systems(Update, handle_stats_close_button)
|
||||
.add_systems(
|
||||
Update,
|
||||
refresh_latest_replay_on_win.after(GameMutation),
|
||||
refresh_replay_history_on_win.after(GameMutation),
|
||||
)
|
||||
.add_systems(Update, handle_watch_replay_button);
|
||||
.add_systems(Update, handle_watch_replay_button)
|
||||
.add_systems(
|
||||
Update,
|
||||
(handle_replay_selector_buttons, repaint_replay_selector_caption).chain(),
|
||||
)
|
||||
.add_systems(Update, scroll_stats_panel);
|
||||
}
|
||||
}
|
||||
|
||||
/// After a win, the engine has just persisted a fresh winning replay.
|
||||
/// Re-load it so the next time the player opens the Stats overlay, the
|
||||
/// "Watch replay" call-to-action reflects the most recent victory
|
||||
/// rather than an older session.
|
||||
fn refresh_latest_replay_on_win(
|
||||
/// Routes mouse-wheel events into the Stats modal's scrollable body
|
||||
/// while the panel is open. No-op when no `StatsScrollable` exists in
|
||||
/// the world (modal closed). Mirrors `scroll_settings_panel`.
|
||||
fn scroll_stats_panel(
|
||||
mut scroll_evr: MessageReader<MouseWheel>,
|
||||
mut scrollables: Query<&mut ScrollPosition, With<StatsScrollable>>,
|
||||
) {
|
||||
if scrollables.is_empty() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// After a win, the engine has just appended a fresh winning replay to
|
||||
/// the rolling history file. Re-load it so the next time the player
|
||||
/// opens the Stats overlay the selector reflects the new entry, and
|
||||
/// reset [`SelectedReplayIndex`] to `0` so the default selection is the
|
||||
/// just-recorded win.
|
||||
fn refresh_replay_history_on_win(
|
||||
mut wins: MessageReader<GameWonEvent>,
|
||||
mut latest: ResMut<LatestReplayResource>,
|
||||
mut history: ResMut<ReplayHistoryResource>,
|
||||
mut selected: ResMut<SelectedReplayIndex>,
|
||||
path: Res<LatestReplayPath>,
|
||||
) {
|
||||
// Only re-load when at least one win actually fired.
|
||||
@@ -182,32 +262,123 @@ fn refresh_latest_replay_on_win(
|
||||
let Some(p) = path.0.as_deref() else {
|
||||
return;
|
||||
};
|
||||
latest.0 = load_latest_replay_from(p);
|
||||
history.0 = load_replay_history_from(p).unwrap_or_default();
|
||||
// Snap the selector back to the most recent win — that's the one
|
||||
// the player just earned.
|
||||
selected.0 = 0;
|
||||
}
|
||||
|
||||
/// Click handler for the "Watch replay" button.
|
||||
///
|
||||
/// Replay playback lives on the sync server's web UI rather than in
|
||||
/// the desktop client. This handler currently surfaces a clear toast
|
||||
/// pointing the player there once the upload + URL is wired; until
|
||||
/// then it acknowledges the click and signals that the feature is on
|
||||
/// the way.
|
||||
/// Starts in-engine replay playback for the currently-selected entry in
|
||||
/// [`ReplayHistoryResource`] (per [`SelectedReplayIndex`]). If the
|
||||
/// history is empty or the selector points past the end (defensive
|
||||
/// guard), surfaces an [`InfoToastEvent`] instead. The playback path
|
||||
/// resets the live game to the recorded deal and ticks through the
|
||||
/// move list via [`crate::replay_playback`]; the
|
||||
/// [`crate::replay_overlay`] banner surfaces while playback runs.
|
||||
fn handle_watch_replay_button(
|
||||
mut commands: Commands,
|
||||
buttons: Query<&Interaction, (With<WatchReplayButton>, Changed<Interaction>)>,
|
||||
latest: Res<LatestReplayResource>,
|
||||
history: Res<ReplayHistoryResource>,
|
||||
selected: Res<SelectedReplayIndex>,
|
||||
playback: Option<ResMut<crate::replay_playback::ReplayPlaybackState>>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||
return;
|
||||
}
|
||||
let message = match &latest.0 {
|
||||
Some(replay) => format!(
|
||||
"Replay ready ({}) \u{2014} web playback coming in a future build",
|
||||
format_replay_caption(replay),
|
||||
),
|
||||
None => "No replay recorded yet \u{2014} win a game first.".to_string(),
|
||||
};
|
||||
toast.write(InfoToastEvent(message));
|
||||
let chosen = history.0.replays.get(selected.0);
|
||||
match (chosen, playback) {
|
||||
(Some(replay), Some(mut playback)) => {
|
||||
crate::replay_playback::start_replay_playback(
|
||||
&mut commands,
|
||||
&mut playback,
|
||||
replay.clone(),
|
||||
);
|
||||
}
|
||||
(Some(replay), None) => {
|
||||
// ReplayPlaybackPlugin not registered (headless test
|
||||
// fixtures); fall back to a descriptive toast.
|
||||
toast.write(InfoToastEvent(format!(
|
||||
"Replay ready ({})",
|
||||
format_replay_caption(replay)
|
||||
)));
|
||||
}
|
||||
(None, _) => {
|
||||
toast.write(InfoToastEvent(
|
||||
"No replay recorded yet \u{2014} win a game first.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Click handler for the Prev / Next chips on the Stats overlay's
|
||||
/// replay selector. Steps [`SelectedReplayIndex`] within the bounds of
|
||||
/// the current [`ReplayHistoryResource`]; selection wraps so the
|
||||
/// chooser is always sat on a valid replay.
|
||||
///
|
||||
/// No-op when the history is empty — the selector chips paint disabled
|
||||
/// in that case but a defensive bounds check here keeps things tidy if
|
||||
/// the click somehow lands.
|
||||
fn handle_replay_selector_buttons(
|
||||
prev: Query<&Interaction, (With<ReplayPrevButton>, Changed<Interaction>)>,
|
||||
next: Query<&Interaction, (With<ReplayNextButton>, Changed<Interaction>)>,
|
||||
history: Res<ReplayHistoryResource>,
|
||||
mut selected: ResMut<SelectedReplayIndex>,
|
||||
) {
|
||||
let len = history.0.replays.len();
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
let prev_pressed = prev.iter().any(|i| *i == Interaction::Pressed);
|
||||
let next_pressed = next.iter().any(|i| *i == Interaction::Pressed);
|
||||
if prev_pressed {
|
||||
// Step toward older replays — wrap to the oldest when at the
|
||||
// newest (index 0).
|
||||
selected.0 = if selected.0 == 0 { len - 1 } else { selected.0 - 1 };
|
||||
}
|
||||
if next_pressed {
|
||||
// Step toward more recent replays — wrap to the newest when at
|
||||
// the oldest.
|
||||
selected.0 = (selected.0 + 1) % len;
|
||||
}
|
||||
}
|
||||
|
||||
/// Live-update the `"Replay N / M"` caption text as the selector
|
||||
/// changes. The caption sits next to the Prev / Next chips above the
|
||||
/// Watch button so the player can see at a glance which replay they're
|
||||
/// about to watch.
|
||||
fn repaint_replay_selector_caption(
|
||||
history: Res<ReplayHistoryResource>,
|
||||
selected: Res<SelectedReplayIndex>,
|
||||
mut q: Query<&mut Text, With<ReplaySelectorCaption>>,
|
||||
) {
|
||||
if !history.is_changed() && !selected.is_changed() {
|
||||
return;
|
||||
}
|
||||
for mut text in &mut q {
|
||||
**text = replay_selector_caption(selected.0, history.0.replays.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure helper: render the selector caption shown next to the Prev /
|
||||
/// Next chips. Returns `"No replays"` when the history is empty,
|
||||
/// otherwise `"Replay {1-based index} / {total}"`.
|
||||
///
|
||||
/// `index` is zero-based as it's stored in [`SelectedReplayIndex`].
|
||||
/// The display flips it to a one-based ordinal so "Replay 1" reads as
|
||||
/// "the most recent win" — matching the mental model the chooser
|
||||
/// surfaces.
|
||||
pub fn replay_selector_caption(index: usize, total: usize) -> String {
|
||||
if total == 0 {
|
||||
return "No replays".to_string();
|
||||
}
|
||||
// Defensive clamp — the caller is supposed to keep `index` in
|
||||
// range, but a stale selector after a cap-driven truncation
|
||||
// shouldn't crash the renderer.
|
||||
let one_based = index.min(total.saturating_sub(1)) + 1;
|
||||
format!("Replay {one_based} / {total}")
|
||||
}
|
||||
|
||||
/// Pure helper: render a one-line caption for a [`Replay`] suitable
|
||||
@@ -359,7 +530,8 @@ fn toggle_stats_screen(
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
time_attack: Option<Res<TimeAttackResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
latest_replay: Res<LatestReplayResource>,
|
||||
latest_replay: Res<ReplayHistoryResource>,
|
||||
selected_index: Res<SelectedReplayIndex>,
|
||||
screens: Query<Entity, With<StatsScreen>>,
|
||||
) {
|
||||
let button_clicked = requests.read().count() > 0;
|
||||
@@ -369,13 +541,14 @@ fn toggle_stats_screen(
|
||||
if let Ok(entity) = screens.single() {
|
||||
commands.entity(entity).despawn();
|
||||
} else {
|
||||
let selected = latest_replay.0.replays.get(selected_index.0);
|
||||
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.as_ref(),
|
||||
selected,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -430,14 +603,33 @@ fn spawn_stats_screen(
|
||||
..default()
|
||||
};
|
||||
|
||||
spawn_modal(commands, StatsScreen, Z_MODAL_PANEL, |card| {
|
||||
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
|
||||
// overflows the modal on the 800x600 minimum window. Wrapping
|
||||
// in an `Overflow::scroll_y()` Node with a constrained
|
||||
// `max_height` keeps every cell reachable; the Watch Replay /
|
||||
// Done action row stays fixed outside the scroll.
|
||||
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| {
|
||||
// First-launch caption — sits above the grid as gentle nudge so
|
||||
// the wall of em-dashes reads as "nothing to track yet" rather
|
||||
// than as broken state.
|
||||
if is_first_launch {
|
||||
card.spawn((
|
||||
body.spawn((
|
||||
Text::new("Play a game to start tracking stats."),
|
||||
TextFont {
|
||||
font_size: TYPE_CAPTION,
|
||||
@@ -455,7 +647,7 @@ fn spawn_stats_screen(
|
||||
}
|
||||
|
||||
// --- primary stat cells grid ---
|
||||
card.spawn(Node {
|
||||
body.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
justify_content: JustifyContent::Center,
|
||||
@@ -481,12 +673,12 @@ fn spawn_stats_screen(
|
||||
// scoring (count of wins inside a 10-minute window) so a per-game
|
||||
// best wouldn't compose; Daily uses Classic scoring and so already
|
||||
// contributes to the Classic row.
|
||||
card.spawn((
|
||||
body.spawn((
|
||||
Text::new("Per-mode bests"),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
card.spawn(Node {
|
||||
body.spawn(Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
width: Val::Percent(100.0),
|
||||
row_gap: VAL_SPACE_2,
|
||||
@@ -518,7 +710,7 @@ fn spawn_stats_screen(
|
||||
|
||||
// --- progression section ---
|
||||
if let Some(p) = progress {
|
||||
card.spawn((
|
||||
body.spawn((
|
||||
Text::new("Progression"),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
@@ -530,7 +722,7 @@ fn spawn_stats_screen(
|
||||
let daily_str = format_stat_value(p.daily_challenge_streak);
|
||||
let challenge_str = challenge_progress_label(p.challenge_index);
|
||||
|
||||
card.spawn(Node {
|
||||
body.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
justify_content: JustifyContent::Center,
|
||||
@@ -549,14 +741,14 @@ fn spawn_stats_screen(
|
||||
});
|
||||
|
||||
// Weekly goals
|
||||
card.spawn((
|
||||
body.spawn((
|
||||
Text::new("Weekly Goals"),
|
||||
font_section.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
for goal in WEEKLY_GOALS {
|
||||
let pv = p.weekly_goal_progress.get(goal.id).copied().unwrap_or(0);
|
||||
card.spawn((
|
||||
body.spawn((
|
||||
Text::new(format!(" {}: {}/{}", goal.description, pv, goal.target)),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
@@ -564,7 +756,7 @@ fn spawn_stats_screen(
|
||||
}
|
||||
|
||||
// Unlocks line
|
||||
card.spawn((
|
||||
body.spawn((
|
||||
Text::new(format!(
|
||||
"Card Backs: {} | Backgrounds: {}",
|
||||
format_id_list(&p.unlocked_card_backs),
|
||||
@@ -580,7 +772,7 @@ fn spawn_stats_screen(
|
||||
&& ta.active {
|
||||
let mins = (ta.remaining_secs / 60.0).floor() as u64;
|
||||
let secs = (ta.remaining_secs % 60.0).floor() as u64;
|
||||
card.spawn((
|
||||
body.spawn((
|
||||
Text::new(format!(
|
||||
"Time Attack \u{2014} {mins}m {secs:02}s left | Wins: {}",
|
||||
ta.wins
|
||||
@@ -598,11 +790,12 @@ fn spawn_stats_screen(
|
||||
Some(r) => format!("Latest win: {}", format_replay_caption(r)),
|
||||
None => "No replay recorded yet \u{2014} win a game first.".to_string(),
|
||||
};
|
||||
card.spawn((
|
||||
body.spawn((
|
||||
Text::new(replay_caption),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
});
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
// The Watch Replay button is always rendered so the
|
||||
@@ -628,6 +821,8 @@ fn spawn_stats_screen(
|
||||
);
|
||||
});
|
||||
});
|
||||
// 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
|
||||
@@ -960,6 +1155,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_modal_body_is_scrollable() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyS);
|
||||
app.update();
|
||||
|
||||
let count = app
|
||||
.world_mut()
|
||||
.query::<&StatsScrollable>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(
|
||||
count, 1,
|
||||
"Stats modal must spawn exactly one StatsScrollable body"
|
||||
);
|
||||
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Node, With<StatsScrollable>>();
|
||||
let nodes: Vec<&Node> = q.iter(app.world()).collect();
|
||||
assert_ne!(
|
||||
nodes[0].max_height,
|
||||
Val::Auto,
|
||||
"scrollable body must set a non-default max_height"
|
||||
);
|
||||
assert_eq!(nodes[0].overflow, Overflow::scroll_y());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_screen_renders_three_per_mode_bests_rows() {
|
||||
// Open the Stats overlay and assert three [`PerModeBestsRow`]
|
||||
|
||||
@@ -121,11 +121,34 @@ impl Plugin for UiFocusPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<FocusedButton>()
|
||||
.add_systems(Startup, spawn_focus_overlay)
|
||||
// Attach + auto-focus run in `PostUpdate` so they see entities
|
||||
// a click-handler in `Update` queued via `Commands` earlier in
|
||||
// the same frame. If they ran in `Update` they'd race the
|
||||
// click handler: there's no ordering edge between an arbitrary
|
||||
// modal-spawning system and the focus chain, so Bevy's
|
||||
// `auto_insert_apply_deferred` pass cannot synchronise them.
|
||||
// Pushing the attach / auto-focus pair into `PostUpdate` puts
|
||||
// the natural schedule-boundary sync point between every
|
||||
// modal spawn and focus arrival — `FocusedButton` is always
|
||||
// populated before the same `app.update()` returns.
|
||||
//
|
||||
// The remaining systems stay in `Update` so they keep
|
||||
// observing input on the frame it occurs. They read
|
||||
// `FocusedButton` written during the *previous* tick's
|
||||
// `PostUpdate`, which is exactly what we want: the very next
|
||||
// user keypress after a modal opens lands on a populated
|
||||
// resource.
|
||||
.add_systems(
|
||||
Update,
|
||||
PostUpdate,
|
||||
(
|
||||
attach_focusable_to_modal_buttons,
|
||||
auto_focus_on_modal_open,
|
||||
)
|
||||
.chain(),
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
sync_focus_on_mouse_click,
|
||||
clear_hud_focus_on_unhover,
|
||||
handle_focus_keys,
|
||||
@@ -827,6 +850,143 @@ mod tests {
|
||||
assert_eq!(focused, Some(a), "Primary button A should auto-focus");
|
||||
}
|
||||
|
||||
/// One-shot trigger resource consumed by the production-shaped test
|
||||
/// system [`spawn_modal_via_system`]. When set to `true`, the system
|
||||
/// queues a `spawn_modal` call on the next `Update` and clears the
|
||||
/// flag. Mirrors the real production flow where a click-handler
|
||||
/// system queues the modal spawn via `Commands` rather than the
|
||||
/// test fixture using `world.flush()` ahead of time.
|
||||
#[derive(Resource, Default)]
|
||||
struct SpawnModalTrigger(bool);
|
||||
|
||||
/// Production-shaped modal spawner: a regular Bevy `System` that
|
||||
/// reads a trigger flag and queues a 2-button modal via `Commands`.
|
||||
/// Crucially this system has **no** ordering relationship with
|
||||
/// `UiFocusPlugin`'s chain — exactly the situation that surfaces the
|
||||
/// "focus arrives one frame late" bug in production.
|
||||
fn spawn_modal_via_system(
|
||||
mut commands: Commands,
|
||||
mut trigger: ResMut<SpawnModalTrigger>,
|
||||
) {
|
||||
if !trigger.0 {
|
||||
return;
|
||||
}
|
||||
trigger.0 = false;
|
||||
spawn_modal(&mut commands, TestModal, 0, |card| {
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
TestButtonB,
|
||||
"B",
|
||||
None,
|
||||
ButtonVariant::Secondary,
|
||||
None,
|
||||
);
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
TestButtonA,
|
||||
"A",
|
||||
None,
|
||||
ButtonVariant::Primary,
|
||||
None,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Same-frame-focus contract: when a modal is spawned by an
|
||||
/// independent system during the same `Update` as the focus chain,
|
||||
/// `FocusedButton` must be populated with the primary button by the
|
||||
/// time `handle_focus_keys` runs in that **same** update — so a Tab
|
||||
/// pressed in the very next tick advances focus rather than
|
||||
/// landing on "nothing focused → primary".
|
||||
#[test]
|
||||
fn primary_button_is_focused_on_modal_spawn_same_frame() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(UiModalPlugin)
|
||||
.add_plugins(UiFocusPlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.init_resource::<SpawnModalTrigger>();
|
||||
// Register the production-shaped spawn system in `Update` with
|
||||
// no chain relationship to `UiFocusPlugin`.
|
||||
app.add_systems(Update, spawn_modal_via_system);
|
||||
// Initial Startup pass.
|
||||
app.update();
|
||||
|
||||
// Trigger the spawn and run exactly ONE update — the same
|
||||
// `Update` cycle that the focus chain runs in. By the end of
|
||||
// this update, `FocusedButton` must already point at the
|
||||
// primary button.
|
||||
app.world_mut().resource_mut::<SpawnModalTrigger>().0 = true;
|
||||
app.update();
|
||||
|
||||
let primary = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<TestButtonA>>()
|
||||
.iter(app.world())
|
||||
.next()
|
||||
.expect("Primary button should exist after the spawn update");
|
||||
|
||||
assert_eq!(
|
||||
app.world().resource::<FocusedButton>().0,
|
||||
Some(primary),
|
||||
"FocusedButton must be populated with the primary on the same frame the modal spawns"
|
||||
);
|
||||
}
|
||||
|
||||
/// Tab pressed on the very next tick after a modal opens must
|
||||
/// advance focus from the primary to the secondary — not from
|
||||
/// "nothing focused" to the primary. The latter would mean focus
|
||||
/// arrived a frame late and Tab was wasted on first-focus instead
|
||||
/// of advancing.
|
||||
#[test]
|
||||
fn first_tab_after_modal_open_advances_to_secondary() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(UiModalPlugin)
|
||||
.add_plugins(UiFocusPlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.init_resource::<SpawnModalTrigger>();
|
||||
app.add_systems(Update, spawn_modal_via_system);
|
||||
app.update();
|
||||
|
||||
// Spawn the modal in update N.
|
||||
app.world_mut().resource_mut::<SpawnModalTrigger>().0 = true;
|
||||
app.update();
|
||||
|
||||
// Press Tab on update N+1. If focus arrived correctly in N,
|
||||
// Tab advances primary → secondary. If focus arrived late,
|
||||
// Tab promotes "no focus" to primary (the bug).
|
||||
let primary = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<TestButtonA>>()
|
||||
.iter(app.world())
|
||||
.next()
|
||||
.expect("primary spawned");
|
||||
let secondary = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<TestButtonB>>()
|
||||
.iter(app.world())
|
||||
.next()
|
||||
.expect("secondary spawned");
|
||||
|
||||
press_key(&mut app, KeyCode::Tab);
|
||||
app.update();
|
||||
|
||||
let focused_after_tab = app.world().resource::<FocusedButton>().0;
|
||||
assert_ne!(
|
||||
focused_after_tab,
|
||||
Some(primary),
|
||||
"first Tab after modal open should advance off the primary, not land on it (focus arrived late)"
|
||||
);
|
||||
assert_eq!(
|
||||
focused_after_tab,
|
||||
Some(secondary),
|
||||
"first Tab from primary should land on the secondary"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_advances_focus_in_spawn_order() {
|
||||
let mut app = headless_app();
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
//! ```
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
||||
use bevy::window::PrimaryWindow;
|
||||
use solitaire_data::AnimSpeed;
|
||||
|
||||
use crate::font_plugin::FontResource;
|
||||
@@ -74,6 +76,19 @@ pub struct ModalScrim;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ModalCard;
|
||||
|
||||
/// Marker on a [`ModalScrim`] entity opting that modal into the
|
||||
/// click-outside-to-dismiss behaviour.
|
||||
///
|
||||
/// When attached, [`dismiss_modal_on_scrim_click`] despawns the scrim
|
||||
/// (and its hierarchy) on a left mouse press whose cursor falls on the
|
||||
/// scrim and outside every [`ModalCard`]. Modals with destructive
|
||||
/// actions or unsaved state (Settings, Onboarding, Pause, Forfeit
|
||||
/// confirmation, Confirm New Game, etc.) intentionally do not opt in
|
||||
/// — those require an explicit Cancel / Done / Confirm so an
|
||||
/// accidental scrim click cannot lose work.
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct ScrimDismissible;
|
||||
|
||||
/// Marker on a header `Text` (`TYPE_HEADLINE` + `TEXT_PRIMARY`).
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ModalHeader;
|
||||
@@ -474,6 +489,89 @@ pub fn advance_modal_enter(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Click-outside-to-dismiss
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns `true` when the cursor at `cursor_logical` falls inside the
|
||||
/// axis-aligned rectangle described by `centre_logical` (rectangle
|
||||
/// centre, logical pixels) and `size_logical` (full width × height,
|
||||
/// logical pixels).
|
||||
///
|
||||
/// Pure helper extracted from [`dismiss_modal_on_scrim_click`] so the
|
||||
/// hit-test decision can be tested without a real `Window` /
|
||||
/// rendered UI tree.
|
||||
#[inline]
|
||||
fn cursor_is_inside_rect(cursor_logical: Vec2, centre_logical: Vec2, size_logical: Vec2) -> bool {
|
||||
let half = size_logical * 0.5;
|
||||
cursor_logical.x >= centre_logical.x - half.x
|
||||
&& cursor_logical.x <= centre_logical.x + half.x
|
||||
&& cursor_logical.y >= centre_logical.y - half.y
|
||||
&& cursor_logical.y <= centre_logical.y + half.y
|
||||
}
|
||||
|
||||
/// Despawns the topmost [`ScrimDismissible`] modal when the player
|
||||
/// presses the left mouse button while the cursor is over the scrim
|
||||
/// AND outside every [`ModalCard`]. Modals without the marker are
|
||||
/// untouched, and existing dismiss paths (Cancel / Done / Esc /
|
||||
/// dedicated buttons) keep working unchanged.
|
||||
///
|
||||
/// **Topmost-only.** Stacked dismissible modals would otherwise all
|
||||
/// dismiss together on a single click. The system processes at most
|
||||
/// one entity per frame: the first match in the query is taken,
|
||||
/// matching the click-handler convention used elsewhere in the engine.
|
||||
/// Spawn order is the practical tiebreaker — dismissible modals are
|
||||
/// rarely stacked, so picking any one is acceptable.
|
||||
///
|
||||
/// **No same-frame dismissal.** `just_pressed` is true only on the
|
||||
/// frame the button transitions to pressed. The press that *opens* a
|
||||
/// modal happens on one frame; this system fires on a subsequent
|
||||
/// press, so a modal can never be opened and dismissed in a single
|
||||
/// click.
|
||||
///
|
||||
/// `cards`/`scrims` queries read [`UiGlobalTransform`] (window-space
|
||||
/// physical pixels) and [`ComputedNode`] (size in physical pixels);
|
||||
/// both are converted to logical pixels via
|
||||
/// `ComputedNode::inverse_scale_factor` so they can be compared with
|
||||
/// the cursor position from `Window::cursor_position` (logical px).
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn dismiss_modal_on_scrim_click(
|
||||
mut commands: Commands,
|
||||
mouse: Option<Res<ButtonInput<MouseButton>>>,
|
||||
windows: Query<&Window, With<PrimaryWindow>>,
|
||||
scrims: Query<Entity, (With<ModalScrim>, With<ScrimDismissible>)>,
|
||||
cards: Query<(&UiGlobalTransform, &ComputedNode), With<ModalCard>>,
|
||||
) {
|
||||
let Some(mouse) = mouse else { return };
|
||||
if !mouse.just_pressed(MouseButton::Left) {
|
||||
return;
|
||||
}
|
||||
let Ok(window) = windows.single() else {
|
||||
return;
|
||||
};
|
||||
let Some(cursor) = window.cursor_position() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Topmost-only: bail after the first dismissible scrim. Stacked
|
||||
// dismissible modals are not currently a real case, but this guard
|
||||
// keeps the behaviour predictable if they ever arise.
|
||||
let Some(scrim_entity) = scrims.iter().next() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let cursor_over_card = cards.iter().any(|(transform, computed)| {
|
||||
let inv = computed.inverse_scale_factor;
|
||||
let size_logical = computed.size() * inv;
|
||||
let centre_logical = transform.translation * inv;
|
||||
cursor_is_inside_rect(cursor, centre_logical, size_logical)
|
||||
});
|
||||
|
||||
if !cursor_over_card {
|
||||
commands.entity(scrim_entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
/// Repaints every `ModalButton` on `Changed<Interaction>` so hover and
|
||||
/// press states are visible without each overlay registering its own
|
||||
/// paint system.
|
||||
@@ -515,6 +613,12 @@ impl Plugin for UiModalPlugin {
|
||||
Update,
|
||||
(apply_modal_enter_speed, advance_modal_enter, paint_modal_buttons).chain(),
|
||||
);
|
||||
// Click-outside-to-dismiss is independent of the open
|
||||
// animation chain — it reads `just_pressed(Left)` and runs
|
||||
// every tick. `just_pressed` is true only on the frame the
|
||||
// button transitions to pressed, so the press that *opens* a
|
||||
// modal cannot dismiss the same modal on the next frame.
|
||||
app.add_systems(Update, dismiss_modal_on_scrim_click);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -668,5 +772,224 @@ mod tests {
|
||||
Duration::from_secs_f32(secs),
|
||||
));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Click-outside-to-dismiss
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Pure-helper hit-test: cursor inside the rectangle returns true.
|
||||
#[test]
|
||||
fn cursor_is_inside_rect_inside_returns_true() {
|
||||
// 100×60 rectangle centred at (200, 150).
|
||||
let centre = Vec2::new(200.0, 150.0);
|
||||
let size = Vec2::new(100.0, 60.0);
|
||||
// Centre + a few corners just inside.
|
||||
assert!(cursor_is_inside_rect(centre, centre, size));
|
||||
assert!(cursor_is_inside_rect(Vec2::new(151.0, 121.0), centre, size));
|
||||
assert!(cursor_is_inside_rect(Vec2::new(249.0, 179.0), centre, size));
|
||||
}
|
||||
|
||||
/// Pure-helper hit-test: cursor outside the rectangle returns false
|
||||
/// on every side.
|
||||
#[test]
|
||||
fn cursor_is_inside_rect_outside_returns_false() {
|
||||
let centre = Vec2::new(200.0, 150.0);
|
||||
let size = Vec2::new(100.0, 60.0);
|
||||
assert!(!cursor_is_inside_rect(Vec2::new(149.0, 150.0), centre, size)); // left
|
||||
assert!(!cursor_is_inside_rect(Vec2::new(251.0, 150.0), centre, size)); // right
|
||||
assert!(!cursor_is_inside_rect(Vec2::new(200.0, 119.0), centre, size)); // above
|
||||
assert!(!cursor_is_inside_rect(Vec2::new(200.0, 181.0), centre, size)); // below
|
||||
}
|
||||
|
||||
/// Builds a headless app capable of running
|
||||
/// `dismiss_modal_on_scrim_click`: registers the plugin, primes the
|
||||
/// `ButtonInput<MouseButton>` resource that `MinimalPlugins`
|
||||
/// doesn't provide, and spawns a synthetic `PrimaryWindow`.
|
||||
fn dismiss_test_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(UiModalPlugin);
|
||||
app.init_resource::<ButtonInput<MouseButton>>();
|
||||
// Synthetic primary window. `MinimalPlugins` doesn't ship
|
||||
// `WindowPlugin`, so spawning the entity by hand is fine —
|
||||
// `dismiss_modal_on_scrim_click` only reads `cursor_position`
|
||||
// off it, not any platform-backed state.
|
||||
app.world_mut().spawn((
|
||||
Window {
|
||||
resolution: bevy::window::WindowResolution::new(800, 600),
|
||||
..default()
|
||||
},
|
||||
PrimaryWindow,
|
||||
));
|
||||
app
|
||||
}
|
||||
|
||||
/// Marker for synthetic-modal tests below.
|
||||
#[derive(Component, Debug)]
|
||||
struct DismissTestModal;
|
||||
|
||||
/// Spawns a synthetic scrim + card pair pre-populated with
|
||||
/// `ComputedNode` + `UiGlobalTransform` so the dismiss system has
|
||||
/// real geometry to hit-test against without running the full UI
|
||||
/// layout pipeline. `card_centre` and `card_size` are in physical
|
||||
/// pixels (matching `ComputedNode.size`); the synthetic
|
||||
/// `inverse_scale_factor` is 1.0 so logical == physical.
|
||||
fn spawn_synthetic_modal(
|
||||
app: &mut App,
|
||||
dismissible: bool,
|
||||
card_centre: Vec2,
|
||||
card_size: Vec2,
|
||||
) -> Entity {
|
||||
let world = app.world_mut();
|
||||
let mut scrim = world.spawn((DismissTestModal, ModalScrim));
|
||||
if dismissible {
|
||||
scrim.insert(ScrimDismissible);
|
||||
}
|
||||
let scrim_entity = scrim.id();
|
||||
let card_entity = world
|
||||
.spawn((
|
||||
ModalCard,
|
||||
{
|
||||
let mut node = ComputedNode {
|
||||
stack_index: 0,
|
||||
size: card_size,
|
||||
content_size: card_size,
|
||||
scrollbar_size: Vec2::ZERO,
|
||||
scroll_position: Vec2::ZERO,
|
||||
outline_width: 0.0,
|
||||
outline_offset: 0.0,
|
||||
unrounded_size: card_size,
|
||||
border: bevy::sprite::BorderRect::default(),
|
||||
border_radius: bevy::ui::ResolvedBorderRadius::default(),
|
||||
padding: bevy::sprite::BorderRect::default(),
|
||||
inverse_scale_factor: 1.0,
|
||||
};
|
||||
// `is_empty` guard inside Bevy treats zero-size
|
||||
// nodes as inert; we always pass a non-zero size.
|
||||
node.size = card_size;
|
||||
node
|
||||
},
|
||||
UiGlobalTransform::from_translation(card_centre),
|
||||
))
|
||||
.id();
|
||||
// Parent the card to the scrim so a `commands.entity(scrim).despawn()`
|
||||
// also takes the card down — matching the real `spawn_modal` hierarchy.
|
||||
world.entity_mut(scrim_entity).add_child(card_entity);
|
||||
scrim_entity
|
||||
}
|
||||
|
||||
/// Sets the synthetic primary window's cursor position (logical px,
|
||||
/// since we use `inverse_scale_factor = 1.0` everywhere in tests).
|
||||
fn set_cursor(app: &mut App, position: Option<Vec2>) {
|
||||
let world = app.world_mut();
|
||||
let mut q = world.query_filtered::<&mut Window, With<PrimaryWindow>>();
|
||||
let mut window = q.single_mut(world).expect("primary window");
|
||||
window.set_cursor_position(position);
|
||||
}
|
||||
|
||||
/// Drives a fresh `just_pressed(Left)` for the next `app.update()`.
|
||||
/// `MinimalPlugins` doesn't run the input clear pass, so we mark
|
||||
/// the clear by hand on the resource between presses.
|
||||
fn press_left_mouse(app: &mut App) {
|
||||
let mut input = app
|
||||
.world_mut()
|
||||
.resource_mut::<ButtonInput<MouseButton>>();
|
||||
input.clear();
|
||||
input.press(MouseButton::Left);
|
||||
}
|
||||
|
||||
/// Click outside the card on a dismissible modal despawns it.
|
||||
#[test]
|
||||
fn dismissible_scrim_despawns_on_scrim_click_outside_card() {
|
||||
let mut app = dismiss_test_app();
|
||||
let scrim = spawn_synthetic_modal(
|
||||
&mut app,
|
||||
/* dismissible: */ true,
|
||||
Vec2::new(400.0, 300.0),
|
||||
Vec2::new(200.0, 100.0),
|
||||
);
|
||||
// Cursor far outside the card — top-left corner of the window.
|
||||
set_cursor(&mut app, Some(Vec2::new(50.0, 50.0)));
|
||||
press_left_mouse(&mut app);
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
app.world().get_entity(scrim).is_err(),
|
||||
"dismissible scrim should be despawned on a scrim-area click"
|
||||
);
|
||||
}
|
||||
|
||||
/// Click *inside* the card area must NOT dismiss the modal — the
|
||||
/// player intends to interact with the card content.
|
||||
#[test]
|
||||
fn dismissible_scrim_does_not_despawn_on_card_click() {
|
||||
let mut app = dismiss_test_app();
|
||||
let scrim = spawn_synthetic_modal(
|
||||
&mut app,
|
||||
/* dismissible: */ true,
|
||||
Vec2::new(400.0, 300.0),
|
||||
Vec2::new(200.0, 100.0),
|
||||
);
|
||||
// Cursor at the card centre — definitely inside.
|
||||
set_cursor(&mut app, Some(Vec2::new(400.0, 300.0)));
|
||||
press_left_mouse(&mut app);
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
app.world().get_entity(scrim).is_ok(),
|
||||
"click inside the card must not dismiss the modal"
|
||||
);
|
||||
}
|
||||
|
||||
/// Modals without `ScrimDismissible` ignore scrim clicks entirely.
|
||||
/// Settings, Onboarding, Pause, etc. rely on this opt-out.
|
||||
#[test]
|
||||
fn non_dismissible_scrim_does_not_despawn_on_scrim_click() {
|
||||
let mut app = dismiss_test_app();
|
||||
let scrim = spawn_synthetic_modal(
|
||||
&mut app,
|
||||
/* dismissible: */ false,
|
||||
Vec2::new(400.0, 300.0),
|
||||
Vec2::new(200.0, 100.0),
|
||||
);
|
||||
set_cursor(&mut app, Some(Vec2::new(50.0, 50.0)));
|
||||
press_left_mouse(&mut app);
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
app.world().get_entity(scrim).is_ok(),
|
||||
"non-dismissible scrim must survive a scrim-area click"
|
||||
);
|
||||
}
|
||||
|
||||
/// Stacked dismissible modals: one click despawns at most one
|
||||
/// modal per frame (the one the query yields first). The other
|
||||
/// stays put until the next press.
|
||||
#[test]
|
||||
fn stacked_modals_dismiss_at_most_one_per_click() {
|
||||
let mut app = dismiss_test_app();
|
||||
let a = spawn_synthetic_modal(
|
||||
&mut app,
|
||||
/* dismissible: */ true,
|
||||
Vec2::new(400.0, 300.0),
|
||||
Vec2::new(200.0, 100.0),
|
||||
);
|
||||
let b = spawn_synthetic_modal(
|
||||
&mut app,
|
||||
/* dismissible: */ true,
|
||||
Vec2::new(400.0, 300.0),
|
||||
Vec2::new(200.0, 100.0),
|
||||
);
|
||||
// Cursor outside both cards.
|
||||
set_cursor(&mut app, Some(Vec2::new(50.0, 50.0)));
|
||||
press_left_mouse(&mut app);
|
||||
app.update();
|
||||
|
||||
let a_alive = app.world().get_entity(a).is_ok();
|
||||
let b_alive = app.world().get_entity(b).is_ok();
|
||||
assert!(
|
||||
a_alive ^ b_alive,
|
||||
"exactly one of the two stacked dismissible modals should remain"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user