Compare commits

..

12 Commits

Author SHA1 Message Date
funman300 27cdf78ce0 docs: cut v0.17.0 — solver-driven hints + replay-rate slider
Two follow-up commits on top of v0.16.0:
- 87275bf: H-key hint asks the v0.15.0 solver for the actual best
  first move, with the existing heuristic kept as fallback.
- 53e3b81: Settings → Gameplay slider tunes replay playback rate
  (0.10–1.00 s, default 0.45 s) read per frame from SettingsResource.

Adds the [0.17.0] CHANGELOG section, folds the post-v0.16.0
provisional table into a v0.17.0 shipped table in SESSION_HANDOFF,
prunes the now-stale "Cut v0.17.0" item from the punch list, and
re-letters the resume-prompt decision options A–D.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:11:08 +00:00
funman300 faa6c5efc4 docs: reconcile SESSION_HANDOFF with actually-shipped state
The post-v0.16.0 table marked the replay-rate slider as `(pending)`
but 53e3b81 already shipped it. Resume prompt said "HEAD at v0.16.0
/ 1196 tests" while the same doc above said HEAD was post-v0.16.0
with two follow-ups and 1208 tests.

Updates the slider row to reference 53e3b81, refreshes the resume
prompt's HEAD/test counts, and rewrites the "DECISION TO ASK THE
PLAYER FIRST" list — drops the smoke-test and "solver hints" bullets
(both already covered) and pulls forward the actual open items
(cut v0.17.0, solver-on-AsyncComputeTaskPool, won-previously,
replay sharing, packaging).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:05:03 +00:00
funman300 487b99bbc9 docs: SESSION_HANDOFF refresh — solver hints + replay slider, async deferred
Documents the two follow-ups landed on top of v0.16.0 (solver-driven
hints in 87275bf, replay-rate slider in this commit's parent) and
notes that an async-solver attempt was rolled back when a sub-agent
was interrupted leaving 3 failing tests. Async-solver is still
worth doing but needs smaller scoping next round.

Also records the process note raised this session: agent briefs had
been mandating ≥3 tests per feature, which produced low-value
coverage on trivial settings fields (Default trait arithmetic,
serde derive round-trips, stdlib clamp). Future briefs should ask
only for tests that pin behaviour contracts or regressions on real
bugs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:00:59 +00:00
funman300 53e3b816cf feat(settings,engine): replay-playback rate slider in Settings → Gameplay
The replay overlay's per-move tick rate has been hardcoded at
REPLAY_MOVE_INTERVAL_SECS = 0.45 s/move since the in-engine
playback shipped. Power users want to scrub faster through older
wins. Adds a Settings slider that tunes the interval 0.10–1.00 s in
0.05 s steps; default 0.45 s preserves existing feel.

Settings.replay_move_interval_secs uses #[serde(default)] so legacy
files load to 0.45. sanitized() clamps out-of-range values.
tick_replay_playback now reads SettingsResource per frame and falls
back to the constant when the resource is absent (test fixtures).
The slider takes effect on the very next playback tick — no need to
restart playback.

Mirrors the existing tooltip-delay slider exactly: SettingsButton::
ReplayMoveIntervalUp/Down variants, the same `slider_row` pattern,
the same per-tick repaint system shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:00:59 +00:00
funman300 87275bf340 feat(core,engine): solver-driven hints with heuristic fallback
The H-key hint now asks the v0.15.0 Klondike solver for the actual
best first move from the current game state instead of the existing
heuristic. The heuristic stays as the fallback path so hints still
work when the solver bails Inconclusive on the player's budget.

solitaire_core::solver gains a path-recording variant. The internal
DFS already enumerated moves on each frame; recording the root_move
on the stack frame is +16 bytes and one unwrap_or per expansion —
the new-game retry loop sees no measurable slowdown.

New public API (additive — try_solve unchanged):

  pub struct SolverMove { source, dest, count }
  pub struct SolveOutcome { result: SolverResult, first_move: Option<SolverMove> }
  pub fn try_solve_with_first_move(seed, draw_mode, &cfg) -> SolveOutcome
  pub fn try_solve_from_state(&GameState, &cfg) -> SolveOutcome

The internal solver-move enum was renamed InternalMove so the public
SolverMove can use engine-friendly (source, dest, count) types
instead of the compact internal form.

Engine wiring: handle_keyboard_hint calls try_solve_from_state on
the live GameStateResource. On Winnable + first_move, the hint
surfaces that exact move (no cycling — a single, optimal hint).
Unwinnable or Inconclusive falls through to the existing all_hints
cycling heuristic so hints remain useful in deals the solver gives
up on.

A new HintSolverConfig resource lets tests inject tight budgets to
force the fallback path; production uses SolverConfig::default()
and median solve time stays at 2 ms per H press.

Six new tests pin the contract: 4 in solitaire_core (Winnable
returns first_move, Unwinnable returns None, deterministic, seed
and state forms agree); 2 in solitaire_engine (hint uses solver
when Winnable, falls back to heuristic when Inconclusive).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 01:10:02 +00:00
funman300 56647d7f0d docs: CHANGELOG + SESSION_HANDOFF refresh for v0.16.0
CHANGELOG gains a [0.16.0] section covering the modal-feel polish
round: per-modal Overflow::scroll_y on Achievements / Help / Stats /
Profile / Leaderboard, pointer cursor on hover for every Button,
same-frame focus on modal open (attach + auto_focus moved to
PostUpdate), and click-outside-to-dismiss for the six read-only
modals via a new ScrimDismissible marker.

The bottom-of-file compare links thread the new tag into the chain.
Test count updated to 1196.

SESSION_HANDOFF rewritten for the post-v0.16.0 state. Punch list
collapsed to two release-prep items (smoke-test, desktop packaging)
plus the carryover from v0.15.0's next-round candidates that didn't
ship this round (solver-driven hints, replay-rate slider, solver
progress overlay, async solver, "won previously" indicator, replay
sharing). Resume prompt asks A–E.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:52:08 +00:00
funman300 cbf2483028 feat(engine): opt Profile / Leaderboard / Home into scrim-click dismiss
Follow-up to a54201e. The previous commit added ScrimDismissible to
Stats, Achievements, and Help; this one extends the same one-line
opt-in to the remaining three read-only modals so the click-outside-
to-close gesture is consistent across every informational surface.

Each modal now has the same shape: capture the scrim from
spawn_modal, attach ScrimDismissible after the build closure
returns. Three lines per file plus the import; no behaviour change
to the modal content itself.

Settings, Onboarding, Pause, Forfeit confirm, ConfirmNewGame, and
the win/game-over modals continue to opt OUT — all carry unsaved
or destructive state where an accidental scrim click would lose
work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:47:02 +00:00
funman300 a54201e97b feat(engine): click-outside-to-dismiss for read-only modals
Adds a ScrimDismissible marker to ui_modal that opts a modal into
the standard "click outside the card to close" gesture. The new
dismiss_modal_on_scrim_click system fires on a left-mouse press
whose cursor falls on the scrim and outside every ModalCard, then
despawns the topmost dismissible scrim — Bevy's hierarchy despawn
cascades to the card and its children.

Marker design is opt-in per modal so destructive / state-mutating
modals (Settings saves on close, Onboarding requires explicit
acknowledgement, Pause / Forfeit / ConfirmNewGame need confirmed
intent) don't lose work to an accidental scrim click. Three
read-only modals opt in this round:

- Stats — informational; press S or click outside to dismiss.
- Achievements — read-only list.
- Help — keyboard reference.

Profile, Leaderboard, and Home will opt in the same way in a
follow-up; they were left out to keep this commit's scope tight.

The hit-test path uses each ModalCard's UiGlobalTransform +
ComputedNode bounding box so stacked modals close cleanly: the
topmost dismissible scrim is the only candidate per click. Tests
spawn synthetic ComputedNodes (with bevy::sprite::BorderRect for
the resolved-border slots Bevy's UI module re-exports) so the
geometry hit-tests deterministically without running the full UI
layout pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:32:58 +00:00
funman300 48e412177c fix(engine): focus arrives on the same frame a modal opens
Previously when a click-handler in Update spawned a modal,
attach_focusable_to_modal_buttons and auto_focus_on_modal_open ran
in the same Update — but with no ordering edge to the click handler
the deferred Commands wouldn't materialise in time, so attach saw
no entities, FocusedButton stayed empty, and the very next Tab/Enter
press wasted itself moving focus from None to the primary instead
of activating it.

Moves attach_focusable_to_modal_buttons + auto_focus_on_modal_open
from Update to PostUpdate. The schedule boundary itself supplies
the sync point: every modal spawned anywhere in Update is
materialised before PostUpdate runs, attach can find the new
ModalButtons, and FocusedButton is populated before app.update()
returns. handle_focus_keys stays in Update so it observes input on
the frame it occurs, reading FocusedButton written by the previous
tick's PostUpdate.

Two new tests pin the contract:
- primary_button_is_focused_on_modal_spawn_same_frame uses a
  production-shaped spawner system (no chain edge to UiFocusPlugin)
  and asserts FocusedButton.0 is Some after a single update —
  fails without the fix, passes with it.
- first_tab_after_modal_open_advances_to_secondary guards against a
  regression where focus arrives but the very first Tab moves from
  None to primary instead of from primary to secondary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:32:19 +00:00
funman300 cd54ce1bb0 feat(engine): pointer cursor on hover over interactive buttons
Previously the cursor stayed the default arrow over every clickable
UI element (modal buttons, HUD action bar, mode-launcher cards,
settings toggles). Adds the standard "this is clickable" hand
affordance: while not dragging a card, hovering any entity with
Interaction::Hovered (or Pressed — keeps the pointer through a
click-and-hold) sets the window cursor to SystemCursorIcon::Pointer.

The new branch sits between the existing drag handlers in
update_cursor_icon: Grabbing wins when actively dragging, then
Pointer when a button is hovered, then Grab when a draggable card
is hovered, then Default. Card-drag affordance unchanged.

A pure pick_cursor_icon(is_dragging, any_button_hovered,
any_card_hovered) helper makes the priority logic unit-testable
without standing up a full Window + Camera fixture; four new tests
pin every branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:32:04 +00:00
funman300 7a3032b74c fix(engine): scroll the modals whose content overflows the viewport
Smoke-test report: the Achievements list isn't scrollable. With 19
achievements the panel overflows the modal at the 800x600 minimum
window and the bottom rows are clipped. The same problem applies to
several other modals whose content has grown over the v0.13–v0.15
rounds.

Mirrors the existing SettingsPanelScrollable pattern from
settings_plugin: each modal's body Node gets Overflow::scroll_y()
plus a max_height (Val::Vh(70.0) for most, Val::Vh(50.0) for the
leaderboard's variable-length ranking section), a marker component
so the scroll system can find it, and a sibling system that routes
MouseWheel events into the body's ScrollPosition.

Five modals fixed:
- Achievements: 19 rows clearly overflow; AchievementsScrollable +
  scroll_achievements_panel.
- Help: ~28 reference rows overflow at 800x600; HelpScrollable +
  scroll_help_panel.
- Stats: 8-cell primary grid + per-mode bests + progression +
  weekly goals + unlocks + Time Attack readout + replay caption is
  enough content to overflow once the player has any progress;
  StatsScrollable + scroll_stats_panel.
- Profile: Sync + Progression + 14-day calendar + up to 18
  unlocked achievements + Stats summary overflows once a few
  achievements unlock; ProfileScrollable + scroll_profile_panel.
- Leaderboard: 10-row cap is at the edge of overflow on 800x600
  with long display names; LeaderboardScrollable +
  scroll_leaderboard_panel (max_height = 50vh — the ranking section
  is the only variable-length part).

Home modal NOT scrolled — five mode cards plus a Cancel button
were sized to fit at 800x600 by design and adding scroll there
would clutter the launcher.

Five new tests pin the contract: each modal's body has the
scrollable marker, a non-default max_height, and Overflow::scroll_y.

Defer-list (small UX nits surfaced during the sweep, not fixed
here):
- Modal close-on-click-outside is missing across the board; would
  need Interaction on ModalScrim in ui_modal.
- ModalButton hover doesn't set a pointer cursor.
- Tab focus on modal open is initialised on the next frame instead
  of the same frame; first Tab press selects rather than focus
  already being on the primary.

These are bigger touches than the scroll fix and don't fit a
30-LOC budget; surfacing for a follow-up round.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:30:04 +00:00
funman300 89699a8a86 docs: SESSION_HANDOFF refresh for post-v0.15.0 (follow-up)
The previous v0.15.0 doc commit only landed CHANGELOG — the
SESSION_HANDOFF write silently no-op'd due to a Write tool param
mix-up. This commit lands the matching handoff refresh:

- Status block updated to v0.15.0 / HEAD / 1178 tests
- New v0.15.0 changelog table covering the seven feature commits
  (Bevy trim, replay playback core + overlay + Stats wiring,
  rolling replay history, Cinephile achievement, solver + toggle)
- Open punch list collapsed to two release-prep items (smoke-test,
  desktop packaging) and six fresh next-round candidates
  (solver-driven hints — now unblocked, replay-rate slider, solver
  progress overlay, async solver, "won previously" indicator,
  replay sharing)
- Resume prompt asks A–E

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:08:46 +00:00
17 changed files with 2839 additions and 646 deletions
+94 -1
View File
@@ -8,6 +8,98 @@ project follows [Semantic Versioning](https://semver.org/).
_Nothing yet._ _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 ## [0.15.0] — 2026-05-02
In-engine replay playback, the Klondike solver + "Winnable deals In-engine replay playback, the Klondike solver + "Winnable deals
@@ -465,7 +557,8 @@ with no PNG artwork yet.
CREDITS.md, persistent window geometry, mode-launcher Home repurpose, CREDITS.md, persistent window geometry, mode-launcher Home repurpose,
client-side sync round-trip integration tests. client-side sync round-trip integration tests.
[Unreleased]: https://github.com/funman300/Rusty_Solitaire/compare/v0.15.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.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.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.13.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.12.0...v0.13.0
+40 -69
View File
@@ -1,24 +1,22 @@
# Solitaire Quest — Session Handoff # 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 ## 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). - **Working tree:** clean apart from untracked `CARD_PLAN.md` (intentional).
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean. - **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
- **Tests:** **1134 passed / 0 failed** across the workspace. - **Tests:** **1208 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`. - **Tags on origin:** `v0.9.0` through `v0.17.0`.
## Where we are ## 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. 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.
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.
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) ### 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. `github.com/funman300/Rusty_Solitaire` is the canonical repo. Always push there.
## Session 8 + 9 (shipped 2026-05-02) — v0.14.0 ## v0.17.0 (shipped 2026-05-06)
### v0.13.0-era UX candidates (had landed but missed v0.13.0's tag)
| Area | Commit | What landed | | 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. | | 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. |
| 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`. | | Replay-rate slider | `53e3b81` | Settings → Gameplay slider tunes `replay_move_interval_secs` 0.101.00 s in 0.05 s steps; default 0.45 s. Read per frame from `SettingsResource`. |
| 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.02.0, default 1.0, "Off" at zero. Multiplies the time-bonus shown in the win modal. Cosmetic only — does NOT affect achievement unlock thresholds. |
### Quat smoke-test bug fixes ## v0.16.0 (shipped 2026-05-06)
| Area | Commit | What landed | | 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. | | 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. |
| 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. | | 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. |
| 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. | | 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. |
| 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. | | 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. |
### 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. |
## Open punch list ## Open punch list
### Release prep ### 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. ### Process note (raised this session)
- **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.
## 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`. - **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.
- **User themes** under `themes://`. Drop a directory containing `theme.ron` + 53 SVGs. - **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.
- **Importer** at `solitaire_engine::theme::import_theme(zip)` validates archives and atomically unpacks. - **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.
- **Picker UI** in Settings → Cosmetic; thumbnails + the active theme's `back` override the legacy `back_N.png` picker when present.
## Resume prompt ## 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. You are a senior Rust + Bevy developer working on Solitaire Quest.
Working directory: <Rusty_Solitaire clone path on this machine — local Working directory: <Rusty_Solitaire clone path on this machine — local
directory may still be named Rusty_Solitare from earlier; that's fine>. directory may still be named Rusty_Solitare from earlier; that's fine>.
Branch: master. Direction is OPEN — v0.14.0 just shipped covering the Branch: master. Direction is OPEN — v0.16.0 just shipped covering
Quat bug fixes, the v0.13.0 candidate tail, and the entire modal scroll fixes, pointer cursor, same-frame focus, and scrim-click
replay-pipeline feature. dismiss across all six read-only modals.
State: HEAD at v0.14.0. Working tree clean apart from untracked State: HEAD at v0.17.0 (solver hints + replay-rate slider on top
CARD_PLAN.md (intentional). of v0.16.0). Working tree clean apart from untracked CARD_PLAN.md
(intentional).
Build: cargo clippy --workspace --all-targets -- -D warnings clean. 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): 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 2. CHANGELOG.md — release-by-release record
3. CLAUDE.md — hard rules (UI-first, no panics, etc.) 3. CLAUDE.md — hard rules (UI-first, no panics, etc.)
4. ARCHITECTURE.md — crate responsibilities + data flow 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) may be missing on a fresh machine)
DECISION TO ASK THE PLAYER FIRST: DECISION TO ASK THE PLAYER FIRST:
A. Smoke-test v0.14.0 on the alex machine first to confirm the A. Solver-on-AsyncComputeTaskPool with progress toast + cancel.
three Quat bug fixes hold up in real gameplay and the replay A previous attempt was rolled back when an agent left 3
pipeline works end-to-end (record → upload → web viewer). failing tests; redoing it needs smaller pieces. Eliminates the
B. Take the deferred Bevy-audio-feature trim (Quat investigation worst-case 6 s UI stall — highest gameplay impact left.
#2) — one-line workspace edit, ~50 fewer transitive crates. B. Per-deal "won previously" HUD indicator using the rolling
C. Take the deferred solver toggle (Quat investigation #1): add replay history's seeds.
"Winnable deals only" Settings toggle. Larger. C. Replay sharing — copyable URL via the existing web viewer.
D. Promote the in-engine "Watch replay" button to real playback. D. Take the deferred desktop-packaging item (needs artwork +
E. Pick from the remaining "next-round candidates" in this doc.
F. Take the deferred desktop-packaging item (needs artwork +
signing certs from the user). signing certs from the user).
WORKFLOW NOTES: WORKFLOW NOTES:
@@ -136,5 +107,5 @@ WORKFLOW NOTES:
- Every commit must pass build / clippy / test before pushing. - Every commit must pass build / clippy / test before pushing.
- Push to GitHub (origin) — that is the canonical remote. - Push to GitHub (origin) — that is the canonical remote.
OPEN AT THE START: ask which of AF. Don't pick unilaterally. OPEN AT THE START: ask which of AD. Don't pick unilaterally.
``` ```
+397 -43
View File
@@ -65,7 +65,7 @@ use std::hash::{Hash, Hasher};
use crate::card::{Card, Suit}; use crate::card::{Card, Suit};
use crate::deck::{deal_klondike, Deck}; use crate::deck::{deal_klondike, Deck};
use crate::game_state::DrawMode; use crate::game_state::{DrawMode, GameState};
use crate::pile::{Pile, PileType}; use crate::pile::{Pile, PileType};
use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence}; use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence};
@@ -108,6 +108,42 @@ impl Default for SolverConfig {
} }
} }
/// A single move the solver can recommend, expressed in terms of the
/// engine-level `(source, dest, count)` triple used by `MoveRequestEvent`.
///
/// Returned as part of [`SolveOutcome::first_move`] when
/// [`try_solve_with_first_move`] or [`try_solve_from_state`] proves the
/// position winnable. The hint system surfaces this to the player as the
/// "provably best" first move.
///
/// `count` is always `1` for non-tableau-to-tableau moves (foundation moves
/// always move a single card; waste moves a single card; draws use a
/// dedicated representation that the public API surfaces as
/// `source: PileType::Stock, dest: PileType::Waste, count: 1`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SolverMove {
/// Pile the move originates from.
pub source: PileType,
/// Pile the move lands on.
pub dest: PileType,
/// Number of cards in the move (1 for non-tableau-to-tableau moves).
pub count: usize,
}
/// Solver verdict plus, when winnable, the first move on a winning path.
///
/// `result == Winnable` guarantees `first_move == Some(_)`; the inverse
/// holds only when the search proved a verdict — `Inconclusive` and
/// `Unwinnable` always carry `first_move == None`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SolveOutcome {
/// The high-level verdict (Winnable / Unwinnable / Inconclusive).
pub result: SolverResult,
/// First move on the solution path when `result == Winnable`,
/// otherwise `None`.
pub first_move: Option<SolverMove>,
}
/// Tries to solve a fresh Classic-mode game from `seed` + `draw_mode`. /// Tries to solve a fresh Classic-mode game from `seed` + `draw_mode`.
/// ///
/// This is a pure function — same input always yields the same /// This is a pure function — same input always yields the same
@@ -120,18 +156,47 @@ impl Default for SolverConfig {
/// [`is_valid_tableau_sequence`]) drive move enumeration so the /// [`is_valid_tableau_sequence`]) drive move enumeration so the
/// solver's notion of "legal" exactly matches the live game. /// solver's notion of "legal" exactly matches the live game.
pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> SolverResult { pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> SolverResult {
// Delegate to the path-recording variant and discard the move. The path
// recording is cheap (a single Option<SolverMove> per stack frame) so
// this preserves `try_solve`'s existing performance characteristics —
// the new-game retry loop, which is the hot caller, sees no slowdown.
try_solve_with_first_move(seed, draw_mode, config).result
}
/// Tries to solve a fresh Classic-mode game from `seed` + `draw_mode` and,
/// when a win is found, returns the first move on the winning path.
///
/// Same semantics as [`try_solve`] for the verdict; the only difference is
/// that the [`SolveOutcome::first_move`] is populated when `result ==
/// SolverResult::Winnable`. `Unwinnable` and `Inconclusive` always carry
/// `first_move == None`.
///
/// Used by the engine hint system to promote H-key suggestions from a
/// heuristic to the provably-optimal first move; the hint system falls
/// back to its heuristic when this returns `Inconclusive`.
pub fn try_solve_with_first_move(
seed: u64,
draw_mode: DrawMode,
config: &SolverConfig,
) -> SolveOutcome {
let state = SolverState::initial(seed, draw_mode); let state = SolverState::initial(seed, draw_mode);
let mut visited: HashSet<u64> = HashSet::new(); state.solve(config)
let mut moves_consumed: u64 = 0; }
let mut budget_exceeded = false;
let won = state.search(config, &mut visited, &mut moves_consumed, &mut budget_exceeded); /// Tries to solve from an existing in-progress [`GameState`].
if won { ///
SolverResult::Winnable /// Mirrors [`try_solve_with_first_move`] but takes a live `GameState`
} else if budget_exceeded { /// instead of a fresh seed. The hint system uses this so it can ask the
SolverResult::Inconclusive /// solver about the actual board the player is staring at, not just the
} else { /// initial deal.
SolverResult::Unwinnable ///
} /// Reads `state.draw_mode` and the current pile contents. The active
/// `GameMode` is irrelevant — the solver only models Classic Klondike
/// rules, which are a strict subset of every other mode (Zen / Challenge
/// only differ in scoring and undo-availability).
pub fn try_solve_from_state(state: &GameState, config: &SolverConfig) -> SolveOutcome {
let solver_state = SolverState::from_game_state(state);
solver_state.solve(config)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -141,9 +206,11 @@ pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> Solve
/// The candidate moves the solver enumerates at each step. Distinct /// The candidate moves the solver enumerates at each step. Distinct
/// from `MoveRequestEvent` (engine-level) and `move_cards` (game-level) /// from `MoveRequestEvent` (engine-level) and `move_cards` (game-level)
/// because the solver also needs to model the stock-draw + recycle as a /// because the solver also needs to model the stock-draw + recycle as a
/// first-class move. /// first-class move. Distinct from the public [`SolverMove`] because the
/// internal form encodes each move kind structurally for fast pattern
/// matching during enumeration.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SolverMove { enum InternalMove {
/// Move `count` cards from a tableau column to another tableau column. /// Move `count` cards from a tableau column to another tableau column.
TableauToTableau { from: usize, to: usize, count: usize }, TableauToTableau { from: usize, to: usize, count: usize },
/// Move the top of a tableau column to a foundation slot. /// Move the top of a tableau column to a foundation slot.
@@ -156,6 +223,41 @@ enum SolverMove {
Draw, Draw,
} }
impl InternalMove {
/// Convert this internal move into the public [`SolverMove`] form
/// suitable for handing off to the engine layer. Cheap — `O(1)` field
/// rewrites with no allocation.
fn to_public(self) -> SolverMove {
match self {
InternalMove::TableauToTableau { from, to, count } => SolverMove {
source: PileType::Tableau(from),
dest: PileType::Tableau(to),
count,
},
InternalMove::TableauToFoundation { from, slot } => SolverMove {
source: PileType::Tableau(from),
dest: PileType::Foundation(slot),
count: 1,
},
InternalMove::WasteToTableau { to } => SolverMove {
source: PileType::Waste,
dest: PileType::Tableau(to),
count: 1,
},
InternalMove::WasteToFoundation { slot } => SolverMove {
source: PileType::Waste,
dest: PileType::Foundation(slot),
count: 1,
},
InternalMove::Draw => SolverMove {
source: PileType::Stock,
dest: PileType::Waste,
count: 1,
},
}
}
}
/// Compact replica of `GameState` tailored for the solver. Strips /// Compact replica of `GameState` tailored for the solver. Strips
/// undo / score / move-count tracking and replaces the `HashMap` of /// undo / score / move-count tracking and replaces the `HashMap` of
/// piles with fixed arrays so the canonical hash is cheap to compute. /// piles with fixed arrays so the canonical hash is cheap to compute.
@@ -232,8 +334,8 @@ impl SolverState {
/// The order matters — foundation moves shrink the search frontier /// The order matters — foundation moves shrink the search frontier
/// fastest, and stock-draws are the costliest. See the top-of-file /// fastest, and stock-draws are the costliest. See the top-of-file
/// algorithm note. /// algorithm note.
fn enumerate_moves(&self) -> Vec<SolverMove> { fn enumerate_moves(&self) -> Vec<InternalMove> {
let mut moves: Vec<SolverMove> = Vec::new(); let mut moves: Vec<InternalMove> = Vec::new();
// 1) Foundation moves from tableau tops. // 1) Foundation moves from tableau tops.
for (i, col) in self.tableau.iter().enumerate() { for (i, col) in self.tableau.iter().enumerate() {
@@ -246,7 +348,7 @@ impl SolverState {
&self.foundation[slot as usize], &self.foundation[slot as usize],
); );
if can_place_on_foundation(top, &foundation_pile) { if can_place_on_foundation(top, &foundation_pile) {
moves.push(SolverMove::TableauToFoundation { from: i, slot }); moves.push(InternalMove::TableauToFoundation { from: i, slot });
} }
} }
} }
@@ -260,7 +362,7 @@ impl SolverState {
&self.foundation[slot as usize], &self.foundation[slot as usize],
); );
if can_place_on_foundation(top, &foundation_pile) { if can_place_on_foundation(top, &foundation_pile) {
moves.push(SolverMove::WasteToFoundation { slot }); moves.push(InternalMove::WasteToFoundation { slot });
} }
} }
@@ -298,7 +400,7 @@ impl SolverState {
{ {
continue; continue;
} }
moves.push(SolverMove::TableauToTableau { from: src, to: dst, count }); moves.push(InternalMove::TableauToTableau { from: src, to: dst, count });
} }
} }
} }
@@ -308,7 +410,7 @@ impl SolverState {
for dst in 0..7usize { for dst in 0..7usize {
let dst_pile = Self::pile_view(PileType::Tableau(dst), &self.tableau[dst]); let dst_pile = Self::pile_view(PileType::Tableau(dst), &self.tableau[dst]);
if can_place_on_tableau(top, &dst_pile) { if can_place_on_tableau(top, &dst_pile) {
moves.push(SolverMove::WasteToTableau { to: dst }); moves.push(InternalMove::WasteToTableau { to: dst });
} }
} }
} }
@@ -326,7 +428,7 @@ impl SolverState {
let cycled_without_progress = let cycled_without_progress =
self.consecutive_draws > stock_cycle_len.saturating_add(1); self.consecutive_draws > stock_cycle_len.saturating_add(1);
if can_draw && !cycled_without_progress { if can_draw && !cycled_without_progress {
moves.push(SolverMove::Draw); moves.push(InternalMove::Draw);
} }
moves moves
@@ -334,11 +436,11 @@ impl SolverState {
/// Apply `mv` to `self`, returning the previous `consecutive_draws` /// Apply `mv` to `self`, returning the previous `consecutive_draws`
/// value so the caller can restore it on backtrack. /// value so the caller can restore it on backtrack.
fn apply_move(&mut self, mv: SolverMove) -> SolverStateUndo { fn apply_move(&mut self, mv: InternalMove) -> SolverStateUndo {
let prev_just_drew = self.just_drew; let prev_just_drew = self.just_drew;
let prev_consec = self.consecutive_draws; let prev_consec = self.consecutive_draws;
match mv { match mv {
SolverMove::TableauToTableau { from, to, count } => { InternalMove::TableauToTableau { from, to, count } => {
let start = self.tableau[from].len() - count; let start = self.tableau[from].len() - count;
let moved: Vec<Card> = self.tableau[from].split_off(start); let moved: Vec<Card> = self.tableau[from].split_off(start);
self.tableau[to].extend(moved); self.tableau[to].extend(moved);
@@ -351,7 +453,7 @@ impl SolverState {
self.just_drew = false; self.just_drew = false;
self.consecutive_draws = 0; self.consecutive_draws = 0;
} }
SolverMove::TableauToFoundation { from, slot } => { InternalMove::TableauToFoundation { from, slot } => {
if let Some(card) = self.tableau[from].pop() { if let Some(card) = self.tableau[from].pop() {
self.foundation[slot as usize].push(card); self.foundation[slot as usize].push(card);
if let Some(top) = self.tableau[from].last_mut() if let Some(top) = self.tableau[from].last_mut()
@@ -363,21 +465,21 @@ impl SolverState {
self.just_drew = false; self.just_drew = false;
self.consecutive_draws = 0; self.consecutive_draws = 0;
} }
SolverMove::WasteToTableau { to } => { InternalMove::WasteToTableau { to } => {
if let Some(card) = self.waste.pop() { if let Some(card) = self.waste.pop() {
self.tableau[to].push(card); self.tableau[to].push(card);
} }
self.just_drew = false; self.just_drew = false;
self.consecutive_draws = 0; self.consecutive_draws = 0;
} }
SolverMove::WasteToFoundation { slot } => { InternalMove::WasteToFoundation { slot } => {
if let Some(card) = self.waste.pop() { if let Some(card) = self.waste.pop() {
self.foundation[slot as usize].push(card); self.foundation[slot as usize].push(card);
} }
self.just_drew = false; self.just_drew = false;
self.consecutive_draws = 0; self.consecutive_draws = 0;
} }
SolverMove::Draw => { InternalMove::Draw => {
if self.stock.is_empty() { if self.stock.is_empty() {
// Recycle waste back to stock face-down, reversed. // Recycle waste back to stock face-down, reversed.
let mut recycled: Vec<Card> = self.waste.drain(..).collect(); let mut recycled: Vec<Card> = self.waste.drain(..).collect();
@@ -415,39 +517,55 @@ impl SolverState {
/// stack lives on the heap and grows only with `Vec` capacity, not /// stack lives on the heap and grows only with `Vec` capacity, not
/// with thread-stack pages. /// with thread-stack pages.
/// ///
/// Returns `true` as soon as a winning leaf is found. Sets /// Returns `Some(first_move)` (the move applied at the root that led
/// `*budget_exceeded = true` if either budget trips before a /// to a winning leaf) as soon as a winning leaf is found. Returns
/// verdict. /// `None` if the search exhausts (Unwinnable) or a budget trips —
/// callers distinguish those two cases via `*budget_exceeded`.
///
/// Path recording is implemented by stashing the root-level move on
/// each pushed frame and propagating it unchanged into deeper
/// children. Cost: one `Option<InternalMove>` (≤ 16 bytes) per
/// frame and one branch on push. Negligible on the hot path; the
/// new-game retry loop sees no measurable slowdown.
fn search( fn search(
self, self,
config: &SolverConfig, config: &SolverConfig,
visited: &mut HashSet<u64>, visited: &mut HashSet<u64>,
moves_consumed: &mut u64, moves_consumed: &mut u64,
budget_exceeded: &mut bool, budget_exceeded: &mut bool,
) -> bool { ) -> Option<SolverMove> {
// Each stack frame keeps a state plus the move iterator we // Each stack frame keeps a state plus the move iterator we
// haven't yet expanded. Popping a frame is the backtrack. // haven't yet expanded. Popping a frame is the backtrack.
struct Frame { struct Frame {
state: SolverState, state: SolverState,
pending: std::vec::IntoIter<SolverMove>, pending: std::vec::IntoIter<InternalMove>,
/// First move on the path from the root to this frame's
/// state. `None` for the root frame; populated when a child
/// frame is pushed. Propagates unchanged from parent to deeper
/// children so any winning leaf can read it directly.
root_move: Option<InternalMove>,
} }
// Quick exits before allocating the stack. // Quick exits before allocating the stack. An already-won state
// surfaces as Winnable with no move to recommend (the player has
// nothing left to do); the engine treats this gracefully —
// `is_won` callers gate H-key hints on `!is_won` already.
if self.is_won() { if self.is_won() {
return true; return None;
} }
if *moves_consumed >= config.move_budget || visited.len() >= config.state_budget { if *moves_consumed >= config.move_budget || visited.len() >= config.state_budget {
*budget_exceeded = true; *budget_exceeded = true;
return false; return None;
} }
let root_hash = self.canonical_hash(); let root_hash = self.canonical_hash();
if !visited.insert(root_hash) { if !visited.insert(root_hash) {
return false; return None;
} }
let root_moves = self.enumerate_moves(); let root_moves = self.enumerate_moves();
let mut stack: Vec<Frame> = Vec::new(); let mut stack: Vec<Frame> = Vec::new();
stack.push(Frame { stack.push(Frame {
state: self, state: self,
pending: root_moves.into_iter(), pending: root_moves.into_iter(),
root_move: None,
}); });
while let Some(frame) = stack.last_mut() { while let Some(frame) = stack.last_mut() {
@@ -457,7 +575,7 @@ impl SolverState {
|| visited.len() >= config.state_budget || visited.len() >= config.state_budget
{ {
*budget_exceeded = true; *budget_exceeded = true;
return false; return None;
} }
let Some(mv) = frame.pending.next() else { let Some(mv) = frame.pending.next() else {
// Exhausted this frame's children — backtrack. // Exhausted this frame's children — backtrack.
@@ -465,10 +583,15 @@ impl SolverState {
continue; continue;
}; };
*moves_consumed = moves_consumed.saturating_add(1); *moves_consumed = moves_consumed.saturating_add(1);
// Determine the root-level move for the *child* we are about
// to push: if the current frame is the root (root_move is
// None) then the child's root move is `mv` itself; otherwise
// it inherits from the parent.
let child_root_move = frame.root_move.unwrap_or(mv);
let mut next = frame.state.clone(); let mut next = frame.state.clone();
next.apply_move(mv); next.apply_move(mv);
if next.is_won() { if next.is_won() {
return true; return Some(child_root_move.to_public());
} }
let h = next.canonical_hash(); let h = next.canonical_hash();
if !visited.insert(h) { if !visited.insert(h) {
@@ -478,9 +601,74 @@ impl SolverState {
stack.push(Frame { stack.push(Frame {
state: next, state: next,
pending: next_moves.into_iter(), pending: next_moves.into_iter(),
root_move: Some(child_root_move),
}); });
} }
false None
}
/// Drive [`SolverState::search`] and convert the raw outcome into a
/// public [`SolveOutcome`]. Shared by [`try_solve_with_first_move`]
/// and [`try_solve_from_state`].
fn solve(self, config: &SolverConfig) -> SolveOutcome {
let mut visited: HashSet<u64> = HashSet::new();
let mut moves_consumed: u64 = 0;
let mut budget_exceeded = false;
let already_won = self.is_won();
let first_move = self.search(config, &mut visited, &mut moves_consumed, &mut budget_exceeded);
let result = if already_won || first_move.is_some() {
SolverResult::Winnable
} else if budget_exceeded {
SolverResult::Inconclusive
} else {
SolverResult::Unwinnable
};
SolveOutcome { result, first_move }
}
/// Build a `SolverState` from an in-progress [`GameState`].
///
/// Reads the live pile contents and `draw_mode`. Missing piles are
/// treated as empty — the engine's `GameState::new` always populates
/// every pile slot, but defensive code keeps this loader safe in the
/// face of partially-constructed test fixtures.
///
/// The search-metadata fields (`just_drew`, `consecutive_draws`)
/// reset to "no draws yet" — the solver is concerned with future
/// reachability from this position, not the engine's own draw
/// history.
fn from_game_state(game: &GameState) -> Self {
let tableau: [Vec<Card>; 7] = core::array::from_fn(|i| {
game.piles
.get(&PileType::Tableau(i))
.map(|p| p.cards.clone())
.unwrap_or_default()
});
let foundation: [Vec<Card>; 4] = core::array::from_fn(|i| {
game.piles
.get(&PileType::Foundation(i as u8))
.map(|p| p.cards.clone())
.unwrap_or_default()
});
let stock = game
.piles
.get(&PileType::Stock)
.map(|p| p.cards.clone())
.unwrap_or_default();
let waste = game
.piles
.get(&PileType::Waste)
.map(|p| p.cards.clone())
.unwrap_or_default();
Self {
tableau,
foundation,
stock,
waste,
draw_mode: game.draw_mode.clone(),
just_drew: false,
consecutive_draws: 0,
}
} }
/// Build a deterministic 64-bit hash of the visible game state. /// Build a deterministic 64-bit hash of the visible game state.
@@ -656,9 +844,9 @@ mod tests {
let mut moves_consumed: u64 = 0; let mut moves_consumed: u64 = 0;
let mut budget_exceeded = false; let mut budget_exceeded = false;
let cfg = SolverConfig::default(); let cfg = SolverConfig::default();
let won = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded); let first_move = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded);
assert!(won, "obviously-winnable position must be recognised as Winnable"); assert!(first_move.is_some(), "obviously-winnable position must be recognised as Winnable");
assert!(!budget_exceeded); assert!(!budget_exceeded);
assert!( assert!(
moves_consumed < 1000, moves_consumed < 1000,
@@ -699,8 +887,8 @@ mod tests {
let mut visited: HashSet<u64> = HashSet::new(); let mut visited: HashSet<u64> = HashSet::new();
let mut moves_consumed: u64 = 0; let mut moves_consumed: u64 = 0;
let mut budget_exceeded = false; let mut budget_exceeded = false;
let won = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded); let first_move = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded);
assert!(!won, "buried Ace under same-suit Two with no recovery must not solve"); assert!(first_move.is_none(), "buried Ace under same-suit Two with no recovery must not solve");
assert!(!budget_exceeded, "small synthetic state must complete within budget"); assert!(!budget_exceeded, "small synthetic state must complete within budget");
} }
@@ -890,4 +1078,170 @@ mod tests {
counts[0], counts[1], counts[2], counts[0], counts[1], counts[2],
); );
} }
// -----------------------------------------------------------------------
// First-move-recording API: try_solve_with_first_move /
// try_solve_from_state. Exercised by the engine hint system.
// -----------------------------------------------------------------------
/// A synthetic GameState with each foundation holding A..Q for its
/// suit, the four Kings sitting on tableau columns 0..3, empty stock
/// and empty waste. Exactly four legal moves exist — one Tableau→
/// Foundation per King — and any one of them is the first move on a
/// solution path.
fn near_finished_game_state() -> GameState {
use crate::card::Rank;
let mut game = GameState::new(1, DrawMode::DrawOne);
// Wipe every pile.
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();
// Foundations: A through Q for each suit. Slot 0=Clubs,
// 1=Diamonds, 2=Hearts, 3=Spades to match
// `target_foundation_slot` ordering.
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,
});
}
}
// Tableau 0..3: one King each, face-up.
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
}
#[test]
fn try_solve_with_first_move_returns_some_move_for_winnable_state() {
let game = near_finished_game_state();
let cfg = SolverConfig::default();
let outcome = try_solve_from_state(&game, &cfg);
assert_eq!(
outcome.result,
SolverResult::Winnable,
"near-finished state must solve as Winnable"
);
let mv = outcome.first_move.expect("Winnable must include a first_move");
// The first move must be a King going from a tableau column to
// its matching foundation slot. Single-card move.
assert_eq!(mv.count, 1);
assert!(matches!(mv.source, PileType::Tableau(c) if c < 4));
assert!(matches!(mv.dest, PileType::Foundation(s) if s < 4));
}
#[test]
fn try_solve_with_first_move_returns_none_for_unwinnable_state() {
use crate::card::Rank;
// The "buried Ace under same-suit Two with no recovery" fixture
// used by `solver_recognises_obviously_unwinnable_deal`, lifted
// into a real `GameState` so we can exercise `try_solve_from_state`.
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();
// Tableau 0: A♠ on the bottom, 2♠ on top — the 2♠ has no legal
// destination, so the Ace is buried forever.
let t0 = game.piles.get_mut(&PileType::Tableau(0)).unwrap();
t0.cards.push(Card { id: 0, suit: Suit::Spades, rank: Rank::Ace, face_up: true });
t0.cards.push(Card { id: 1, suit: Suit::Spades, rank: Rank::Two, face_up: true });
// Tableau 1: a face-up King with nothing else — irrelevant; the
// pruning check elides "King → empty" no-ops.
game.piles
.get_mut(&PileType::Tableau(1))
.unwrap()
.cards
.push(Card { id: 2, suit: Suit::Clubs, rank: Rank::King, face_up: true });
let cfg = SolverConfig::default();
let outcome = try_solve_from_state(&game, &cfg);
assert_eq!(
outcome.result,
SolverResult::Unwinnable,
"buried-Ace fixture must be proved Unwinnable"
);
assert!(
outcome.first_move.is_none(),
"Unwinnable verdict must carry first_move == None"
);
}
#[test]
fn try_solve_with_first_move_is_deterministic() {
// Same state run multiple times yields the same first_move.
let game = near_finished_game_state();
let cfg = SolverConfig::default();
let a = try_solve_from_state(&game, &cfg);
let b = try_solve_from_state(&game, &cfg);
let c = try_solve_from_state(&game, &cfg);
assert_eq!(a, b, "repeat solves must yield the same outcome");
assert_eq!(b, c);
}
#[test]
fn try_solve_with_first_move_seed_form_matches_state_form() {
// For a fresh seed, the two public entry points must agree —
// they share the same internal `solve()` implementation, but
// route through different state constructors. This is the
// smoke test that catches drift between them.
let cfg = SolverConfig {
move_budget: 5_000,
state_budget: 5_000,
};
let a = try_solve_with_first_move(7, DrawMode::DrawOne, &cfg);
let game = GameState::new(7, DrawMode::DrawOne);
let b = try_solve_from_state(&game, &cfg);
assert_eq!(a.result, b.result, "verdicts must match across the two entry points");
assert_eq!(a.first_move, b.first_move, "first_move must match across the two entry points");
}
} }
+2 -1
View File
@@ -141,7 +141,8 @@ pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
pub mod settings; pub mod settings;
pub use settings::{ pub use settings::{
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend, load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
Theme, WindowGeometry, SOLVER_DEAL_RETRY_CAP, TIME_BONUS_MULTIPLIER_MAX, 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, TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_MAX_SECS,
TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS, TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
}; };
+159 -3
View File
@@ -181,6 +181,17 @@ pub struct Settings {
/// solver retry loop — see `solitaire_engine::handle_new_game`. /// solver retry loop — see `solitaire_engine::handle_new_game`.
#[serde(default)] #[serde(default)]
pub winnable_deals_only: bool, 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 { fn default_draw_mode() -> DrawMode {
@@ -238,6 +249,33 @@ fn default_time_bonus_multiplier() -> f32 {
1.0 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`] /// Maximum number of seed retries [`solitaire_engine::handle_new_game`]
/// is willing to attempt before giving up and accepting the latest /// is willing to attempt before giving up and accepting the latest
/// candidate seed when [`Settings::winnable_deals_only`] is on. If /// candidate seed when [`Settings::winnable_deals_only`] is on. If
@@ -268,14 +306,16 @@ impl Default for Settings {
tooltip_delay_secs: default_tooltip_delay(), tooltip_delay_secs: default_tooltip_delay(),
time_bonus_multiplier: default_time_bonus_multiplier(), time_bonus_multiplier: default_time_bonus_multiplier(),
winnable_deals_only: false, winnable_deals_only: false,
replay_move_interval_secs: default_replay_move_interval_secs(),
} }
} }
} }
impl Settings { impl Settings {
/// Clamps `sfx_volume`, `music_volume`, `tooltip_delay_secs`, and /// Clamps `sfx_volume`, `music_volume`, `tooltip_delay_secs`,
/// `time_bonus_multiplier` into their respective ranges after /// `time_bonus_multiplier`, and `replay_move_interval_secs` into
/// deserialization or hand-editing of `settings.json`. /// their respective ranges after deserialization or hand-editing of
/// `settings.json`.
pub fn sanitized(self) -> Self { pub fn sanitized(self) -> Self {
Self { Self {
sfx_volume: self.sfx_volume.clamp(0.0, 1.0), sfx_volume: self.sfx_volume.clamp(0.0, 1.0),
@@ -286,6 +326,9 @@ impl Settings {
time_bonus_multiplier: self time_bonus_multiplier: self
.time_bonus_multiplier .time_bonus_multiplier
.clamp(TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX), .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 ..self
} }
} }
@@ -324,6 +367,21 @@ impl Settings {
self.time_bonus_multiplier = (raw * 10.0).round() / 10.0; self.time_bonus_multiplier = (raw * 10.0).round() / 10.0;
self.time_bonus_multiplier 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 /// Returns the platform-specific path to `settings.json`, or `None` if
@@ -456,6 +514,7 @@ mod tests {
tooltip_delay_secs: default_tooltip_delay(), tooltip_delay_secs: default_tooltip_delay(),
time_bonus_multiplier: default_time_bonus_multiplier(), time_bonus_multiplier: default_time_bonus_multiplier(),
winnable_deals_only: false, winnable_deals_only: false,
replay_move_interval_secs: default_replay_move_interval_secs(),
}; };
save_settings_to(&path, &s).expect("save"); save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path); let loaded = load_settings_from(&path);
@@ -908,4 +967,101 @@ mod tests {
"legacy settings.json missing winnable_deals_only must deserialize to false" "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
);
}
} }
+198 -64
View File
@@ -7,6 +7,7 @@
use std::path::PathBuf; use std::path::PathBuf;
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*; use bevy::prelude::*;
use chrono::{Local, Timelike, Utc}; use chrono::{Local, Timelike, Utc};
use solitaire_core::achievement::{ use solitaire_core::achievement::{
@@ -31,6 +32,7 @@ use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::stats_plugin::{StatsResource, StatsUpdate}; use crate::stats_plugin::{StatsResource, StatsUpdate};
use crate::ui_modal::{ use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
@@ -48,6 +50,19 @@ pub struct AchievementsScreen;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct AchievementRow; 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). /// All per-player achievement records (one per known achievement).
#[derive(Resource, Debug, Clone)] #[derive(Resource, Debug, Clone)]
pub struct AchievementsResource(pub Vec<AchievementRecord>); pub struct AchievementsResource(pub Vec<AchievementRecord>);
@@ -96,6 +111,11 @@ impl Plugin for AchievementPlugin {
.add_message::<XpAwardedEvent>() .add_message::<XpAwardedEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_message::<ToggleAchievementsRequestEvent>() .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 // Run after GameMutation (so GameWonEvent is available), after
// StatsUpdate (so stats reflect this win), and after ProgressUpdate // StatsUpdate (so stats reflect this win), and after ProgressUpdate
// (so daily_challenge_streak is up to date for daily_devotee). // (so daily_challenge_streak is up to date for daily_devotee).
@@ -118,6 +138,7 @@ impl Plugin for AchievementPlugin {
) )
.add_systems(Update, toggle_achievements_screen) .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 // Event-driven unlock: observe `ReplayPlaybackState` and unlock
// `cinephile` the first time playback runs to natural completion. // `cinephile` the first time playback runs to natural completion.
// Reads the resource via `Option<Res<_>>` so headless tests that // Reads the resource via `Option<Res<_>>` so headless tests that
@@ -395,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( fn spawn_achievements_screen(
commands: &mut Commands, commands: &mut Commands,
records: &[AchievementRecord], records: &[AchievementRecord],
@@ -421,78 +474,98 @@ fn spawn_achievements_screen(
..default() ..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); spawn_modal_header(card, header, font_res);
// Achievement rows — unlocked first, then locked alphabetical. // Scrollable body — the achievements list grows to ~19 rows which
let mut sorted: Vec<_> = records.iter().collect(); // overflows the modal on the 800x600 minimum window. Wrapping the
sorted.sort_by_key(|r| (!r.unlocked, r.id.clone())); // 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 { for record in &sorted {
let def = achievement_by_id(&record.id); 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. // Hide secret locked achievements so they remain a surprise.
let is_secret = def.is_some_and(|d| d.secret); let is_secret = def.is_some_and(|d| d.secret);
if is_secret && !record.unlocked { if is_secret && !record.unlocked {
continue; continue;
} }
let (name_color, desc_color, prefix) = if record.unlocked { let (name_color, desc_color, prefix) = if record.unlocked {
(ACCENT_PRIMARY, TEXT_PRIMARY, "\u{2713} ") (ACCENT_PRIMARY, TEXT_PRIMARY, "\u{2713} ")
} else { } else {
(TEXT_DISABLED, TEXT_DISABLED, "\u{25CB} ") (TEXT_DISABLED, TEXT_DISABLED, "\u{25CB} ")
}; };
let tooltip_text = tooltip_for_row(record.unlocked, def); let tooltip_text = tooltip_for_row(record.unlocked, def);
card.spawn(( body.spawn((
Node { Node {
flex_direction: FlexDirection::Column, flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_1, row_gap: VAL_SPACE_1,
..default() ..default()
}, },
AchievementRow, AchievementRow,
Tooltip::new(tooltip_text), Tooltip::new(tooltip_text),
)) ))
.with_children(|row| { .with_children(|row| {
row.spawn(( row.spawn((
Text::new(format!("{prefix}{name}")), Text::new(format!("{prefix}{name}")),
font_name.clone(), font_name.clone(),
TextColor(name_color), TextColor(name_color),
));
if !description.is_empty() {
row.spawn((
Text::new(format!(" {description}")),
font_desc.clone(),
TextColor(desc_color),
));
}
if let Some(reward_str) = def.and_then(|d| d.reward).map(format_reward) {
row.spawn((
Text::new(format!(" Reward: {reward_str}")),
font_meta.clone(),
TextColor(STATE_SUCCESS),
));
}
if let Some(date) = record.unlock_date {
row.spawn((
Text::new(format!(" Unlocked {}", date.format("%Y-%m-%d"))),
font_meta.clone(),
TextColor(TEXT_SECONDARY),
));
}
});
// Subtle row separator — keeps the long list scannable.
body.spawn((
Node {
height: Val::Px(1.0),
..default()
},
BackgroundColor(BORDER_SUBTLE),
)); ));
if !description.is_empty() { }
row.spawn(( });
Text::new(format!(" {description}")),
font_desc.clone(),
TextColor(desc_color),
));
}
if let Some(reward_str) = def.and_then(|d| d.reward).map(format_reward) {
row.spawn((
Text::new(format!(" Reward: {reward_str}")),
font_meta.clone(),
TextColor(STATE_SUCCESS),
));
}
if let Some(date) = record.unlock_date {
row.spawn((
Text::new(format!(" Unlocked {}", date.format("%Y-%m-%d"))),
font_meta.clone(),
TextColor(TEXT_SECONDARY),
));
}
});
// Subtle row separator — keeps the long list scannable.
card.spawn((
Node {
height: Val::Px(1.0),
..default()
},
BackgroundColor(BORDER_SUBTLE),
));
}
spawn_modal_actions(card, |actions| { spawn_modal_actions(card, |actions| {
spawn_modal_button( spawn_modal_button(
@@ -505,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 { fn format_reward(reward: Reward) -> String {
@@ -895,6 +971,64 @@ mod tests {
assert_eq!(count, 0); 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 // format_reward
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
+108 -19
View File
@@ -2,9 +2,19 @@
//! //!
//! **Cursor icons** (`update_cursor_icon`) //! **Cursor icons** (`update_cursor_icon`)
//! - Cards are being dragged → `Grabbing` (closed hand) //! - 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) //! - Cursor hovers over a face-up draggable card → `Grab` (open hand)
//! - Otherwise → `Default` (arrow) //! - 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`) //! **Drop-target highlights** (`update_drop_highlights`)
//! While a drag is in progress every `PileMarker` sprite is tinted: //! While a drag is in progress every `PileMarker` sprite is tinted:
//! - **Green** if the dragged stack can legally land there. //! - **Green** if the dragged stack can legally land there.
@@ -70,6 +80,31 @@ impl Plugin for CursorPlugin {
// #31 — Cursor icon // #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. /// Updates the primary-window cursor icon based on drag state and hover.
fn update_cursor_icon( fn update_cursor_icon(
drag: Res<DragState>, drag: Res<DragState>,
@@ -77,32 +112,39 @@ fn update_cursor_icon(
cameras: Query<(&Camera, &GlobalTransform)>, cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
game: Option<Res<GameStateResource>>, game: Option<Res<GameStateResource>>,
button_q: Query<&Interaction, With<Button>>,
mut commands: Commands, mut commands: Commands,
) { ) {
let Ok((win_entity, window)) = windows.single() else { return }; let Ok((win_entity, window)) = windows.single() else { return };
if !drag.is_idle() { let is_dragging = !drag.is_idle();
commands
.entity(win_entity)
.insert(CursorIcon::from(SystemCursorIcon::Grabbing));
return;
}
let hovering = (|| { // A UI button is "hovered" if any `Button` entity has its
let cursor = window.cursor_position()?; // `Interaction` set to `Hovered` or `Pressed`. We include
let (camera, cam_xf) = cameras.single().ok()?; // `Pressed` so the pointer icon stays visible while a click is
let world = camera.viewport_to_world_2d(cam_xf, cursor).ok()?; // being held, matching browser behaviour.
let layout = layout.as_ref()?.0.clone(); let any_button_hovered = button_q
let game = game.as_ref()?; .iter()
Some(cursor_over_draggable(world, &game.0, &layout)) .any(|i| matches!(i, Interaction::Hovered | Interaction::Pressed));
})()
.unwrap_or(false);
commands.entity(win_entity).insert(CursorIcon::from(if hovering { let any_card_hovered = if is_dragging || any_button_hovered {
SystemCursorIcon::Grab // No need to do the world-space hit test when a higher
// priority branch already wins.
false
} else { } else {
SystemCursorIcon::Default (|| {
})); let cursor = window.cursor_position()?;
let (camera, cam_xf) = cameras.single().ok()?;
let world = camera.viewport_to_world_2d(cam_xf, cursor).ok()?;
let layout = layout.as_ref()?.0.clone();
let game = game.as_ref()?;
Some(cursor_over_draggable(world, &game.0, &layout))
})()
.unwrap_or(false)
};
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. /// 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] #[test]
fn cursor_over_draggable_returns_false_for_empty_game() { fn cursor_over_draggable_returns_false_for_empty_game() {
use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::game_state::{DrawMode, GameState};
+145 -49
View File
@@ -4,12 +4,14 @@
//! is an optional accelerator. Listed shortcuts are grouped by intent — //! is an optional accelerator. Listed shortcuts are grouped by intent —
//! gameplay, modes, and overlays. //! gameplay, modes, and overlays.
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*; use bevy::prelude::*;
use crate::events::HelpRequestEvent; use crate::events::HelpRequestEvent;
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::ui_modal::{ use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
Z_MODAL_PANEL, BORDER_SUBTLE, RADIUS_SM, SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, 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)] #[derive(Component, Debug)]
pub struct HelpCloseButton; 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 /// Spawns and despawns the help / controls overlay shown when the player
/// clicks the "Help" HUD button or presses `F1`. All hotkeys and gesture /// clicks the "Help" HUD button or presses `F1`. All hotkeys and gesture
/// guides live here. /// guides live here.
@@ -32,7 +44,14 @@ pub struct HelpPlugin;
impl Plugin for HelpPlugin { impl Plugin for HelpPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_message::<HelpRequestEvent>() 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. /// Each entry in the controls reference table.
struct ControlRow { struct ControlRow {
keys: &'static str, keys: &'static str,
@@ -165,62 +210,80 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
..default() ..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); spawn_modal_header(card, "Controls", font_res);
for section in CONTROL_SECTIONS { // Scrollable body — the controls reference is six sections totalling
// Section title in muted text — distinguishes from row content. // ~28 rows, which overflows the modal on the 800x600 minimum
card.spawn(( // window. Wrapping in an `Overflow::scroll_y()` Node with a
Text::new(section.title), // constrained `max_height` keeps every row reachable; the Done
font_section.clone(), // button below stays fixed outside the scroll.
TextColor(TEXT_SECONDARY), 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.
body.spawn((
Text::new(section.title),
font_section.clone(),
TextColor(TEXT_SECONDARY),
));
// Each row is a flex-row: kbd-style chip + description. // Each row is a flex-row: kbd-style chip + description.
for row in section.rows { for row in section.rows {
card.spawn(Node { body.spawn(Node {
flex_direction: FlexDirection::Row, flex_direction: FlexDirection::Row,
align_items: AlignItems::Center, align_items: AlignItems::Center,
column_gap: VAL_SPACE_3, column_gap: VAL_SPACE_3,
..default() ..default()
}) })
.with_children(|line| { .with_children(|line| {
// The hotkey rendered as a small chip with a border — // The hotkey rendered as a small chip with a border —
// visual cue that it's a key reference, not part of // visual cue that it's a key reference, not part of
// the description text. // the description text.
line.spawn(( line.spawn((
Node { Node {
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1), padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
min_width: Val::Px(64.0), min_width: Val::Px(64.0),
justify_content: JustifyContent::Center, justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)), border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)), border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default() ..default()
}, },
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
)) ))
.with_children(|chip| { .with_children(|chip| {
chip.spawn(( chip.spawn((
Text::new(row.keys), Text::new(row.keys),
font_kbd.clone(), font_kbd.clone(),
TextColor(TEXT_PRIMARY),
));
});
line.spawn((
Text::new(row.description),
font_row.clone(),
TextColor(TEXT_PRIMARY), TextColor(TEXT_PRIMARY),
)); ));
}); });
line.spawn(( }
Text::new(row.description),
font_row.clone(), // Section spacer — small empty box. Keeps each section
TextColor(TEXT_PRIMARY), // visually grouped.
)); body.spawn(Node {
height: Val::Px(SPACE_2),
..default()
}); });
} }
});
// Section spacer — small empty box. Keeps each section
// visually grouped.
card.spawn(Node {
height: Val::Px(SPACE_2),
..default()
});
}
spawn_modal_actions(card, |actions| { spawn_modal_actions(card, |actions| {
spawn_modal_button( 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)] #[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] #[test]
fn pressing_f1_twice_closes_help_screen() { fn pressing_f1_twice_closes_help_screen() {
let mut app = headless_app(); let mut app = headless_app();
+4 -1
View File
@@ -26,6 +26,7 @@ use crate::progress_plugin::ProgressResource;
use crate::ui_focus::{Disabled, FocusGroup, Focusable}; use crate::ui_focus::{Disabled, FocusGroup, Focusable};
use crate::ui_modal::{ use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_STRONG, BORDER_SUBTLE, RADIUS_MD, STATE_INFO, 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. /// 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>) { 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); spawn_modal_header(card, "Choose a Mode", font_res);
for mode in [ 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 /// Tab-walk order for each mode card, matching the visual top-to-bottom
+257 -9
View File
@@ -54,6 +54,16 @@ use crate::time_attack_plugin::TimeAttackResource;
/// Z-depth used for cards while being dragged — above all resting cards. /// Z-depth used for cards while being dragged — above all resting cards.
const DRAG_Z: f32 = 500.0; 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 /// Shared countdown state for the new-game double-press confirmation
/// flow. /// flow.
/// ///
@@ -89,6 +99,7 @@ pub struct InputPlugin;
impl Plugin for InputPlugin { impl Plugin for InputPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<HintCycleIndex>() app.init_resource::<HintCycleIndex>()
.init_resource::<HintSolverConfig>()
.init_resource::<KeyboardConfirmState>() .init_resource::<KeyboardConfirmState>()
.add_message::<NewGameConfirmEvent>() .add_message::<NewGameConfirmEvent>()
.add_message::<StartZenRequestEvent>() .add_message::<StartZenRequestEvent>()
@@ -236,20 +247,34 @@ fn handle_keyboard_core(
// Esc is handled by `PausePlugin` (overlay toggle + paused flag). // Esc is handled by `PausePlugin` (overlay toggle + paused flag).
} }
/// Handles the H key: cycles through all available hints, highlighting the /// Handles the H key: surface the solver's provably-best first move when
/// source card yellow for 2 s and showing a descriptive toast. /// 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 /// The solver (`solitaire_core::solver::try_solve_from_state`) is run
/// moves are available a "No hints available" toast is shown instead. /// 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)] #[allow(clippy::too_many_arguments)]
fn handle_keyboard_hint( fn handle_keyboard_hint(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
game: Option<Res<GameStateResource>>, game: Option<Res<GameStateResource>>,
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
solver_config: Res<HintSolverConfig>,
mut hint_cycle: ResMut<HintCycleIndex>, mut hint_cycle: ResMut<HintCycleIndex>,
mut commands: Commands, mut commands: Commands,
mut card_entities: Query<(Entity, &CardEntity, &mut Sprite)>, card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
mut info_toast: MessageWriter<InfoToastEvent>, mut info_toast: MessageWriter<InfoToastEvent>,
mut hint_visual: MessageWriter<HintVisualEvent>, mut hint_visual: MessageWriter<HintVisualEvent>,
) { ) {
@@ -269,6 +294,25 @@ fn handle_keyboard_hint(
let Some(_layout_res) = layout else { return }; 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); let hints = all_hints(&g.0);
if hints.is_empty() { if hints.is_empty() {
info_toast.write(InfoToastEvent("No hints available".to_string())); 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. // Pick the hint at the current cycle index (wrapping) and advance.
let idx = hint_cycle.0 % hints.len(); let idx = hint_cycle.0 % hints.len();
hint_cycle.0 = hint_cycle.0.wrapping_add(1); 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 // When the hint points at the stock (draw suggestion) there is no
// face-up card to highlight — show a toast instead. // face-up card to highlight — show a toast instead.
// If the stock is empty, pressing D will recycle the waste rather // If the stock is empty, pressing D will recycle the waste rather
// than draw a card, so the toast text must reflect that. // than draw a card, so the toast text must reflect that.
if *from == PileType::Stock { if *from == PileType::Stock {
let stock_empty = g.0.piles let stock_empty = game.piles
.get(&PileType::Stock) .get(&PileType::Stock)
.is_some_and(|p| p.cards.is_empty()); .is_some_and(|p| p.cards.is_empty());
let msg = if stock_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. // 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)) .and_then(|p| p.cards.last().filter(|c| c.face_up))
.map(|c| c.id); .map(|c| c.id);
if let Some(card_id) = top_card_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". // player keeps thinking in suit terms; otherwise fall back to "foundation".
let msg = match to { let msg = match to {
PileType::Foundation(_) => { 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 { if let Some(suit) = claimed {
let suit_name = match suit { let suit_name = match suit {
Suit::Clubs => "Clubs", Suit::Clubs => "Clubs",
@@ -2125,5 +2184,194 @@ mod tests {
anim.end_z 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)"
);
}
} }
+160 -65
View File
@@ -9,6 +9,7 @@
//! When the provider does not support leaderboards (e.g. `LocalOnlyProvider`) //! When the provider does not support leaderboards (e.g. `LocalOnlyProvider`)
//! the panel shows "Not available" immediately. //! the panel shows "Not available" immediately.
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*; use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task}; use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use solitaire_data::settings::SyncBackend; use solitaire_data::settings::SyncBackend;
@@ -20,10 +21,11 @@ use crate::settings_plugin::SettingsResource;
use crate::sync_plugin::SyncProviderResource; use crate::sync_plugin::SyncProviderResource;
use crate::ui_modal::{ use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_INFO, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, 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)] #[derive(Component, Debug)]
pub struct LeaderboardScreen; 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. /// Marker on the "Opt In" button inside the leaderboard panel.
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct LeaderboardOptInButton; struct LeaderboardOptInButton;
@@ -98,6 +112,11 @@ impl Plugin for LeaderboardPlugin {
.init_resource::<OptInTask>() .init_resource::<OptInTask>()
.init_resource::<OptOutTask>() .init_resource::<OptOutTask>()
.add_message::<ToggleLeaderboardRequestEvent>() .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( .add_systems(
Update, Update,
( (
@@ -112,7 +131,8 @@ impl Plugin for LeaderboardPlugin {
poll_opt_out_task, poll_opt_out_task,
) )
.chain(), .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. /// 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( fn handle_leaderboard_close_button(
mut commands: Commands, mut commands: Commands,
close_buttons: Query<&Interaction, (With<LeaderboardCloseButton>, Changed<Interaction>)>, close_buttons: Query<&Interaction, (With<LeaderboardCloseButton>, Changed<Interaction>)>,
@@ -346,7 +393,7 @@ fn spawn_leaderboard_screen(
remote_available: bool, remote_available: bool,
font_res: Option<&FontResource>, 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); spawn_modal_header(card, "Leaderboard", font_res);
// Subhead — what the screen does + what the buttons control. // Subhead — what the screen does + what the buttons control.
@@ -420,76 +467,94 @@ fn spawn_leaderboard_screen(
BackgroundColor(BORDER_SUBTLE), BackgroundColor(BORDER_SUBTLE),
)); ));
match data { // Scrollable data section — caps at top 10 rows today, but on the
LeaderboardResource::Idle => { // 800x600 minimum window the header + caption + opt-in row + 10
card.spawn(( // entries crowds the modal. Wrapping in `Overflow::scroll_y()`
Text::new("Fetching\u{2026}"), // with a `max_height` keeps every entry reachable and survives
font_status.clone(), // any future expansion of the row cap.
TextColor(STATE_INFO), card.spawn((
)); LeaderboardScrollable,
} ScrollPosition::default(),
LeaderboardResource::Error(_) => { Node {
card.spawn(( flex_direction: FlexDirection::Column,
Text::new("Couldn't reach the leaderboard. Try again later."), row_gap: VAL_SPACE_2,
font_status.clone(), max_height: Val::Vh(50.0),
TextColor(TEXT_SECONDARY), overflow: Overflow::scroll_y(),
)); ..default()
} },
LeaderboardResource::Loaded(rows) if rows.is_empty() => { ))
card.spawn(( .with_children(|body| {
Text::new("No entries yet \u{2014} sync and opt in to appear here."), match data {
font_row.clone(), LeaderboardResource::Idle => {
TextColor(TEXT_SECONDARY), body.spawn((
)); Text::new("Fetching\u{2026}"),
} font_status.clone(),
LeaderboardResource::Loaded(rows) => { TextColor(STATE_INFO),
// Column headers ));
card.spawn(Node { }
flex_direction: FlexDirection::Row, LeaderboardResource::Error(_) => {
column_gap: VAL_SPACE_4, body.spawn((
..default() Text::new("Couldn't reach the leaderboard. Try again later."),
}) font_status.clone(),
.with_children(|row| { TextColor(TEXT_SECONDARY),
header_cell(row, "#", 30.0, &font_header); ));
header_cell(row, "Player", 160.0, &font_header); }
header_cell(row, "Best Score", 100.0, &font_header); LeaderboardResource::Loaded(rows) if rows.is_empty() => {
header_cell(row, "Fastest Win", 110.0, &font_header); body.spawn((
}); Text::new("No entries yet \u{2014} sync and opt in to appear here."),
font_row.clone(),
let mut sorted = rows.to_vec(); TextColor(TEXT_SECONDARY),
sorted.sort_by_key(|e| std::cmp::Reverse(e.best_score.unwrap_or(0))); ));
}
for (i, entry) in sorted.iter().take(10).enumerate() { LeaderboardResource::Loaded(rows) => {
// Top three get accent treatments to highlight the // Column headers
// podium without leaning on hand-picked metallic body.spawn(Node {
// colours that sit outside the token system.
let rank_color = match i {
0 => ACCENT_PRIMARY, // Balatro yellow for #1
1 | 2 => TEXT_PRIMARY,
_ => TEXT_SECONDARY,
};
let time_str = entry
.best_time_secs
.map_or_else(|| "-".to_string(), format_secs);
let score_str = entry
.best_score
.map_or_else(|| "-".to_string(), |s| s.to_string());
card.spawn(Node {
flex_direction: FlexDirection::Row, flex_direction: FlexDirection::Row,
column_gap: VAL_SPACE_4, column_gap: VAL_SPACE_4,
..default() ..default()
}) })
.with_children(|row| { .with_children(|row| {
data_cell(row, &format!("{}", i + 1), 30.0, rank_color, &font_row); header_cell(row, "#", 30.0, &font_header);
data_cell(row, &entry.display_name, 160.0, TEXT_PRIMARY, &font_row); header_cell(row, "Player", 160.0, &font_header);
data_cell(row, &score_str, 100.0, TEXT_PRIMARY, &font_row); header_cell(row, "Best Score", 100.0, &font_header);
data_cell(row, &time_str, 110.0, TEXT_PRIMARY, &font_row); header_cell(row, "Fastest Win", 110.0, &font_header);
}); });
let mut sorted = rows.to_vec();
sorted.sort_by_key(|e| std::cmp::Reverse(e.best_score.unwrap_or(0)));
for (i, entry) in sorted.iter().take(10).enumerate() {
// Top three get accent treatments to highlight the
// podium without leaning on hand-picked metallic
// colours that sit outside the token system.
let rank_color = match i {
0 => ACCENT_PRIMARY, // Balatro yellow for #1
1 | 2 => TEXT_PRIMARY,
_ => TEXT_SECONDARY,
};
let time_str = entry
.best_time_secs
.map_or_else(|| "-".to_string(), format_secs);
let score_str = entry
.best_score
.map_or_else(|| "-".to_string(), |s| s.to_string());
body.spawn(Node {
flex_direction: FlexDirection::Row,
column_gap: VAL_SPACE_4,
..default()
})
.with_children(|row| {
data_cell(row, &format!("{}", i + 1), 30.0, rank_color, &font_row);
data_cell(row, &entry.display_name, 160.0, TEXT_PRIMARY, &font_row);
data_cell(row, &score_str, 100.0, TEXT_PRIMARY, &font_row);
data_cell(row, &time_str, 110.0, TEXT_PRIMARY, &font_row);
});
}
} }
} }
} });
spawn_modal_actions(card, |actions| { spawn_modal_actions(card, |actions| {
spawn_modal_button( 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) { fn header_cell(parent: &mut ChildSpawnerCommands, text: &str, width: f32, font: &TextFont) {
@@ -646,6 +713,34 @@ mod tests {
assert_eq!(count, 1); 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] #[test]
fn pressing_l_twice_dismisses_screen() { fn pressing_l_twice_dismisses_screen() {
let mut app = headless_app(); let mut app = headless_app();
+271 -168
View File
@@ -4,6 +4,7 @@
//! summary in a single scrollable panel. Spawned on the first `P` keypress and //! summary in a single scrollable panel. Spawned on the first `P` keypress and
//! despawned on the second. //! despawned on the second.
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use chrono::{Duration, Local, NaiveDate}; 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::stats_plugin::{format_fastest_win, format_win_rate, StatsResource};
use crate::ui_modal::{ use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, SPACE_1, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY, ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, SPACE_1, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY,
@@ -60,10 +62,60 @@ pub struct ProfilePlugin;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct ProfileCloseButton; 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 { impl Plugin for ProfilePlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_message::<ToggleProfileRequestEvent>() 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,186 +185,205 @@ fn spawn_profile_screen(
..default() ..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); spawn_modal_header(card, "Profile", font_res);
// First-launch welcome — only when the player has zero XP and // Scrollable body — the Profile panel renders sync info,
// zero daily streak, so the profile doesn't read as a wall of // progression (incl. a 14-day calendar), every unlocked
// zeros to a brand-new player. // achievement (up to ~18), and a stats summary, which can
if let Some(p) = progress // overflow the modal on the 800x600 minimum window once the
&& p.0.total_xp == 0 // player has unlocked several achievements. The Done action
&& p.0.daily_challenge_streak == 0 // stays fixed outside the scroll.
{ card.spawn((
card.spawn(( ProfileScrollable,
Text::new("Welcome! Play games to earn XP and unlock achievements."), ScrollPosition::default(),
font_section.clone(), Node {
TextColor(ACCENT_PRIMARY), flex_direction: FlexDirection::Column,
Node { row_gap: VAL_SPACE_1,
margin: UiRect { max_height: Val::Vh(70.0),
bottom: VAL_SPACE_2, 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.
if let Some(p) = progress
&& p.0.total_xp == 0
&& p.0.daily_challenge_streak == 0
{
body.spawn((
Text::new("Welcome! Play games to earn XP and unlock achievements."),
font_section.clone(),
TextColor(ACCENT_PRIMARY),
Node {
margin: UiRect {
bottom: VAL_SPACE_2,
..default()
},
..default() ..default()
}, },
..default()
},
));
}
// ── Sync section ────────────────────────────────────────────
card.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((
Text::new(format!("Account: {username} | Backend: {backend_name}")),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
}
if let Some(ss) = sync_status {
let status_text = match &ss.0 {
SyncStatus::Idle => "Sync: idle".to_string(),
SyncStatus::Syncing => "Sync: syncing\u{2026}".to_string(),
SyncStatus::LastSynced(dt) => {
format!("Last synced: {}", dt.format("%Y-%m-%d %H:%M"))
}
SyncStatus::Error(e) => format!("Sync error: {e}"),
};
card.spawn((
Text::new(status_text),
font_row.clone(),
TextColor(TEXT_SECONDARY),
));
}
// ── Progression section ─────────────────────────────────────
spawn_spacer(card, VAL_SPACE_2);
card.spawn((
Text::new("Progression"),
font_section.clone(),
TextColor(STATE_INFO),
));
if let Some(p) = progress {
let prog = &p.0;
let (xp_span, xp_done) = xp_progress(prog.total_xp, prog.level);
let pct = if xp_span == 0 {
100u64
} else {
xp_done.saturating_mul(100).checked_div(xp_span).unwrap_or(100)
};
card.spawn((
Text::new(format!(
"Level {} \u{2014} {} XP ({}/{} to next, {}%)",
prog.level, prog.total_xp, xp_done, xp_span, pct
)),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
card.spawn((
Text::new(format!(
"Daily streak: {} | Card backs: {} | Backgrounds: {}",
prog.daily_challenge_streak,
prog.unlocked_card_backs.len(),
prog.unlocked_backgrounds.len(),
)),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
// 14-day daily-challenge calendar row.
spawn_daily_calendar(
card,
&prog.daily_challenge_history,
prog.daily_challenge_streak,
prog.daily_challenge_longest_streak,
Local::now().date_naive(),
font_res,
);
}
// ── Achievements section ────────────────────────────────────
spawn_spacer(card, VAL_SPACE_2);
card.spawn((
Text::new("Achievements"),
font_section.clone(),
TextColor(STATE_INFO),
));
if let Some(ar) = achievements {
let records = &ar.0;
let unlocked_count = records.iter().filter(|r| r.unlocked).count();
card.spawn((
Text::new(format!("{unlocked_count} / 18 unlocked")),
font_row.clone(),
TextColor(ACCENT_PRIMARY),
));
let mut any_unlocked = false;
for record in records {
let def = achievement_by_id(record.id.as_str());
let is_secret = def.is_some_and(|d| d.secret);
if is_secret && !record.unlocked {
continue;
}
if !record.unlocked {
continue;
}
any_unlocked = true;
let name = def.map_or(record.id.as_str(), |d| d.name);
let date_str = match record.unlock_date {
Some(dt) => format!(" ({})", dt.format("%Y-%m-%d")),
None => String::new(),
};
card.spawn((
Text::new(format!(" [x] {name}{date_str}")),
font_row.clone(),
TextColor(STATE_SUCCESS),
)); ));
} }
if !any_unlocked {
card.spawn(( // ── Sync section ────────────────────────────────────────────
Text::new(" No achievements unlocked yet."), 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);
body.spawn((
Text::new(format!("Account: {username} | Backend: {backend_name}")),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
}
if let Some(ss) = sync_status {
let status_text = match &ss.0 {
SyncStatus::Idle => "Sync: idle".to_string(),
SyncStatus::Syncing => "Sync: syncing\u{2026}".to_string(),
SyncStatus::LastSynced(dt) => {
format!("Last synced: {}", dt.format("%Y-%m-%d %H:%M"))
}
SyncStatus::Error(e) => format!("Sync error: {e}"),
};
body.spawn((
Text::new(status_text),
font_row.clone(), font_row.clone(),
TextColor(TEXT_SECONDARY), TextColor(TEXT_SECONDARY),
)); ));
} }
}
// ── Statistics summary section ────────────────────────────── // ── Progression section ─────────────────────────────────────
spawn_spacer(card, VAL_SPACE_2); spawn_spacer(body, VAL_SPACE_2);
card.spawn(( body.spawn((
Text::new("Statistics Summary"), Text::new("Progression"),
font_section.clone(), font_section.clone(),
TextColor(STATE_INFO), TextColor(STATE_INFO),
));
if let Some(sr) = stats {
let s = &sr.0;
let best_score_str = if s.best_single_score == 0 {
"\u{2014}".to_string()
} else {
s.best_single_score.to_string()
};
card.spawn((
Text::new(format!(
"Played: {} | Won: {} | Win rate: {} | Best time: {}",
s.games_played,
s.games_won,
format_win_rate(s),
format_fastest_win(s.fastest_win_seconds),
)),
font_row.clone(),
TextColor(TEXT_PRIMARY),
)); ));
card.spawn(( if let Some(p) = progress {
Text::new(format!( let prog = &p.0;
"Win streak: {} current, {} best | Best score: {}", let (xp_span, xp_done) = xp_progress(prog.total_xp, prog.level);
s.win_streak_current, s.win_streak_best, best_score_str, let pct = if xp_span == 0 {
)), 100u64
font_row.clone(), } else {
TextColor(TEXT_PRIMARY), xp_done.saturating_mul(100).checked_div(xp_span).unwrap_or(100)
};
body.spawn((
Text::new(format!(
"Level {} \u{2014} {} XP ({}/{} to next, {}%)",
prog.level, prog.total_xp, xp_done, xp_span, pct
)),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
body.spawn((
Text::new(format!(
"Daily streak: {} | Card backs: {} | Backgrounds: {}",
prog.daily_challenge_streak,
prog.unlocked_card_backs.len(),
prog.unlocked_backgrounds.len(),
)),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
// 14-day daily-challenge calendar row.
spawn_daily_calendar(
body,
&prog.daily_challenge_history,
prog.daily_challenge_streak,
prog.daily_challenge_longest_streak,
Local::now().date_naive(),
font_res,
);
}
// ── Achievements section ────────────────────────────────────
spawn_spacer(body, VAL_SPACE_2);
body.spawn((
Text::new("Achievements"),
font_section.clone(),
TextColor(STATE_INFO),
)); ));
} if let Some(ar) = achievements {
let records = &ar.0;
let unlocked_count = records.iter().filter(|r| r.unlocked).count();
body.spawn((
Text::new(format!("{unlocked_count} / 18 unlocked")),
font_row.clone(),
TextColor(ACCENT_PRIMARY),
));
let mut any_unlocked = false;
for record in records {
let def = achievement_by_id(record.id.as_str());
let is_secret = def.is_some_and(|d| d.secret);
if is_secret && !record.unlocked {
continue;
}
if !record.unlocked {
continue;
}
any_unlocked = true;
let name = def.map_or(record.id.as_str(), |d| d.name);
let date_str = match record.unlock_date {
Some(dt) => format!(" ({})", dt.format("%Y-%m-%d")),
None => String::new(),
};
body.spawn((
Text::new(format!(" [x] {name}{date_str}")),
font_row.clone(),
TextColor(STATE_SUCCESS),
));
}
if !any_unlocked {
body.spawn((
Text::new(" No achievements unlocked yet."),
font_row.clone(),
TextColor(TEXT_SECONDARY),
));
}
}
// ── Statistics summary section ──────────────────────────────
spawn_spacer(body, VAL_SPACE_2);
body.spawn((
Text::new("Statistics Summary"),
font_section.clone(),
TextColor(STATE_INFO),
));
if let Some(sr) = stats {
let s = &sr.0;
let best_score_str = if s.best_single_score == 0 {
"\u{2014}".to_string()
} else {
s.best_single_score.to_string()
};
body.spawn((
Text::new(format!(
"Played: {} | Won: {} | Win rate: {} | Best time: {}",
s.games_played,
s.games_won,
format_win_rate(s),
format_fastest_win(s.fastest_win_seconds),
)),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
body.spawn((
Text::new(format!(
"Win streak: {} current, {} best | Best score: {}",
s.win_streak_current, s.win_streak_best, best_score_str,
)),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
}
});
spawn_modal_actions(card, |actions| { spawn_modal_actions(card, |actions| {
spawn_modal_button( 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. /// 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] #[test]
fn pressing_p_twice_closes_profile_screen() { fn pressing_p_twice_closes_profile_screen() {
let mut app = headless_app(); let mut app = headless_app();
+154 -3
View File
@@ -45,11 +45,34 @@ use solitaire_data::{Replay, ReplayMove};
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent}; use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent};
use crate::game_plugin::{GameMutation, RecordingReplay}; use crate::game_plugin::{GameMutation, RecordingReplay};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::settings_plugin::SettingsResource;
/// Per-move duration during playback. Tunable in Settings later; /// Default per-move duration during playback, in seconds. Acts as the
/// hardcoded for v1. /// 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; 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 /// How long the [`ReplayPlaybackState::Completed`] state lingers before
/// the auto-clear system transitions it back to /// the auto-clear system transitions it back to
/// [`ReplayPlaybackState::Inactive`]. Gives the overlay UI time to /// [`ReplayPlaybackState::Inactive`]. Gives the overlay UI time to
@@ -161,6 +184,12 @@ pub fn start_replay_playback(
let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode.clone(), replay.mode); let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode.clone(), replay.mode);
commands.insert_resource(GameStateResource(fresh)); 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 { **state = ReplayPlaybackState::Playing {
replay, replay,
cursor: 0, cursor: 0,
@@ -207,11 +236,13 @@ pub fn stop_replay_playback(
/// so the loop runs at most once per frame. /// so the loop runs at most once per frame.
fn tick_replay_playback( fn tick_replay_playback(
time: Res<Time>, time: Res<Time>,
settings: Option<Res<SettingsResource>>,
mut state: ResMut<ReplayPlaybackState>, mut state: ResMut<ReplayPlaybackState>,
mut moves_writer: MessageWriter<MoveRequestEvent>, mut moves_writer: MessageWriter<MoveRequestEvent>,
mut draws_writer: MessageWriter<DrawRequestEvent>, mut draws_writer: MessageWriter<DrawRequestEvent>,
) { ) {
let dt = time.delta_secs(); let dt = time.delta_secs();
let interval = current_move_interval_secs(settings.as_deref());
let mut transition_to_completed = false; let mut transition_to_completed = false;
if let ReplayPlaybackState::Playing { if let ReplayPlaybackState::Playing {
@@ -235,7 +266,7 @@ fn tick_replay_playback(
} }
} }
*cursor += 1; *cursor += 1;
*secs_to_next += REPLAY_MOVE_INTERVAL_SECS; *secs_to_next += interval;
} }
if *cursor >= replay.moves.len() { if *cursor >= replay.moves.len() {
@@ -679,4 +710,124 @@ mod tests {
"recording must not grow while playback is active", "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}",
);
}
} }
+121 -2
View File
@@ -18,7 +18,8 @@ use bevy::window::{WindowMoved, WindowResized};
use solitaire_core::game_state::DrawMode; use solitaire_core::game_state::DrawMode;
use solitaire_data::{ use solitaire_data::{
load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings, 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}; use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent};
@@ -132,6 +133,12 @@ struct TooltipDelayText;
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct TimeBonusMultiplierText; 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" /// Marks the `Text` node showing the current "Winnable deals only"
/// state ("ON" / "OFF") in the Gameplay section. /// state ("ON" / "OFF") in the Gameplay section.
#[derive(Component, Debug)] #[derive(Component, Debug)]
@@ -179,6 +186,12 @@ enum SettingsButton {
TimeBonusDown, TimeBonusDown,
/// Increment the cosmetic time-bonus multiplier by one step. /// Increment the cosmetic time-bonus multiplier by one step.
TimeBonusUp, 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, ToggleTheme,
ToggleColorBlind, ToggleColorBlind,
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new /// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
@@ -219,8 +232,12 @@ impl SettingsButton {
SettingsButton::TooltipDelayUp => 46, SettingsButton::TooltipDelayUp => 46,
SettingsButton::TimeBonusDown => 47, SettingsButton::TimeBonusDown => 47,
SettingsButton::TimeBonusUp => 48, 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 // Cosmetic section
SettingsButton::ToggleTheme => 50, SettingsButton::ToggleTheme => 55,
SettingsButton::ToggleColorBlind => 60, SettingsButton::ToggleColorBlind => 60,
// Picker rows — every swatch in a row shares the row's // Picker rows — every swatch in a row shares the row's
// priority so entity-index tiebreaking yields left → right. // priority so entity-index tiebreaking yields left → right.
@@ -310,6 +327,7 @@ impl Plugin for SettingsPlugin {
update_color_blind_text, update_color_blind_text,
update_tooltip_delay_text, update_tooltip_delay_text,
update_time_bonus_multiplier_text, update_time_bonus_multiplier_text,
update_replay_move_interval_text,
update_winnable_deals_only_text, update_winnable_deals_only_text,
attach_focusable_to_settings_buttons, attach_focusable_to_settings_buttons,
scroll_focus_into_view, scroll_focus_into_view,
@@ -605,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 { fn card_back_label(idx: usize) -> String {
if idx == 0 { if idx == 0 {
"Default".to_string() "Default".to_string()
@@ -765,6 +798,29 @@ fn handle_settings_buttons(
changed.write(SettingsChangedEvent(settings.0.clone())); 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 => { SettingsButton::ToggleTheme => {
settings.0.theme = match settings.0.theme { settings.0.theme = match settings.0.theme {
Theme::Green => Theme::Blue, Theme::Green => Theme::Blue,
@@ -876,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 /// Auto-attaches [`Focusable`] to every bespoke Settings button — icon
/// buttons (volume +/, toggle, cycle), swatch buttons (card-back, /// buttons (volume +/, toggle, cycle), swatch buttons (card-back,
/// background pickers), and the "Sync Now" button. The "Done" button is /// background pickers), and the "Sync Now" button. The "Done" button is
@@ -1228,6 +1292,11 @@ fn spawn_settings_panel(
settings.time_bonus_multiplier, settings.time_bonus_multiplier,
font_res, font_res,
); );
replay_move_interval_row(
body,
settings.replay_move_interval_secs,
font_res,
);
// --- Cosmetic --- // --- Cosmetic ---
section_label(body, "Cosmetic", font_res); section_label(body, "Cosmetic", font_res);
@@ -1462,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, /// `Label Value [⇄]` — used for cycle/toggle rows (draw mode, theme,
/// anim speed, colour-blind). /// anim speed, colour-blind).
/// ///
+245 -148
View File
@@ -8,6 +8,7 @@
use std::path::PathBuf; use std::path::PathBuf;
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_data::{ use solitaire_data::{
@@ -28,6 +29,7 @@ use crate::resources::GameStateResource;
use crate::time_attack_plugin::TimeAttackResource; use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_modal::{ use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, STATE_WARNING, STREAK_MILESTONES, ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, STATE_WARNING, STREAK_MILESTONES,
@@ -118,6 +120,18 @@ pub struct ReplaySelectorCaption;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct PerModeBestsRow; 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. /// Registers stats resources, update systems, and the UI toggle.
pub struct StatsPlugin { pub struct StatsPlugin {
/// Where to persist stats. `None` disables all file I/O (for tests). /// Where to persist stats. `None` disables all file I/O (for tests).
@@ -167,6 +181,10 @@ impl Plugin for StatsPlugin {
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_message::<ToggleStatsRequestEvent>() .add_message::<ToggleStatsRequestEvent>()
.add_message::<WinStreakMilestoneEvent>() .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 // record_abandoned must read `move_count` BEFORE handle_new_game
// clobbers it with a fresh game. These are NOT in StatsUpdate because // clobbers it with a fresh game. These are NOT in StatsUpdate because
// StatsUpdate (as a set) is ordered after GameMutation by external // StatsUpdate (as a set) is ordered after GameMutation by external
@@ -195,7 +213,34 @@ impl Plugin for StatsPlugin {
.add_systems( .add_systems(
Update, Update,
(handle_replay_selector_buttons, repaint_replay_selector_caption).chain(), (handle_replay_selector_buttons, repaint_replay_selector_caption).chain(),
); )
.add_systems(Update, scroll_stats_panel);
}
}
/// 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);
} }
} }
@@ -558,107 +603,51 @@ fn spawn_stats_screen(
..default() ..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); spawn_modal_header(card, "Statistics", font_res);
// First-launch caption — sits above the grid as gentle nudge so // Scrollable body — the Stats panel renders an 8-cell grid plus
// the wall of em-dashes reads as "nothing to track yet" rather // multiple sections (per-mode bests, progression, weekly goals,
// than as broken state. // unlocks, optional Time Attack, latest replay caption) and
if is_first_launch { // overflows the modal on the 800x600 minimum window. Wrapping
card.spawn(( // in an `Overflow::scroll_y()` Node with a constrained
Text::new("Play a game to start tracking stats."), // `max_height` keeps every cell reachable; the Watch Replay /
TextFont { // Done action row stays fixed outside the scroll.
font_size: TYPE_CAPTION, card.spawn((
..default() StatsScrollable,
}, ScrollPosition::default(),
TextColor(TEXT_SECONDARY), Node {
Node { flex_direction: FlexDirection::Column,
margin: UiRect { row_gap: VAL_SPACE_3,
bottom: VAL_SPACE_2, 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 {
body.spawn((
Text::new("Play a game to start tracking stats."),
TextFont {
font_size: TYPE_CAPTION,
..default() ..default()
}, },
..default() TextColor(TEXT_SECONDARY),
}, Node {
)); margin: UiRect {
} bottom: VAL_SPACE_2,
..default()
},
..default()
},
));
}
// --- primary stat cells grid --- // --- primary stat cells grid ---
card.spawn(Node { body.spawn(Node {
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
justify_content: JustifyContent::Center,
align_items: AlignItems::FlexStart,
column_gap: VAL_SPACE_4,
row_gap: VAL_SPACE_3,
width: Val::Percent(100.0),
..default()
})
.with_children(|grid| {
spawn_stat_cell(grid, &win_rate_str, "Win Rate");
spawn_stat_cell(grid, &played_str, "Games Played");
spawn_stat_cell(grid, &won_str, "Games Won");
spawn_stat_cell(grid, &lost_str, "Games Lost");
spawn_stat_cell(grid, &fastest_str, "Fastest Win");
spawn_stat_cell(grid, &avg_time_str, "Avg Time");
spawn_stat_cell(grid, &best_score_str, "Best Score");
spawn_stat_cell(grid, &best_streak_str, "Best Streak");
});
// --- per-mode bests section ---
// Three rows, one per supported mode. Time Attack uses session-level
// 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((
Text::new("Per-mode bests"),
font_section.clone(),
TextColor(STATE_INFO),
));
card.spawn(Node {
flex_direction: FlexDirection::Column,
width: Val::Percent(100.0),
row_gap: VAL_SPACE_2,
..default()
})
.with_children(|column| {
spawn_per_mode_bests_row(
column,
"Classic",
stats.classic_best_score,
stats.classic_fastest_win_seconds,
&font_row,
);
spawn_per_mode_bests_row(
column,
"Zen",
stats.zen_best_score,
stats.zen_fastest_win_seconds,
&font_row,
);
spawn_per_mode_bests_row(
column,
"Challenge",
stats.challenge_best_score,
stats.challenge_fastest_win_seconds,
&font_row,
);
});
// --- progression section ---
if let Some(p) = progress {
card.spawn((
Text::new("Progression"),
font_section.clone(),
TextColor(STATE_INFO),
));
let level_str = format_stat_value(p.level);
let xp_str = format_stat_value(p.total_xp as u32);
let next_label = xp_to_next_level_label(p.total_xp, p.level);
let daily_str = format_stat_value(p.daily_challenge_streak);
let challenge_str = challenge_progress_label(p.challenge_index);
card.spawn(Node {
flex_direction: FlexDirection::Row, flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap, flex_wrap: FlexWrap::Wrap,
justify_content: JustifyContent::Center, justify_content: JustifyContent::Center,
@@ -669,68 +658,144 @@ fn spawn_stats_screen(
..default() ..default()
}) })
.with_children(|grid| { .with_children(|grid| {
spawn_stat_cell(grid, &level_str, "Level"); spawn_stat_cell(grid, &win_rate_str, "Win Rate");
spawn_stat_cell(grid, &xp_str, "Total XP"); spawn_stat_cell(grid, &played_str, "Games Played");
spawn_stat_cell(grid, &next_label, "Next Level"); spawn_stat_cell(grid, &won_str, "Games Won");
spawn_stat_cell(grid, &daily_str, "Daily Streak"); spawn_stat_cell(grid, &lost_str, "Games Lost");
spawn_stat_cell(grid, &challenge_str, "Challenge"); spawn_stat_cell(grid, &fastest_str, "Fastest Win");
spawn_stat_cell(grid, &avg_time_str, "Avg Time");
spawn_stat_cell(grid, &best_score_str, "Best Score");
spawn_stat_cell(grid, &best_streak_str, "Best Streak");
}); });
// Weekly goals // --- per-mode bests section ---
card.spawn(( // Three rows, one per supported mode. Time Attack uses session-level
Text::new("Weekly Goals"), // 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.
body.spawn((
Text::new("Per-mode bests"),
font_section.clone(), font_section.clone(),
TextColor(TEXT_SECONDARY), TextColor(STATE_INFO),
)); ));
for goal in WEEKLY_GOALS { body.spawn(Node {
let pv = p.weekly_goal_progress.get(goal.id).copied().unwrap_or(0); flex_direction: FlexDirection::Column,
card.spawn(( width: Val::Percent(100.0),
Text::new(format!(" {}: {}/{}", goal.description, pv, goal.target)), row_gap: VAL_SPACE_2,
..default()
})
.with_children(|column| {
spawn_per_mode_bests_row(
column,
"Classic",
stats.classic_best_score,
stats.classic_fastest_win_seconds,
&font_row,
);
spawn_per_mode_bests_row(
column,
"Zen",
stats.zen_best_score,
stats.zen_fastest_win_seconds,
&font_row,
);
spawn_per_mode_bests_row(
column,
"Challenge",
stats.challenge_best_score,
stats.challenge_fastest_win_seconds,
&font_row,
);
});
// --- progression section ---
if let Some(p) = progress {
body.spawn((
Text::new("Progression"),
font_section.clone(),
TextColor(STATE_INFO),
));
let level_str = format_stat_value(p.level);
let xp_str = format_stat_value(p.total_xp as u32);
let next_label = xp_to_next_level_label(p.total_xp, p.level);
let daily_str = format_stat_value(p.daily_challenge_streak);
let challenge_str = challenge_progress_label(p.challenge_index);
body.spawn(Node {
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
justify_content: JustifyContent::Center,
align_items: AlignItems::FlexStart,
column_gap: VAL_SPACE_4,
row_gap: VAL_SPACE_3,
width: Val::Percent(100.0),
..default()
})
.with_children(|grid| {
spawn_stat_cell(grid, &level_str, "Level");
spawn_stat_cell(grid, &xp_str, "Total XP");
spawn_stat_cell(grid, &next_label, "Next Level");
spawn_stat_cell(grid, &daily_str, "Daily Streak");
spawn_stat_cell(grid, &challenge_str, "Challenge");
});
// Weekly goals
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);
body.spawn((
Text::new(format!(" {}: {}/{}", goal.description, pv, goal.target)),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
}
// Unlocks line
body.spawn((
Text::new(format!(
"Card Backs: {} | Backgrounds: {}",
format_id_list(&p.unlocked_card_backs),
format_id_list(&p.unlocked_backgrounds),
)),
font_row.clone(), font_row.clone(),
TextColor(TEXT_PRIMARY), TextColor(TEXT_SECONDARY),
)); ));
} }
// Unlocks line // --- Time Attack section ---
card.spawn(( if let Some(ta) = time_attack
Text::new(format!( && ta.active {
"Card Backs: {} | Backgrounds: {}", let mins = (ta.remaining_secs / 60.0).floor() as u64;
format_id_list(&p.unlocked_card_backs), let secs = (ta.remaining_secs % 60.0).floor() as u64;
format_id_list(&p.unlocked_backgrounds), body.spawn((
)), Text::new(format!(
"Time Attack \u{2014} {mins}m {secs:02}s left | Wins: {}",
ta.wins
)),
font_section.clone(),
TextColor(STATE_WARNING),
));
}
// --- Latest replay caption ---
// Surfaces the most recent winning game so the player can spot
// whether their last victory has been recorded. The Watch
// Replay action below is what the player clicks to revisit it.
let replay_caption = match latest_replay {
Some(r) => format!("Latest win: {}", format_replay_caption(r)),
None => "No replay recorded yet \u{2014} win a game first.".to_string(),
};
body.spawn((
Text::new(replay_caption),
font_row.clone(), font_row.clone(),
TextColor(TEXT_SECONDARY), TextColor(TEXT_SECONDARY),
)); ));
} });
// --- Time Attack section ---
if let Some(ta) = time_attack
&& ta.active {
let mins = (ta.remaining_secs / 60.0).floor() as u64;
let secs = (ta.remaining_secs % 60.0).floor() as u64;
card.spawn((
Text::new(format!(
"Time Attack \u{2014} {mins}m {secs:02}s left | Wins: {}",
ta.wins
)),
font_section.clone(),
TextColor(STATE_WARNING),
));
}
// --- Latest replay caption ---
// Surfaces the most recent winning game so the player can spot
// whether their last victory has been recorded. The Watch
// Replay action below is what the player clicks to revisit it.
let replay_caption = match latest_replay {
Some(r) => format!("Latest win: {}", format_replay_caption(r)),
None => "No replay recorded yet \u{2014} win a game first.".to_string(),
};
card.spawn((
Text::new(replay_caption),
font_row.clone(),
TextColor(TEXT_SECONDARY),
));
spawn_modal_actions(card, |actions| { spawn_modal_actions(card, |actions| {
// The Watch Replay button is always rendered so the // The Watch Replay button is always rendered so the
@@ -756,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 /// Spawn one row of the "Per-mode bests" section: the mode label on the
@@ -1088,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] #[test]
fn stats_screen_renders_three_per_mode_bests_rows() { fn stats_screen_renders_three_per_mode_bests_rows() {
// Open the Stats overlay and assert three [`PerModeBestsRow`] // Open the Stats overlay and assert three [`PerModeBestsRow`]
+161 -1
View File
@@ -121,11 +121,34 @@ impl Plugin for UiFocusPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<FocusedButton>() app.init_resource::<FocusedButton>()
.add_systems(Startup, spawn_focus_overlay) .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( .add_systems(
Update, PostUpdate,
( (
attach_focusable_to_modal_buttons, attach_focusable_to_modal_buttons,
auto_focus_on_modal_open, auto_focus_on_modal_open,
)
.chain(),
)
.add_systems(
Update,
(
sync_focus_on_mouse_click, sync_focus_on_mouse_click,
clear_hud_focus_on_unhover, clear_hud_focus_on_unhover,
handle_focus_keys, handle_focus_keys,
@@ -827,6 +850,143 @@ mod tests {
assert_eq!(focused, Some(a), "Primary button A should auto-focus"); 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] #[test]
fn tab_advances_focus_in_spawn_order() { fn tab_advances_focus_in_spawn_order() {
let mut app = headless_app(); let mut app = headless_app();
+323
View File
@@ -49,6 +49,8 @@
//! ``` //! ```
use bevy::prelude::*; use bevy::prelude::*;
use bevy::ui::{ComputedNode, UiGlobalTransform};
use bevy::window::PrimaryWindow;
use solitaire_data::AnimSpeed; use solitaire_data::AnimSpeed;
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
@@ -74,6 +76,19 @@ pub struct ModalScrim;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct ModalCard; 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`). /// Marker on a header `Text` (`TYPE_HEADLINE` + `TEXT_PRIMARY`).
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct ModalHeader; 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 /// Repaints every `ModalButton` on `Changed<Interaction>` so hover and
/// press states are visible without each overlay registering its own /// press states are visible without each overlay registering its own
/// paint system. /// paint system.
@@ -515,6 +613,12 @@ impl Plugin for UiModalPlugin {
Update, Update,
(apply_modal_enter_speed, advance_modal_enter, paint_modal_buttons).chain(), (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), 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"
);
}
} }