Compare commits

..

15 Commits

Author SHA1 Message Date
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
funman300 70165da103 docs: CHANGELOG + SESSION_HANDOFF refresh for v0.15.0
CHANGELOG gains a [0.15.0] section covering 7 commits since
v0.14.0: Bevy default-features trim (51 transitive crates dropped),
in-engine replay playback core + overlay banner + Stats button
wiring, rolling replay history (last 8 wins) with selector UI,
"Cinephile" achievement (#19), and the Klondike solver + "Winnable
deals only" toggle.

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

SESSION_HANDOFF rewritten for the post-v0.15.0 state. Open punch
list collapsed to two release-prep items (smoke-test, desktop
packaging) and six fresh next-round candidates: solver-driven
hints (now unblocked), playback-rate slider, solver progress
overlay, solver-on-async-compute, per-deal "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:07:15 +00:00
funman300 8a5fa8751c feat(core,engine): Klondike solver and "Winnable deals only" toggle
Closes Quat investigation #1. Today some Klondike deals are
unwinnable from the start and the player has no signal that the
deal they were given is solvable. A new Settings → Gameplay toggle
"Winnable deals only" (default off) makes the engine retry seeds
at deal-time until the solver returns Winnable, up to a cap.

Solver

solitaire_core::solver is a hand-rolled iterative-DFS solver with
memoisation on a 64-bit canonical state hash. Move enumeration is
priority-ordered: foundation moves first (zero choice when an Ace
or rank-up exists), inter-tableau moves second, waste-to-tableau
third, stock-draw last. The draw is skipped when the cycle counter
shows we've recirculated the entire stock without progress —
Klondike's deterministic stock cycle means further draws can't
unlock anything new.

Two budget knobs (move_budget = 100k, state_budget = 200k by
default) cap pathological cases at Inconclusive; the caller treats
Inconclusive as "winnable" so the player isn't penalised for the
solver giving up. Median solve time is 2 ms; pathological
inconclusives top out near 120 ms.

Switched from recursive to iterative DFS after a real-deal solve
overflowed Rust's default 8 MB thread stack. Behaviour identical;
the change is invisible to callers.

Pure logic — solitaire_core has no Bevy or I/O. Same input always
yields the same SolverResult.

Settings

Settings.winnable_deals_only is a #[serde(default)] bool; legacy
files load to false. SOLVER_DEAL_RETRY_CAP = 50 caps the retry
loop. The Settings → Gameplay toggle reads as "Winnable deals only"
with a "(may take a moment when on)" caption.

Engine integration

handle_new_game's seed-selection path now branches on the toggle.
When on AND mode is Classic AND no specific seed was requested
(daily challenges, replays, and explicit-seed requests bypass the
solver), choose_winnable_seed walks seed N, N+1, N+2, … calling
try_solve until it finds Winnable or Inconclusive. If the cap is
hit without a verdict, the latest tried seed is used so the player
always gets a deal rather than spinning forever.

19 new tests (11 solver, 3 settings, 5 engine including the
choose_winnable_seed unit). Two ignored bench/scan helpers
(solver_bench, find_unwinnable) for ad-hoc profiling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:02:22 +00:00
funman300 bf660df971 feat(core,engine): "Cinephile" achievement for completing a replay
Adds a 19th achievement: "Cinephile — Watch a saved replay all the
way through." Unlocks the first time ReplayPlaybackState transitions
Playing → Completed (i.e. the move list runs out without the player
pressing Stop). Discoverability nudge for the replay feature itself.

The achievement uses the existing event-driven unlock pattern
(condition closure returns false; an unlock system fires
AchievementUnlockedEvent on the right state transition) rather than
the standard condition-evaluation path, mirroring how other
non-stat-driven achievements work.

The unlock system distinguishes natural completion from Stop-button
abort by watching for the specific Playing → Completed transition;
Stop transitions Playing → Inactive directly without going through
Completed, so it doesn't fire the achievement. Already-unlocked
state is checked via AchievementsResource so the achievement can't
double-fire on subsequent replays.

README's "18 Achievements" → "19 Achievements". ARCHITECTURE.md §11
gains a Cinephile entry alongside the existing 18.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:32:56 +00:00
funman300 13a8a012ee feat(data,engine): rolling replay history (last 8 wins)
Promotes replay storage from a single overwriting slot at
latest_replay.json to a rolling list of the most recent 8 wins at
replays.json so the player can revisit a memorable game even after
winning more recently.

Storage layer

solitaire_data::replay gains ReplayHistory (schema_version=1, Vec<Replay>
capped at REPLAY_HISTORY_CAP = 8) plus save_replay_history_to,
load_replay_history_from, append_replay_to_history, and
replay_history_path. append_replay_to_history inserts at the front,
drops the oldest when the cap is hit, and persists atomically via
the existing .tmp + rename pattern. The legacy single-slot helpers
are #[deprecated] but kept for one release as a migration safety
net via the new migrate_legacy_latest_replay helper.

Engine integration

game_plugin's record_replay_on_win now appends to the history
instead of overwriting latest_replay.json. On Startup, if a legacy
latest_replay.json exists but replays.json doesn't, the migration
helper seeds the new file from the legacy entry — so the player's
last v0.14.0 replay carries forward.

Stats UI

LatestReplayResource → ReplayHistoryResource holding the full
history. New SelectedReplayIndex resource (default 0 = most
recent) drives a Prev / Next / "Replay N / M" selector at the top
of the Stats overlay. ReplayPrevButton, ReplayNextButton, and
ReplaySelectorCaption marker components let the repaint system
update the caption as the selection changes. The Watch button
launches the selected replay rather than always the most recent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:32:37 +00:00
funman300 02ababa65f feat(engine): wire Stats Watch Replay button to in-engine playback
Promotes the Stats overlay's Watch Replay button from a stub
InfoToastEvent ("playback coming in a future build") to actually
starting in-engine playback via the new
replay_playback::start_replay_playback API. Pressing the button
when a replay exists resets the game to the recorded deal and the
ReplayOverlayPlugin's banner takes over.

The handler reads ReplayPlaybackState via Option<ResMut<...>> so
headless test fixtures that don't register ReplayPlaybackPlugin
keep compiling — they fall back to a descriptive "Replay ready"
toast. The "no replay yet" branch still surfaces the existing
"win a game first" toast.

Plugin registration in solitaire_app/src/main.rs picks up
ReplayPlaybackPlugin and ReplayOverlayPlugin alongside the existing
StatsPlugin. They run in any order — the playback plugin owns the
state resource, the overlay plugin spawns/despawns based on state
changes, and Stats's button handler dispatches into the playback
plugin's API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:34:48 +00:00
funman300 9c36b49729 feat(engine): replay-playback overlay banner with Stop button
Visible UI for the in-engine replay playback that just landed: a
thin top banner anchored to the window edge while
ReplayPlaybackState is Playing or Completed, surfacing the player's
current position in the move list and a way to abort.

Layout: full-width banner ~48 px tall with three children — a
"Replay" label in ACCENT_PRIMARY left-aligned, "Move N of M"
progress text centred, and a Tertiary Stop button right-aligned via
the existing spawn_modal_button helper so it gets focus rings and
hover/press states for free.

Z_REPLAY_OVERLAY = Z_DROP_OVERLAY + 5 (= 55) sits above HUD but
well below modal scrim (≥200), so Settings, Pause, and Help still
render on top of the overlay during a replay — the player can
adjust audio or pause mid-playback.

State-driven: the spawn system reacts to Changed<ReplayPlaybackState>
transitions, swapping the banner text to "Replay complete" when
state moves Playing → Completed and despawning entirely when state
returns to Inactive (either via the Stop button, completion linger
expiry, or external reset).

Five tests cover spawn-on-Playing, progress text, stop-button
clears state and despawns, despawn-on-Inactive, and Completed
banner text swap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:34:36 +00:00
funman300 8e90574437 feat(engine): in-engine replay playback core
Promotes the replay feature from disk-only to a real in-engine
playback path. A new ReplayPlaybackState resource models a three-
state machine (Inactive / Playing / Completed); start_replay_playback
resets the live game to the recorded deal via
GameState::new_with_mode(seed, draw_mode, mode) and a tick system
fires the canonical MoveRequestEvent / DrawRequestEvent for each
recorded move at REPLAY_MOVE_INTERVAL_SECS (0.45s).

The reset path bypasses NewGameRequestEvent because the existing
event always sources draw_mode from Settings — a Draw-1 replay
would silently coerce to Draw-3 (or vice versa) on a player whose
preference doesn't match the recording. Inserting GameStateResource
directly applies the recording's exact draw_mode and sidesteps the
abandon-current-game confirmation modal that would otherwise block
playback.

Recording suppression during playback is non-invasive: a sibling
system snapshots RecordingReplay's length on entry to playback and
truncates the buffer back to that mark every frame while is_playing
or is_completed. game_plugin's recording append paths are
untouched.

Completion lingers for REPLAY_COMPLETION_LINGER_SECS (5s) so the
overlay can show "Replay complete" before the auto-clear flips
state to Inactive.

Six new tests cover the state transitions, tick cadence, canonical
event firing, completion, stop-clears-state, and the
recording-suppression contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:34:16 +00:00
funman300 95fcdad5d2 chore: disable Bevy default features to drop unused audio stack
Closes Quat investigation #2. The project uses kira for audio
(cpal 0.17 + alsa 0.10), but Bevy's default feature set still pulled
bevy_audio → rodio → cpal 0.15 + alsa 0.9 + symphonia codecs — about
50 transitive crates the binary never executes.

Workspace Cargo.toml's bevy entry now declares default-features =
false plus an explicit allow-list of the features actually used
(default_app subset + default_platform desktop subset + common_api +
2D + UI rendering). The list is derived analytically from the leaves
of Bevy 0.18's 2d and ui meta-features; built cleanly on the first
try with no missing-symbol errors.

Features intentionally omitted vs Bevy default:
- bevy_audio (kira handles audio directly)
- bevy_animation (custom CardAnimation, not Bevy's)
- bevy_gilrs, bevy_gizmos, bevy_picking variants, bevy_post_process,
  scene, hdr, sysinfo_plugin (none used)
- webgl2, web, android-* (desktop-only; solitaire_wasm is Bevy-free
  and uses wasm-bindgen + solitaire_core directly)
- wayland (X11 chosen; Wayland can be added later if requested)

Dependency-tree size for solitaire_app drops from 628 unique crates
to 577 (-51). Verified gone: bevy_audio, rodio, cpal 0.15. The
remaining cpal 0.17 and symphonia 0.5 are pulled by kira, not Bevy.

solitaire_wasm needed no changes — it doesn't depend on bevy.

All 1134 tests pass; clippy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:07:30 +00:00
27 changed files with 5353 additions and 1756 deletions
+3
View File
@@ -716,11 +716,14 @@ pub struct AchievementDef {
| `speed_and_skill` | ??? | Win < 90s without undo | Yes | Card back #4 | | `speed_and_skill` | ??? | Win < 90s without undo | Yes | Card back #4 |
| `comeback` | ??? | Win after 3+ stock recycles | Yes | Background #4 | | `comeback` | ??? | Win after 3+ stock recycles | Yes | Background #4 |
| `zen_winner` | ??? | Win in Zen Mode | Yes | Badge | | `zen_winner` | ??? | Win in Zen Mode | Yes | Badge |
| `cinephile` | Cinephile | Watch a saved replay all the way through | No | — |
### Evaluation Timing ### Evaluation Timing
Achievement conditions are evaluated by `AchievementPlugin` on every `GameWonEvent` and `StateChangedEvent`. The plugin calls `solitaire_core::check_achievements()` which returns a `Vec<AchievementDef>` of newly unlocked achievements. The plugin then fires `AchievementUnlockedEvent` for each, which the toast and persistence systems handle independently. Achievement conditions are evaluated by `AchievementPlugin` on every `GameWonEvent` and `StateChangedEvent`. The plugin calls `solitaire_core::check_achievements()` which returns a `Vec<AchievementDef>` of newly unlocked achievements. The plugin then fires `AchievementUnlockedEvent` for each, which the toast and persistence systems handle independently.
A small number of achievements are *event-driven* rather than condition-driven: their `AchievementDef::condition` always returns `false` and their unlock is written from a dedicated observer system instead. `cinephile` is the canonical example — it unlocks when `ReplayPlaybackState` transitions from `Playing` to `Completed` (a saved replay watched to its natural end). The Stop button transitions `Playing → Inactive` directly without entering `Completed`, so manual aborts do not unlock the achievement.
--- ---
## 12. Progression System ## 12. Progression System
+124 -1
View File
@@ -8,6 +8,127 @@ project follows [Semantic Versioning](https://semver.org/).
_Nothing yet._ _Nothing yet._
## [0.16.0] — 2026-05-06
A modal-feel polish round. Every overlay screen now scrolls when its
content overflows the 800×600 minimum window, every clickable button
shows a hand cursor on hover, keyboard focus lands on the primary
button on the same frame the modal opens, and read-only modals
dismiss when the player clicks the scrim outside the card.
### Added
- **Pointer cursor on hover** for every interactive `Button` entity
(modal buttons, HUD action bar, mode-launcher cards, settings
toggles, Stats selectors). `update_cursor_icon` gains a fourth
branch sitting between Grabbing (active drag) and Grab
(draggable card hover): when no drag is active and any
`Interaction::Hovered`/`Pressed` button is detected, the window
cursor swaps to `SystemCursorIcon::Pointer`. A pure
`pick_cursor_icon` helper makes the priority logic
unit-testable.
- **Click-outside-to-dismiss** for the six read-only modals: Stats,
Achievements, Help, Profile, Leaderboard, Home. New
`ScrimDismissible` marker on `ModalScrim` opts a modal in;
`dismiss_modal_on_scrim_click` runs in `Update`, despawns the
topmost dismissible scrim on a left-mouse press whose cursor
lands on the scrim and outside every `ModalCard`. Bevy's
hierarchy despawn cascades to the card and children.
Settings, Onboarding, Pause, Forfeit confirm, and Confirm New
Game intentionally don't opt in — they carry unsaved or
destructive state.
### Fixed
- **Modal content scrolls when it overflows** (Achievements, Help,
Stats, Profile, Leaderboard). Each modal's body Node now
carries `Overflow::scroll_y()` plus a `max_height` constraint
(`Val::Vh(70.0)` for most, `Val::Vh(50.0)` for the
leaderboard's variable-length ranking section) and a marker
component (`AchievementsScrollable`, `HelpScrollable`,
`StatsScrollable`, `ProfileScrollable`,
`LeaderboardScrollable`). A sibling `scroll_*_panel` system
per modal routes `MouseWheel` events into the body's
`ScrollPosition`. Mirrors the existing `SettingsPanelScrollable`
pattern. Home modal intentionally not scrolled — its five
mode cards + Cancel are sized to fit at 800×600 by design.
- **Modal focus arrives on the same frame the modal opens.**
Previously `attach_focusable_to_modal_buttons` and
`auto_focus_on_modal_open` ran in `Update` alongside arbitrary
click-handlers that spawn modals; with no ordering edge,
Bevy's deferred `Commands` queued the new entities but the
attach system couldn't see them on the same tick. Both systems
moved to `PostUpdate` so the schedule boundary itself supplies
the sync point — `FocusedButton` is always populated before
`app.update()` returns. The very next Tab/Enter press lands on
a populated resource instead of wasting itself moving focus
from None to the primary.
### Stats
- 1196 passing tests (was 1178 at v0.15.0 close).
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
## [0.15.0] — 2026-05-02
In-engine replay playback, the Klondike solver + "Winnable deals
only" toggle, a 19th achievement, rolling replay history, and a
significant build-time / binary-size win from disabling Bevy's
default audio stack.
### Added
- **In-engine replay playback** for the Stats overlay's Watch Replay
button. New `ReplayPlaybackPlugin` runs a state machine
(Inactive / Playing / Completed) that resets the live game to the
recorded deal and ticks through `replay.moves` at
`REPLAY_MOVE_INTERVAL_SECS` (0.45 s) firing the canonical
`MoveRequestEvent` / `DrawRequestEvent` per recorded move.
Recording is suppressed during playback so replays don't re-record
themselves.
- **Replay overlay banner** (`ReplayOverlayPlugin`) anchored to the
top of the window during playback. Shows "Replay" label, "Move N
of M" progress, and a Stop button. Z-order leaves modals
(Settings, Pause, Help) free to render on top so the player can
adjust audio mid-replay.
- **Rolling replay history** at `<data_dir>/replays.json` capped at
8 entries. Replaces the single-slot `latest_replay.json` (legacy
file is migrated forward on first launch via
`migrate_legacy_latest_replay`). Stats overlay gains a Prev / Next
selector and a "Replay N / M" caption so the player can revisit
older wins.
- **"Cinephile" achievement** (#19). Unlocks the first time
`ReplayPlaybackState` transitions Playing → Completed (i.e. the
replay played out to its end without the player pressing Stop).
Stop transitions Playing → Inactive directly so it doesn't count.
- **Klondike solver** in `solitaire_core::solver`. Iterative-DFS
with memoisation on a 64-bit canonical state hash, two budget
knobs (move_budget + state_budget) for pathological cases, and a
three-state `SolverResult` (Winnable / Unwinnable / Inconclusive).
Median solve time 2 ms; pathological inconclusives cap near
120 ms. Pure logic — `solitaire_core` keeps no Bevy or I/O.
- **"Winnable deals only" toggle** in Settings → Gameplay (default
off). When on, `handle_new_game` walks seed N, N+1, N+2, …
through `try_solve` until it finds Winnable or Inconclusive,
capped at `SOLVER_DEAL_RETRY_CAP` (50) attempts. Daily
challenges, replays, and explicit-seed requests bypass the
solver — only random Classic deals are gated.
### Changed
- **Bevy default-feature trim** (`bevy = { default-features = false,
features = [...] }` in workspace Cargo.toml) drops 51 transitive
crates including the `bevy_audio` → rodio → cpal 0.15 + symphonia
chain that the project doesn't use (kira handles audio directly).
The retained feature list is curated to exactly what the engine
uses; `solitaire_wasm` is unaffected because it doesn't depend on
bevy.
### Stats
- 1178 passing tests (was 1134 at v0.14.0 close).
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
## [0.14.0] — 2026-05-02 ## [0.14.0] — 2026-05-02
Two threads land in v0.14.0: the second half of the post-v0.12.0 UX Two threads land in v0.14.0: the second half of the post-v0.12.0 UX
@@ -405,7 +526,9 @@ 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.14.0...HEAD [Unreleased]: https://github.com/funman300/Rusty_Solitaire/compare/v0.16.0...HEAD
[0.16.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.15.0...v0.16.0
[0.15.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.14.0...v0.15.0
[0.14.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.13.0...v0.14.0 [0.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
[0.12.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.11.0...v0.12.0 [0.12.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.11.0...v0.12.0
Generated
+23 -1090
View File
File diff suppressed because it is too large Load Diff
+40 -1
View File
@@ -36,7 +36,46 @@ solitaire_sync = { path = "solitaire_sync" }
solitaire_data = { path = "solitaire_data" } solitaire_data = { path = "solitaire_data" }
solitaire_engine = { path = "solitaire_engine" } solitaire_engine = { path = "solitaire_engine" }
bevy = "0.18" # Bevy with `default-features = false` to avoid the unused
# `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain.
# Audio is handled directly by `kira` in `audio_plugin.rs`, so the
# `bevy_audio` feature is intentionally omitted. The features below
# enumerate every leaf of the standard `2d` + `ui` meta-features that
# we actually use; new features should only be added with a
# corresponding use site.
bevy = { version = "0.18", default-features = false, features = [
# default_app
"async_executor",
"bevy_asset",
"bevy_input_focus",
"bevy_log",
"bevy_state",
"bevy_window",
"custom_cursor",
"reflect_auto_register",
# default_platform (desktop subset; no android/wayland/webgl/gilrs/sysinfo)
"std",
"bevy_winit",
"default_font",
"multi_threaded",
"x11",
# common_api
"bevy_color",
"bevy_image",
"bevy_mesh",
"bevy_shader",
"bevy_text",
"png",
# 2d rendering
"bevy_camera",
"bevy_render",
"bevy_core_pipeline",
"bevy_sprite",
"bevy_sprite_render",
# UI rendering
"bevy_ui",
"bevy_ui_render",
] }
kira = "0.12" kira = "0.12"
# SVG rasterisation pipeline for the runtime card-theme system. # SVG rasterisation pipeline for the runtime card-theme system.
+1 -1
View File
@@ -22,7 +22,7 @@ optional self-hosted sync so your stats follow you across machines.
move within picker rows, Enter activates; works across every modal and move within picker rows, Enter activates; works across every modal and
the HUD action bar the HUD action bar
- **Progression** — XP, levels, unlockable card backs and backgrounds - **Progression** — XP, levels, unlockable card backs and backgrounds
- **18 Achievements** — including secret ones - **19 Achievements** — including secret ones
- **Daily Challenge** — server-seeded so every player worldwide gets the - **Daily Challenge** — server-seeded so every player worldwide gets the
same deal same deal
- **Leaderboard** — opt-in, powered by your own self-hosted server - **Leaderboard** — opt-in, powered by your own self-hosted server
+40 -73
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.16.0) — Modal-feel polish round shipped: every overlay scrolls when it overflows, every button shows a pointer cursor on hover, modal focus lands on the same frame, and read-only modals dismiss on scrim click. Direction now opens.
## Status at pause ## Status at pause
- **HEAD on origin:** v0.14.0's tag commit (CHANGELOG + handoff refresh). - **HEAD on origin:** v0.16.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:** **1196 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.16.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,33 @@ 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.16.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. | | 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. |
| 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`. | | 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. |
| 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. | | 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. |
| 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. | | 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. |
| 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. | | Scrim dismiss tail | `cbf2483` | One-line opt-in (capture scrim + insert marker) for Profile / Leaderboard / Home, completing all six read-only modals. |
### Quat smoke-test bug fixes
| Area | Commit | What landed |
|---|---|---|
| Move validation (#1) | `f1aeb24` | `solitaire_core::rules::is_valid_tableau_sequence(&[Card]) -> bool` checks every adjacent pair in a moved stack descends one rank with alternating colour. Wired into `move_cards`. Closes the bug where any multi-card lift could be dropped as long as the bottom landed legally. |
| Deal-tween leak (#4) | `3eabc14` | New-game snaps every card sprite to the stock pile position before writing `StateChangedEvent`, so all 52 cards animate from a single deck point during the deal. Previously sprites started from previous-game positions, briefly revealing the prior deal. |
| Softlock detection (#2) | `2716472` | `has_legal_moves` rewritten: walks every potential move source (every stock card, every waste card, the face-up top of every tableau column) against every foundation and every tableau. Previous heuristic returned `true` whenever stock had cards, hiding genuine softlocks. `GameOverScreen` now actually fires for true softlocks. |
| End-game screen (#3) | — | Resolved as downstream of #2. The pre-existing `GameOverScreen` and `WinSummaryOverlay` already cover the close-out paths; the softlock screen just never spawned because the old `has_legal_moves` lied. |
### Replay pipeline (the major feature)
| Area | Commit | What landed |
|---|---|---|
| Replay storage | `42535f5` | `solitaire_data::replay::Replay` (seed + draw_mode + mode + score + time + recorded date + ordered move list) and atomic save/load helpers under `<data_dir>/latest_replay.json`. Schema v1; `load` returns None for any other version. |
| Engine recording | `57d1c58` | `RecordingReplay` resource + `ReplayPath` settings. Every successful `MoveRequestEvent` / `DrawRequestEvent` appends to recording; `GameWonEvent` freezes the recording into a `Replay` and persists. Undo intentionally not recorded. New game clears the recording. |
| Stats button | `d9f36bf` | Stats overlay surfaces a "Latest win:" caption + "Watch replay" button. Loads from disk via `LatestReplayResource`. (Full in-engine playback deferred — button currently fires an `InfoToastEvent` describing the replay.) |
| Server upload + fetch | `93182fa` | `POST /api/replays` accepts a `Replay` JSON; `GET /api/replays/:id` returns it. JWT-gated. SQL migration for the new `replays` table. |
| Engine sync | `23c9704` | Engine uploads winning replays automatically when the player has cloud sync configured. Re-uses the existing JWT/refresh-token flow. |
| WASM crate | `5bed43e` | New workspace member `solitaire_wasm` compiles replay-relevant `solitaire_core` types to WebAssembly so a browser can re-execute a replay client-side. `wasm-bindgen` glue. |
| Web viewer | `07b8ecd` | `GET /replays/:id` returns HTML + CSS + the wasm bundle. Browser fetches the replay JSON, rasterises a deal from the seed, and animates the recorded moves. |
| E2E coverage | `3081505` | Server tests covering the full upload → fetch round-trip via `axum::test`. |
| Web flight anim | `1fcd032` | Card-flight tweens on the web side so the browser viewer reads as a real game replay rather than a static dump. |
## 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. **Smoke-test on a real game**: confirm scroll feels right on Achievements (the original bug), pointer cursor changes on every interactive surface, the very first Tab in a modal already activates the primary, and clicking the dimmed area dismisses the read-only modals while NOT dismissing Settings/Pause.
2. **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. ### Carryover from v0.15.0 next-round candidates
- **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) Still open — would each be ~50200 LOC:
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: - **Solver-driven hints** — the existing hint system uses a heuristic; promote it to ask `try_solve` for the actual best move. Now that the solver is in place this is mostly plumbing.
- **Replay-playback rate slider** — the 0.45 s/move pace is hardcoded; a Settings slider in the same row as tooltip-delay / time-bonus would let power users speed up older replays.
- **Bundled default theme** ships in the binary via `embedded://` — 52 hayeah/playing-cards-assets SVGs + a midnight-purple `back.svg`. - **Solver progress overlay** — when "Winnable deals only" is on, a brief "checking deal…" toast surfaces after ~500 ms so the player isn't confused by the rare worst-case stall.
- **User themes** under `themes://`. Drop a directory containing `theme.ron` + 53 SVGs. - **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. Async + cancel button would be safer.
- **Importer** at `solitaire_engine::theme::import_theme(zip)` validates archives and atomically unpacks. - **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.
- **Picker UI** in Settings → Cosmetic; thumbnails + the active theme's `back` override the legacy `back_N.png` picker when present. - **Replay sharing**`replays.json` is per-machine. Allow a player to copy a replay's URL (already wired via `solitaire_server`) and post it elsewhere. The web-viewer already exists.
## Resume prompt ## Resume prompt
@@ -95,17 +62,17 @@ 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.16.0. Working tree clean apart from untracked
CARD_PLAN.md (intentional). 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: 1196 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 +81,16 @@ 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. Smoke-test v0.16.0. Scroll on Achievements, pointer cursor on
three Quat bug fixes hold up in real gameplay and the replay buttons, first Tab in a modal activates rather than advances,
pipeline works end-to-end (record → upload → web viewer). scrim click dismisses Stats/Achievements/Help/Profile/
B. Take the deferred Bevy-audio-feature trim (Quat investigation Leaderboard/Home but NOT Settings/Pause/etc.
#2) — one-line workspace edit, ~50 fewer transitive crates. B. Solver-driven hints — replace heuristic with try_solve's
C. Take the deferred solver toggle (Quat investigation #1): add best-move suggestion. ~100 LOC.
"Winnable deals only" Settings toggle. Larger. C. Solver-on-AsyncComputeTaskPool with progress toast + cancel.
D. Promote the in-engine "Watch replay" button to real playback. Eliminates the worst-case 6 s stall.
E. Pick from the remaining "next-round candidates" in this doc. D. Pick from the remaining "next-round candidates" in this doc.
F. Take the deferred desktop-packaging item (needs artwork + E. Take the deferred desktop-packaging item (needs artwork +
signing certs from the user). signing certs from the user).
WORKFLOW NOTES: WORKFLOW NOTES:
@@ -136,5 +103,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 AE. Don't pick unilaterally.
``` ```
+4 -1
View File
@@ -10,7 +10,8 @@ use solitaire_engine::{
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin,
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
SelectionPlugin, SettingsPlugin, SplashPlugin,
StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
}; };
@@ -117,6 +118,8 @@ fn main() {
.add_plugins(FeedbackAnimPlugin) .add_plugins(FeedbackAnimPlugin)
.add_plugins(CardAnimationPlugin) .add_plugins(CardAnimationPlugin)
.add_plugins(AutoCompletePlugin) .add_plugins(AutoCompletePlugin)
.add_plugins(ReplayPlaybackPlugin)
.add_plugins(ReplayOverlayPlugin)
.add_plugins(StatsPlugin::default()) .add_plugins(StatsPlugin::default())
.add_plugins(ProgressPlugin::default()) .add_plugins(ProgressPlugin::default())
.add_plugins(AchievementPlugin::default()) .add_plugins(AchievementPlugin::default())
+47
View File
@@ -140,6 +140,16 @@ fn comeback(c: &AchievementContext) -> bool {
fn zen_winner(c: &AchievementContext) -> bool { fn zen_winner(c: &AchievementContext) -> bool {
c.last_win_is_zen c.last_win_is_zen
} }
/// Cinephile is event-driven: it unlocks when the engine observes a
/// `ReplayPlaybackState` transition from `Playing` to `Completed`, not on
/// any field of [`AchievementContext`]. The condition predicate therefore
/// always returns false so [`check_achievements`] never unlocks it from a
/// `GameWonEvent` / `StateChangedEvent` cycle — the unlock is driven by
/// `AchievementUnlockedEvent` written directly from the engine's
/// replay-playback observer.
fn cinephile_never(_c: &AchievementContext) -> bool {
false
}
/// All currently-evaluable achievements. Order is stable so persistence files /// All currently-evaluable achievements. Order is stable so persistence files
/// remain readable across versions (new achievements append). /// remain readable across versions (new achievements append).
@@ -288,6 +298,18 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
reward: Some(Reward::Badge), reward: Some(Reward::Badge),
condition: zen_winner, condition: zen_winner,
}, },
AchievementDef {
id: "cinephile",
name: "Cinephile",
description: "Watch a saved replay all the way through",
secret: false,
reward: None,
// Event-driven unlock: the engine's replay-playback observer fires
// `AchievementUnlockedEvent("cinephile")` directly on a Playing →
// Completed transition. `cinephile_never` keeps the condition path
// a no-op so a `GameWonEvent` evaluation cycle cannot unlock it.
condition: cinephile_never,
},
]; ];
/// Return every `AchievementDef` whose condition is satisfied by `ctx`. /// Return every `AchievementDef` whose condition is satisfied by `ctx`.
@@ -721,6 +743,31 @@ mod tests {
assert!(ids.contains(&"no_undo"), "no_undo must also unlock when perfectionist does"); assert!(ids.contains(&"no_undo"), "no_undo must also unlock when perfectionist does");
} }
#[test]
fn cinephile_achievement_in_canonical_list() {
let def = achievement_by_id("cinephile").expect("cinephile must be registered");
assert_eq!(def.id, "cinephile");
assert_eq!(def.name, "Cinephile");
assert!(!def.secret, "cinephile is not a secret achievement");
// Event-driven: the predicate is a sentinel that always returns
// false. `check_achievements` must never unlock cinephile from a
// GameWonEvent context, even one that satisfies every other gate.
let mut c = ctx();
c.games_won = 1;
c.win_streak_current = 999;
c.last_win_time_seconds = 1;
c.last_win_used_undo = false;
c.best_single_score = 99_999;
c.lifetime_score = u64::MAX;
c.last_win_is_zen = true;
c.last_win_recycle_count = 99;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(
!ids.contains(&"cinephile"),
"cinephile must never unlock via condition evaluation; got {ids:?}",
);
}
#[test] #[test]
fn perfectionist_score_well_above_threshold_still_passes() { fn perfectionist_score_well_above_threshold_still_passes() {
let mut c = ctx(); let mut c = ctx();
+1
View File
@@ -6,3 +6,4 @@ pub mod game_state;
pub mod pile; pub mod pile;
pub mod rules; pub mod rules;
pub mod scoring; pub mod scoring;
pub mod solver;
+893
View File
@@ -0,0 +1,893 @@
//! Klondike solvability checker.
//!
//! Used by the engine to back the **Settings → Gameplay → "Winnable
//! deals only"** toggle: when on, the engine retries fresh deal seeds
//! until [`try_solve`] returns [`SolverResult::Winnable`] (or
//! [`SolverResult::Inconclusive`], which we treat as winnable because
//! we cannot prove otherwise) up to a fixed retry cap.
//!
//! The implementation is a hand-rolled depth-first search with
//! memoisation on a deterministic canonical state hash. It uses no
//! external crates beyond what `solitaire_core` already depends on
//! (`std::collections::HashSet`, `std::hash::DefaultHasher`).
//!
//! # Algorithm
//!
//! 1. Encode the game state into a canonical `u64` hash. Tableau
//! columns are encoded top-to-bottom along with each card's face
//! state; foundations are encoded by their top card; stock and
//! waste are encoded as the concatenation of their card ids in
//! order. Two states with the same canonical hash are considered
//! equivalent for the purposes of pruning.
//!
//! 2. At each search step, enumerate the candidate moves in priority
//! order:
//! - **Foundation moves first** — moving a card to a foundation
//! pile reduces the search frontier and never traps the player.
//! Aces and twos are unconditional (the spec calls these out as
//! "no choice involved" forced plays).
//! - **Inter-tableau moves next** — moves between tableau columns
//! that *don't* immediately undo the previous move (a "self-undo"
//! filter prevents the trivial A→B then B→A cycle).
//! - **Stock/waste draw last** — drawing permutes a long sequence
//! and is the costliest move. It's also the only source of
//! branching once the tableau is locked, so we enumerate it last
//! and only when no productive move was made since the previous
//! stock cycle (we track this with a "drew without other progress"
//! counter).
//!
//! 3. After each move, recurse. If the recursion finds a win we
//! propagate `Winnable` immediately. If the visited-state set or
//! the move-budget counter is exhausted we return `Inconclusive`.
//! Otherwise we exhaust all moves and return `Unwinnable`.
//!
//! # Determinism
//!
//! The search is fully deterministic: move enumeration walks piles in
//! a fixed order and the canonical hash is built with `DefaultHasher`,
//! whose seed is fixed across program runs but documented as not
//! cryptographically stable. For the purposes of "same input → same
//! output across one program run" this is sufficient; the spec
//! explicitly calls `DefaultHasher` "fine for this".
//!
//! # Performance
//!
//! On real fresh deals the solver completes in tens of milliseconds
//! (median ~30 ms on the synthetic deals used by the tests below).
//! Pathological deals are bounded by [`SolverConfig::move_budget`] and
//! [`SolverConfig::state_budget`] — when either trips we return
//! [`SolverResult::Inconclusive`]. The retry loop in the engine treats
//! Inconclusive as winnable so a player who turns the toggle on never
//! sees a hung "searching..." state.
use std::collections::HashSet;
use std::hash::{Hash, Hasher};
use crate::card::{Card, Suit};
use crate::deck::{deal_klondike, Deck};
use crate::game_state::DrawMode;
use crate::pile::{Pile, PileType};
use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence};
/// Verdict returned by [`try_solve`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SolverResult {
/// The solver found a sequence of moves that wins the deal.
Winnable,
/// The solver exhaustively searched and confirmed no win exists.
Unwinnable,
/// The time / move budget was exceeded before a verdict could be
/// reached. Callers should treat this as winnable since we cannot
/// prove otherwise — Klondike has many deals where the search tree
/// is theoretically tractable but practically too wide for a
/// bounded DFS.
Inconclusive,
}
/// Tunable budgets controlling how long [`try_solve`] is willing to
/// search before bailing out with [`SolverResult::Inconclusive`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SolverConfig {
/// Maximum total moves to consider across the entire search tree.
/// Default: `100_000`. A realistic Klondike solve fits in
/// ~10k30k moves for solvable deals; the cap lets us bail out of
/// pathological states.
pub move_budget: u64,
/// Maximum unique states to visit. Memoisation prevents revisiting,
/// but the visited set grows unbounded without a cap. Default:
/// `200_000`.
pub state_budget: usize,
}
impl Default for SolverConfig {
fn default() -> Self {
Self {
move_budget: 100_000,
state_budget: 200_000,
}
}
}
/// Tries to solve a fresh Classic-mode game from `seed` + `draw_mode`.
///
/// This is a pure function — same input always yields the same
/// [`SolverResult`] within one program run.
///
/// The solver only explores *Classic* Klondike rules: there's no
/// undo, no Zen-mode score suppression, and no Challenge-mode undo
/// ban (irrelevant since the solver never undoes). The same engine
/// rules ([`can_place_on_foundation`], [`can_place_on_tableau`],
/// [`is_valid_tableau_sequence`]) drive move enumeration so the
/// solver's notion of "legal" exactly matches the live game.
pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> SolverResult {
let state = SolverState::initial(seed, draw_mode);
let mut visited: HashSet<u64> = HashSet::new();
let mut moves_consumed: u64 = 0;
let mut budget_exceeded = false;
let won = state.search(config, &mut visited, &mut moves_consumed, &mut budget_exceeded);
if won {
SolverResult::Winnable
} else if budget_exceeded {
SolverResult::Inconclusive
} else {
SolverResult::Unwinnable
}
}
// ---------------------------------------------------------------------------
// Internal solver state
// ---------------------------------------------------------------------------
/// The candidate moves the solver enumerates at each step. Distinct
/// from `MoveRequestEvent` (engine-level) and `move_cards` (game-level)
/// because the solver also needs to model the stock-draw + recycle as a
/// first-class move.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SolverMove {
/// Move `count` cards from a tableau column to another tableau column.
TableauToTableau { from: usize, to: usize, count: usize },
/// Move the top of a tableau column to a foundation slot.
TableauToFoundation { from: usize, slot: u8 },
/// Move the top of the waste pile to a tableau column.
WasteToTableau { to: usize },
/// Move the top of the waste pile to a foundation slot.
WasteToFoundation { slot: u8 },
/// Draw from stock to waste (or recycle waste → stock if stock is empty).
Draw,
}
/// Compact replica of `GameState` tailored for the solver. Strips
/// undo / score / move-count tracking and replaces the `HashMap` of
/// piles with fixed arrays so the canonical hash is cheap to compute.
#[derive(Clone)]
struct SolverState {
tableau: [Vec<Card>; 7],
foundation: [Vec<Card>; 4],
stock: Vec<Card>,
waste: Vec<Card>,
draw_mode: DrawMode,
/// True when we just drew (or recycled) and have not yet made a
/// productive non-draw move. While set, further consecutive draws
/// without intervening progress are skipped — see the algorithm
/// note above.
just_drew: bool,
/// Number of draws performed since the last non-draw move. Used
/// to detect "we've cycled the entire stock without finding any
/// playable card", which guarantees no further benefit from
/// drawing.
consecutive_draws: u32,
}
impl SolverState {
fn initial(seed: u64, draw_mode: DrawMode) -> Self {
let mut deck = Deck::new();
deck.shuffle(seed);
let (tableau_piles, stock_pile) = deal_klondike(deck);
let tableau: [Vec<Card>; 7] = tableau_piles.map(|p| p.cards);
let foundation: [Vec<Card>; 4] = core::array::from_fn(|_| Vec::new());
Self {
tableau,
foundation,
stock: stock_pile.cards,
waste: Vec::new(),
draw_mode,
just_drew: false,
consecutive_draws: 0,
}
}
/// True when every foundation slot has 13 cards.
fn is_won(&self) -> bool {
self.foundation.iter().all(|f| f.len() == 13)
}
/// Returns the foundation slot that already claims `suit`, or the
/// first empty slot if no slot claims it. Used so foundation moves
/// always target a single deterministic slot per (card, board) pair.
fn target_foundation_slot(&self, suit: Suit) -> Option<u8> {
let mut empty: Option<u8> = None;
for (idx, pile) in self.foundation.iter().enumerate() {
match pile.first() {
Some(bottom) if bottom.suit == suit => return Some(idx as u8),
None if empty.is_none() => empty = Some(idx as u8),
_ => {}
}
}
empty
}
/// Build a temporary `Pile` view for use with the rule helpers.
/// Cheap clone — the helpers only inspect the top card, so we
/// pass a thin wrapper. (The compiler reuses the inner Vec by
/// value because we drop it immediately.)
fn pile_view(pile_type: PileType, cards: &[Card]) -> Pile {
Pile {
pile_type,
cards: cards.to_vec(),
}
}
/// Enumerate every legal candidate move in priority order:
/// foundation > inter-tableau > waste-to-tableau > stock-draw.
/// The order matters — foundation moves shrink the search frontier
/// fastest, and stock-draws are the costliest. See the top-of-file
/// algorithm note.
fn enumerate_moves(&self) -> Vec<SolverMove> {
let mut moves: Vec<SolverMove> = Vec::new();
// 1) Foundation moves from tableau tops.
for (i, col) in self.tableau.iter().enumerate() {
if let Some(top) = col.last()
&& top.face_up
&& let Some(slot) = self.target_foundation_slot(top.suit)
{
let foundation_pile = Self::pile_view(
PileType::Foundation(slot),
&self.foundation[slot as usize],
);
if can_place_on_foundation(top, &foundation_pile) {
moves.push(SolverMove::TableauToFoundation { from: i, slot });
}
}
}
// 2) Foundation move from the waste top.
if let Some(top) = self.waste.last()
&& let Some(slot) = self.target_foundation_slot(top.suit)
{
let foundation_pile = Self::pile_view(
PileType::Foundation(slot),
&self.foundation[slot as usize],
);
if can_place_on_foundation(top, &foundation_pile) {
moves.push(SolverMove::WasteToFoundation { slot });
}
}
// 3) Inter-tableau moves. For each source column, find the
// longest face-up valid run, then enumerate every prefix
// length that lands legally on every other column. Skip
// moves that just relocate a King onto an empty column when
// the source column would also be left empty (a no-op).
for src in 0..7usize {
let col = &self.tableau[src];
if col.is_empty() {
continue;
}
// Find the largest k such that col[col.len()-k..] is all
// face-up and a valid descending alternating run.
let max_run = longest_face_up_run(col);
for count in 1..=max_run {
let start = col.len() - count;
let bottom = &col[start];
for dst in 0..7usize {
if dst == src {
continue;
}
let dst_pile = Self::pile_view(PileType::Tableau(dst), &self.tableau[dst]);
if !can_place_on_tableau(bottom, &dst_pile) {
continue;
}
// Prune the no-op "drag a King from an empty-after-move
// column onto another empty column".
let leaves_source_empty = start == 0;
let dest_empty = self.tableau[dst].is_empty();
if leaves_source_empty
&& dest_empty
&& bottom.rank == crate::card::Rank::King
{
continue;
}
moves.push(SolverMove::TableauToTableau { from: src, to: dst, count });
}
}
}
// 4) Waste → tableau.
if let Some(top) = self.waste.last() {
for dst in 0..7usize {
let dst_pile = Self::pile_view(PileType::Tableau(dst), &self.tableau[dst]);
if can_place_on_tableau(top, &dst_pile) {
moves.push(SolverMove::WasteToTableau { to: dst });
}
}
}
// 5) Draw — but only if there's something to draw or recycle.
// Skip draws when we've already cycled the full stock+waste
// once without making progress; the deterministic stock
// permutation can't produce new value at that point.
let can_draw = !self.stock.is_empty() || !self.waste.is_empty();
let stock_cycle_len = (self.stock.len() + self.waste.len()) as u32;
// `consecutive_draws > stock_cycle_len` is a conservative cap:
// a single full cycle requires at most `ceil(stock_cycle_len / draw_count)`
// draws (Draw 1 → exactly stock_cycle_len; Draw 3 → fewer), so
// anything past that without intervening progress is wasteful.
let cycled_without_progress =
self.consecutive_draws > stock_cycle_len.saturating_add(1);
if can_draw && !cycled_without_progress {
moves.push(SolverMove::Draw);
}
moves
}
/// Apply `mv` to `self`, returning the previous `consecutive_draws`
/// value so the caller can restore it on backtrack.
fn apply_move(&mut self, mv: SolverMove) -> SolverStateUndo {
let prev_just_drew = self.just_drew;
let prev_consec = self.consecutive_draws;
match mv {
SolverMove::TableauToTableau { from, to, count } => {
let start = self.tableau[from].len() - count;
let moved: Vec<Card> = self.tableau[from].split_off(start);
self.tableau[to].extend(moved);
// Flip the newly exposed source top.
if let Some(top) = self.tableau[from].last_mut()
&& !top.face_up
{
top.face_up = true;
}
self.just_drew = false;
self.consecutive_draws = 0;
}
SolverMove::TableauToFoundation { from, slot } => {
if let Some(card) = self.tableau[from].pop() {
self.foundation[slot as usize].push(card);
if let Some(top) = self.tableau[from].last_mut()
&& !top.face_up
{
top.face_up = true;
}
}
self.just_drew = false;
self.consecutive_draws = 0;
}
SolverMove::WasteToTableau { to } => {
if let Some(card) = self.waste.pop() {
self.tableau[to].push(card);
}
self.just_drew = false;
self.consecutive_draws = 0;
}
SolverMove::WasteToFoundation { slot } => {
if let Some(card) = self.waste.pop() {
self.foundation[slot as usize].push(card);
}
self.just_drew = false;
self.consecutive_draws = 0;
}
SolverMove::Draw => {
if self.stock.is_empty() {
// Recycle waste back to stock face-down, reversed.
let mut recycled: Vec<Card> = self.waste.drain(..).collect();
recycled.reverse();
for mut c in recycled {
c.face_up = false;
self.stock.push(c);
}
} else {
let draw_count = match self.draw_mode {
DrawMode::DrawOne => 1,
DrawMode::DrawThree => 3,
};
let avail = self.stock.len().min(draw_count);
let drain_start = self.stock.len() - avail;
let drawn: Vec<Card> = self.stock.drain(drain_start..).collect();
for mut c in drawn {
c.face_up = true;
self.waste.push(c);
}
}
self.just_drew = true;
self.consecutive_draws = self.consecutive_draws.saturating_add(1);
}
}
SolverStateUndo {
prev_just_drew,
prev_consec,
}
}
/// Iterative depth-first search using an explicit stack — recursion
/// blew through Rust's default 8 MB stack on long real-deal solves
/// because each frame held a `SolverState` clone. The explicit
/// stack lives on the heap and grows only with `Vec` capacity, not
/// with thread-stack pages.
///
/// Returns `true` as soon as a winning leaf is found. Sets
/// `*budget_exceeded = true` if either budget trips before a
/// verdict.
fn search(
self,
config: &SolverConfig,
visited: &mut HashSet<u64>,
moves_consumed: &mut u64,
budget_exceeded: &mut bool,
) -> bool {
// Each stack frame keeps a state plus the move iterator we
// haven't yet expanded. Popping a frame is the backtrack.
struct Frame {
state: SolverState,
pending: std::vec::IntoIter<SolverMove>,
}
// Quick exits before allocating the stack.
if self.is_won() {
return true;
}
if *moves_consumed >= config.move_budget || visited.len() >= config.state_budget {
*budget_exceeded = true;
return false;
}
let root_hash = self.canonical_hash();
if !visited.insert(root_hash) {
return false;
}
let root_moves = self.enumerate_moves();
let mut stack: Vec<Frame> = Vec::new();
stack.push(Frame {
state: self,
pending: root_moves.into_iter(),
});
while let Some(frame) = stack.last_mut() {
// Budget gates — checked before consuming the next move so
// the budget exhaustion is reflected in the verdict.
if *moves_consumed >= config.move_budget
|| visited.len() >= config.state_budget
{
*budget_exceeded = true;
return false;
}
let Some(mv) = frame.pending.next() else {
// Exhausted this frame's children — backtrack.
stack.pop();
continue;
};
*moves_consumed = moves_consumed.saturating_add(1);
let mut next = frame.state.clone();
next.apply_move(mv);
if next.is_won() {
return true;
}
let h = next.canonical_hash();
if !visited.insert(h) {
continue;
}
let next_moves = next.enumerate_moves();
stack.push(Frame {
state: next,
pending: next_moves.into_iter(),
});
}
false
}
/// Build a deterministic 64-bit hash of the visible game state.
///
/// The encoding covers every field that can affect future legal
/// moves: tableau column contents (with face_up state), foundation
/// tops (it's enough to know the top card per slot — the rest is
/// implied by the rank), stock + waste card ids in order, and the
/// draw mode. Two states that differ only in `just_drew` or
/// `consecutive_draws` hash equally — those fields are search
/// metadata, not game state.
fn canonical_hash(&self) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
// Tag the encoding with a version byte so future schema
// changes invalidate cached hashes cleanly.
0u8.hash(&mut h);
for col in &self.tableau {
(col.len() as u32).hash(&mut h);
for c in col {
c.id.hash(&mut h);
c.face_up.hash(&mut h);
}
}
for f in &self.foundation {
match f.last() {
Some(top) => {
1u8.hash(&mut h);
top.id.hash(&mut h);
}
None => {
0u8.hash(&mut h);
}
}
}
(self.stock.len() as u32).hash(&mut h);
for c in &self.stock {
c.id.hash(&mut h);
}
(self.waste.len() as u32).hash(&mut h);
for c in &self.waste {
c.id.hash(&mut h);
}
match self.draw_mode {
DrawMode::DrawOne => 1u8.hash(&mut h),
DrawMode::DrawThree => 3u8.hash(&mut h),
}
h.finish()
}
}
/// Bookkeeping captured by [`SolverState::apply_move`] so the caller
/// could in principle restore mutated state. Currently unused —
/// `search` clones before applying — but kept so a future iteration
/// can switch to in-place mutation without changing the apply path.
#[allow(dead_code)]
struct SolverStateUndo {
prev_just_drew: bool,
prev_consec: u32,
}
/// Returns the length of the longest face-up valid descending
/// alternating-colour run anchored at the top of `cards`. Returns 0
/// when the top is face-down (or the column is empty); returns 1 for
/// a single face-up card; otherwise extends as long as the
/// `is_valid_tableau_sequence` constraint holds.
fn longest_face_up_run(cards: &[Card]) -> usize {
if cards.is_empty() {
return 0;
}
let n = cards.len();
let mut k = 0usize;
while k < n {
let candidate = &cards[n - k - 1..];
if !candidate.iter().all(|c| c.face_up) {
break;
}
if !is_valid_tableau_sequence(candidate) {
break;
}
k += 1;
}
k
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::card::{Card, Rank, Suit};
/// Construct a `SolverState` from raw piles for the synthetic
/// hand-crafted test scenarios. Skips deck-shuffle and the deal
/// step so tests can describe a near-finished or pathological
/// position directly.
fn synthetic(
tableau: [Vec<Card>; 7],
foundation: [Vec<Card>; 4],
stock: Vec<Card>,
waste: Vec<Card>,
draw_mode: DrawMode,
) -> SolverState {
SolverState {
tableau,
foundation,
stock,
waste,
draw_mode,
just_drew: false,
consecutive_draws: 0,
}
}
fn empty_columns() -> [Vec<Card>; 7] {
core::array::from_fn(|_| Vec::new())
}
fn empty_foundations() -> [Vec<Card>; 4] {
core::array::from_fn(|_| Vec::new())
}
fn ace(suit: Suit, id: u32) -> Card {
Card { id, suit, rank: Rank::Ace, face_up: true }
}
fn rank_card(suit: Suit, rank: Rank, id: u32) -> Card {
Card { id, suit, rank, face_up: true }
}
fn full_run(suit: Suit, base_id: u32) -> Vec<Card> {
let ranks = [
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
Rank::Jack, Rank::Queen, Rank::King,
];
ranks
.iter()
.enumerate()
.map(|(i, r)| Card {
id: base_id + i as u32,
suit,
rank: *r,
face_up: true,
})
.collect()
}
#[test]
fn solver_recognises_obviously_winnable_deal() {
// Construct a position where the four foundations are already
// 12 cards each (Ace through Queen) and the four Kings sit
// exposed on individual tableau columns. The solver only has
// to play the four Kings to win.
let mut foundations: [Vec<Card>; 4] = empty_foundations();
for (slot, suit) in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]
.iter()
.enumerate()
{
let mut full = full_run(*suit, (slot as u32) * 13);
full.pop(); // remove King
foundations[slot] = full;
}
let mut tableau = empty_columns();
tableau[0] = vec![rank_card(Suit::Clubs, Rank::King, 100)];
tableau[1] = vec![rank_card(Suit::Diamonds, Rank::King, 101)];
tableau[2] = vec![rank_card(Suit::Hearts, Rank::King, 102)];
tableau[3] = vec![rank_card(Suit::Spades, Rank::King, 103)];
let state = synthetic(tableau, foundations, Vec::new(), Vec::new(), DrawMode::DrawOne);
let mut visited: HashSet<u64> = HashSet::new();
let mut moves_consumed: u64 = 0;
let mut budget_exceeded = false;
let cfg = SolverConfig::default();
let won = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded);
assert!(won, "obviously-winnable position must be recognised as Winnable");
assert!(!budget_exceeded);
assert!(
moves_consumed < 1000,
"near-finished deal should solve in well under 1k moves; consumed {moves_consumed}"
);
}
#[test]
fn solver_recognises_obviously_unwinnable_deal() {
// Synthesise a state where one tableau column buries the Ace
// of Spades under the Two of Spades, both face-up, with no
// stock, no waste, no other moves available. The Two cannot
// go anywhere (nothing to land on; no foundation accepts a
// bare Two), and the Ace is buried, so the deal is dead.
let mut tableau = empty_columns();
// Column 0: bottom-to-top [A♠, 2♠]. The Ace is the bottom
// card; the Two on top of it has no valid destination.
tableau[0] = vec![
Card { id: 0, suit: Suit::Spades, rank: Rank::Ace, face_up: true },
Card { id: 1, suit: Suit::Spades, rank: Rank::Two, face_up: true },
];
// Other six columns isolated. Put a face-up King with no
// matching Queen anywhere — it cannot move because every
// other column is empty (Kings move to empty columns, but a
// King already sitting alone on a column moving to an empty
// column is a no-op, pruned by enumerate_moves).
tableau[1] = vec![rank_card(Suit::Clubs, Rank::King, 2)];
// Empty columns 2..6 — irrelevant.
let state = synthetic(
tableau,
empty_foundations(),
Vec::new(),
Vec::new(),
DrawMode::DrawOne,
);
let cfg = SolverConfig::default();
let mut visited: HashSet<u64> = HashSet::new();
let mut moves_consumed: u64 = 0;
let mut budget_exceeded = false;
let won = 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!(!budget_exceeded, "small synthetic state must complete within budget");
}
#[test]
fn solver_returns_inconclusive_when_budget_exceeded() {
// Tiny budgets force the search to bail before exploring
// meaningful branches on a real fresh deal.
let cfg = SolverConfig {
move_budget: 50,
state_budget: 50,
};
let result = try_solve(0, DrawMode::DrawOne, &cfg);
assert_eq!(
result,
SolverResult::Inconclusive,
"very tight budgets must surface as Inconclusive on a real deal"
);
}
#[test]
fn solver_is_deterministic() {
// Same seed + same draw mode + same config must always return
// the same verdict. We use a tight budget so the test runs
// fast even when seed N happens to be a long-search deal.
let cfg = SolverConfig {
move_budget: 5_000,
state_budget: 5_000,
};
let r1 = try_solve(7, DrawMode::DrawOne, &cfg);
let r2 = try_solve(7, DrawMode::DrawOne, &cfg);
let r3 = try_solve(7, DrawMode::DrawOne, &cfg);
assert_eq!(r1, r2, "repeat solves must yield the same result");
assert_eq!(r2, r3);
}
#[test]
fn solver_handles_draw_three_mode() {
// The solver must accept DrawMode::DrawThree and never panic.
// A tight budget keeps the test fast — we only assert that
// the call returns a verdict (any of the three variants) and
// that the verdict is reproducible.
let cfg = SolverConfig {
move_budget: 5_000,
state_budget: 5_000,
};
let r1 = try_solve(123, DrawMode::DrawThree, &cfg);
let r2 = try_solve(123, DrawMode::DrawThree, &cfg);
assert_eq!(r1, r2, "DrawThree solver must be deterministic");
}
#[test]
fn try_solve_winnable_synthetic_via_real_init_path() {
// Cross-check: try_solve with the default budget on a real
// dealt seed should never panic and should return one of the
// three verdict variants. We don't pin a specific verdict —
// that would tightly couple the test to RNG behaviour — but
// we do assert the function reaches a result.
let cfg = SolverConfig::default();
let _verdict = try_solve(42, DrawMode::DrawOne, &cfg);
// Reaching here means the function returned without panic.
}
#[test]
fn longest_face_up_run_handles_face_down_at_top() {
let cards = vec![
Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: false },
];
assert_eq!(longest_face_up_run(&cards), 0);
}
#[test]
fn longest_face_up_run_extends_through_valid_run() {
let cards = vec![
// bottom: face-down filler
Card { id: 0, suit: Suit::Spades, rank: Rank::Two, face_up: false },
Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: true },
Card { id: 2, suit: Suit::Hearts, rank: Rank::Queen, face_up: true },
Card { id: 3, suit: Suit::Clubs, rank: Rank::Jack, face_up: true },
];
assert_eq!(longest_face_up_run(&cards), 3);
}
#[test]
fn longest_face_up_run_breaks_on_invalid_sequence() {
// K♠ Q♥ Q♣ — second pair fails the descending check, so the
// run is just the top single card (Q♣).
let cards = vec![
Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: true },
Card { id: 2, suit: Suit::Hearts, rank: Rank::Queen, face_up: true },
Card { id: 3, suit: Suit::Clubs, rank: Rank::Queen, face_up: true },
];
assert_eq!(longest_face_up_run(&cards), 1);
}
#[test]
fn target_foundation_slot_prefers_claimed_suit() {
let mut state = synthetic(
empty_columns(),
empty_foundations(),
Vec::new(),
Vec::new(),
DrawMode::DrawOne,
);
// Slot 0 is empty; slot 1 already holds the Ace of Hearts.
state.foundation[1].push(ace(Suit::Hearts, 0));
assert_eq!(state.target_foundation_slot(Suit::Hearts), Some(1));
}
#[test]
fn target_foundation_slot_falls_back_to_empty() {
let state = synthetic(
empty_columns(),
empty_foundations(),
Vec::new(),
Vec::new(),
DrawMode::DrawOne,
);
// No slot claims any suit; every Ace targets slot 0.
assert_eq!(state.target_foundation_slot(Suit::Spades), Some(0));
}
/// Scan a wide seed window to find one Winnable + one Unwinnable
/// seed under tight budgets. Used during development to source the
/// fixture seeds for the engine-level retry test.
/// Run with:
/// `cargo test -p solitaire_core --release -- --ignored find_unwinnable --nocapture`.
#[test]
#[ignore]
fn find_unwinnable() {
let cfg = SolverConfig::default();
let mut found = 0;
let mut counts = [0u32; 3];
for seed in 0u64..500 {
let r = try_solve(seed, DrawMode::DrawOne, &cfg);
let bucket = match r {
SolverResult::Winnable => 0,
SolverResult::Unwinnable => 1,
SolverResult::Inconclusive => 2,
};
counts[bucket] += 1;
if r == SolverResult::Unwinnable {
println!("seed {seed} -> Unwinnable");
let next = try_solve(seed.wrapping_add(1), DrawMode::DrawOne, &cfg);
println!("seed {} -> {:?}", seed.wrapping_add(1), next);
found += 1;
if found >= 5 {
break;
}
}
}
println!(
"(scan complete) Winnable={} Unwinnable={} Inconclusive={}",
counts[0], counts[1], counts[2]
);
}
/// Manual bench — run with:
/// `cargo test -p solitaire_core --release -- --ignored solver_bench --nocapture`.
/// Prints per-seed timing and the verdict distribution so a developer
/// can sanity-check the median. Not part of the regular suite because
/// (a) it's slow and (b) timing output is noise during normal runs.
#[test]
#[ignore]
fn solver_bench() {
let cfg = SolverConfig::default();
let mut samples_ms: Vec<u128> = Vec::new();
let mut counts = [0u32; 3];
for seed in 0u64..20 {
let t = std::time::Instant::now();
let r = try_solve(seed, DrawMode::DrawOne, &cfg);
let ms = t.elapsed().as_millis();
samples_ms.push(ms);
let bucket = match r {
SolverResult::Winnable => 0,
SolverResult::Unwinnable => 1,
SolverResult::Inconclusive => 2,
};
counts[bucket] += 1;
println!("seed={seed:3} {ms:>6} ms {r:?}");
}
samples_ms.sort_unstable();
let median = samples_ms[samples_ms.len() / 2];
let total: u128 = samples_ms.iter().sum();
println!(
"\nmedian: {median} ms mean: {} ms Winnable: {} Unwinnable: {} Inconclusive: {}",
total / samples_ms.len() as u128,
counts[0], counts[1], counts[2],
);
}
}
+8 -5
View File
@@ -141,9 +141,9 @@ 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, TIME_BONUS_MULTIPLIER_MAX, TIME_BONUS_MULTIPLIER_MIN, Theme, WindowGeometry, SOLVER_DEAL_RETRY_CAP, TIME_BONUS_MULTIPLIER_MAX,
TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_MAX_SECS, TOOLTIP_DELAY_MIN_SECS, TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_MAX_SECS,
TOOLTIP_DELAY_STEP_SECS, TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
}; };
pub mod auth_tokens; pub mod auth_tokens;
@@ -155,7 +155,10 @@ pub mod sync_client;
pub use sync_client::{provider_for_backend, LocalOnlyProvider, SolitaireServerClient}; pub use sync_client::{provider_for_backend, LocalOnlyProvider, SolitaireServerClient};
pub mod replay; pub mod replay;
#[allow(deprecated)]
pub use replay::{latest_replay_path, load_latest_replay_from, save_latest_replay_to};
pub use replay::{ pub use replay::{
latest_replay_path, load_latest_replay_from, save_latest_replay_to, Replay, ReplayMove, append_replay_to_history, load_replay_history_from, migrate_legacy_latest_replay,
REPLAY_SCHEMA_VERSION, replay_history_path, save_replay_history_to, Replay, ReplayHistory, ReplayMove,
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION,
}; };
+405
View File
@@ -31,6 +31,34 @@ use solitaire_core::pile::PileType;
const APP_DIR_NAME: &str = "solitaire_quest"; const APP_DIR_NAME: &str = "solitaire_quest";
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json"; const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
/// Maximum number of recent winning replays the rolling history retains.
///
/// When [`append_replay_to_history`] pushes a fresh entry past this cap,
/// the oldest entry is dropped so the file never grows unbounded. The
/// player can revisit any of the last [`REPLAY_HISTORY_CAP`] wins from
/// the Stats overlay's replay selector — older wins age out silently.
pub const REPLAY_HISTORY_CAP: usize = 8;
/// Save-file schema version for [`ReplayHistory`]. Bump when the on-disk
/// shape of the wrapper changes incompatibly so [`load_replay_history_from`]
/// returns `None` for older files (the player simply sees an empty
/// history rather than a half-loaded broken one). Bumping
/// [`REPLAY_SCHEMA_VERSION`] independently invalidates individual
/// [`Replay`] payloads inside an otherwise-current history.
///
/// History:
/// - v1 (current): initial release of the rolling history wrapper.
pub const REPLAY_HISTORY_SCHEMA_VERSION: u32 = 1;
/// Default value for [`ReplayHistory::schema_version`] when deserialising
/// files that pre-date the field. Any value other than
/// [`REPLAY_HISTORY_SCHEMA_VERSION`] causes [`load_replay_history_from`]
/// to return `None`.
fn history_schema_v0() -> u32 {
0
}
/// Save-file schema version for [`Replay`]. Increment when the on-disk /// Save-file schema version for [`Replay`]. Increment when the on-disk
/// representation changes incompatibly so [`load_latest_replay_from`] can /// representation changes incompatibly so [`load_latest_replay_from`] can
@@ -138,17 +166,87 @@ impl Replay {
} }
} }
/// Rolling history of the player's most recent winning replays.
///
/// Stored as a single JSON file at
/// `<data_dir>/solitaire_quest/replays.json` (see
/// [`replay_history_path`]). Capped at [`REPLAY_HISTORY_CAP`] entries —
/// when [`append_replay_to_history`] pushes past the cap, the oldest
/// entry is dropped so the file never grows unbounded.
///
/// `replays[0]` is always the most recent win; the Stats overlay's
/// replay selector defaults to that entry and surfaces the older
/// entries behind a small chooser so the player can revisit a memorable
/// game even after a more recent win.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReplayHistory {
/// Schema version. See [`REPLAY_HISTORY_SCHEMA_VERSION`].
#[serde(default = "history_schema_v0")]
pub schema_version: u32,
/// Most recent first. Capped at [`REPLAY_HISTORY_CAP`] entries —
/// older entries drop off when the cap is hit.
pub replays: Vec<Replay>,
}
impl Default for ReplayHistory {
/// An empty history at the current schema version. Used by callers
/// that need a starting point before the first winning replay has
/// ever been recorded.
fn default() -> Self {
Self {
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
replays: Vec::new(),
}
}
}
impl ReplayHistory {
/// Returns the most recent replay (`replays[0]`), or `None` when the
/// history is empty. Convenience used by the Stats overlay's default
/// selector position.
pub fn most_recent(&self) -> Option<&Replay> {
self.replays.first()
}
/// Returns the number of replays currently retained.
pub fn len(&self) -> usize {
self.replays.len()
}
/// Returns `true` when no replays have been recorded yet.
pub fn is_empty(&self) -> bool {
self.replays.is_empty()
}
}
/// Returns the platform-specific path to `latest_replay.json`, or `None` /// Returns the platform-specific path to `latest_replay.json`, or `None`
/// if `dirs::data_dir()` is unavailable (e.g. minimal Linux containers). /// if `dirs::data_dir()` is unavailable (e.g. minimal Linux containers).
#[deprecated(
note = "single-slot replay storage replaced by the rolling history at \
replay_history_path(); kept for the one-shot legacy migration \
in migrate_legacy_latest_replay"
)]
pub fn latest_replay_path() -> Option<PathBuf> { pub fn latest_replay_path() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME)) dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME))
} }
/// Returns the platform-specific path to `replays.json`, the rolling
/// history file, or `None` if `dirs::data_dir()` is unavailable (e.g.
/// minimal Linux containers).
pub fn replay_history_path() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(REPLAY_HISTORY_FILE_NAME))
}
/// Save a [`Replay`] atomically to `path` using the standard `.tmp` → /// Save a [`Replay`] atomically to `path` using the standard `.tmp` →
/// rename contract that the rest of `storage.rs` uses. /// rename contract that the rest of `storage.rs` uses.
/// ///
/// Overwrites any existing replay — only the most recent winning replay /// Overwrites any existing replay — only the most recent winning replay
/// is retained on disk. /// is retained on disk.
#[deprecated(
note = "single-slot replay storage replaced by the rolling history; \
use append_replay_to_history instead. Kept for the one-shot \
legacy migration."
)]
pub fn save_latest_replay_to(path: &Path, replay: &Replay) -> io::Result<()> { pub fn save_latest_replay_to(path: &Path, replay: &Replay) -> io::Result<()> {
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?; fs::create_dir_all(parent)?;
@@ -168,6 +266,11 @@ pub fn save_latest_replay_to(path: &Path, replay: &Replay) -> io::Result<()> {
/// "No replay recorded yet" caption rather than a half-loaded broken /// "No replay recorded yet" caption rather than a half-loaded broken
/// replay. Bumping [`REPLAY_SCHEMA_VERSION`] therefore invalidates every /// replay. Bumping [`REPLAY_SCHEMA_VERSION`] therefore invalidates every
/// older save without further migration code. /// older save without further migration code.
#[deprecated(
note = "single-slot replay storage replaced by the rolling history; \
use load_replay_history_from instead. Kept for the one-shot \
legacy migration."
)]
pub fn load_latest_replay_from(path: &Path) -> Option<Replay> { pub fn load_latest_replay_from(path: &Path) -> Option<Replay> {
let data = fs::read(path).ok()?; let data = fs::read(path).ok()?;
let replay: Replay = serde_json::from_slice(&data).ok()?; let replay: Replay = serde_json::from_slice(&data).ok()?;
@@ -177,7 +280,124 @@ pub fn load_latest_replay_from(path: &Path) -> Option<Replay> {
Some(replay) Some(replay)
} }
/// Save a [`ReplayHistory`] atomically to `path` using the standard
/// `.tmp` → rename contract.
///
/// The on-disk encoding is pretty-printed JSON; the file is intended to
/// be small (≤ [`REPLAY_HISTORY_CAP`] entries, each carrying a few
/// hundred move records at most) so the readability tradeoff is fine.
pub fn save_replay_history_to(path: &Path, history: &ReplayHistory) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(history).map_err(io::Error::other)?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, json.as_bytes())?;
fs::rename(&tmp, path)?;
Ok(())
}
/// Load a [`ReplayHistory`] from `path`, returning `None` when the file
/// is missing, corrupt, or carries a [`schema_version`](ReplayHistory::schema_version)
/// other than [`REPLAY_HISTORY_SCHEMA_VERSION`].
///
/// Individual [`Replay`] entries inside an otherwise-current history are
/// filtered to only those carrying [`REPLAY_SCHEMA_VERSION`] — older
/// entries are silently dropped so a future bump of the inner replay
/// schema does not corrupt the wrapper.
pub fn load_replay_history_from(path: &Path) -> Option<ReplayHistory> {
let data = fs::read(path).ok()?;
let history: ReplayHistory = serde_json::from_slice(&data).ok()?;
if history.schema_version != REPLAY_HISTORY_SCHEMA_VERSION {
return None;
}
let filtered: Vec<Replay> = history
.replays
.into_iter()
.filter(|r| r.schema_version == REPLAY_SCHEMA_VERSION)
.collect();
Some(ReplayHistory {
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
replays: filtered,
})
}
/// Append `replay` to the front of the rolling history at `path`,
/// dropping the oldest entry once [`REPLAY_HISTORY_CAP`] is exceeded,
/// and persist the updated history atomically.
///
/// If `path` has no existing history (missing file, corrupt, or
/// schema-mismatched) a fresh [`ReplayHistory::default`] is used as the
/// starting point so the new replay is always saved. The returned
/// [`ReplayHistory`] is the exact value written to disk so callers can
/// update an in-memory mirror (e.g. the Stats overlay's
/// `ReplayHistoryResource`) without a follow-up `load`.
pub fn append_replay_to_history(
path: &Path,
replay: Replay,
) -> io::Result<ReplayHistory> {
let mut history = load_replay_history_from(path).unwrap_or_default();
// Most recent first. Reserve the front slot; pop the oldest if we
// exceed the cap so the file never grows unbounded.
history.replays.insert(0, replay);
if history.replays.len() > REPLAY_HISTORY_CAP {
history.replays.truncate(REPLAY_HISTORY_CAP);
}
save_replay_history_to(path, &history)?;
Ok(history)
}
/// One-shot migration from the legacy single-slot
/// `latest_replay.json` file to the rolling [`ReplayHistory`] stored at
/// `history_path`.
///
/// Behaviour matrix:
/// - `history_path` already exists → no-op (the rolling history wins).
/// - `history_path` is absent and `latest_path` is absent → no-op.
/// - `history_path` is absent and `latest_path` exists with a valid
/// replay → seed a fresh history with that one replay and write it.
/// - `history_path` is absent and `latest_path` exists but is corrupt /
/// schema-mismatched → write an empty history (we know the player is
/// on the new build and shouldn't keep being prompted to migrate).
///
/// The legacy `latest_replay.json` file is intentionally NOT deleted by
/// this helper — keep it for one release as a safety net so a player
/// rolling back to the previous build doesn't lose their last winning
/// replay. The deletion is planned for the release after this one.
pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
if history_path.exists() {
// Rolling history is authoritative once it exists.
return;
}
if !latest_path.exists() {
return;
}
// Use the deprecated loader directly — the migration is the one
// place we still consult the legacy file shape on purpose.
#[allow(deprecated)]
let legacy = load_latest_replay_from(latest_path);
let history = match legacy {
Some(replay) => ReplayHistory {
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
replays: vec![replay],
},
None => ReplayHistory::default(),
};
if let Err(e) = save_replay_history_to(history_path, &history) {
// Migration failure is non-fatal: on the next launch we'll just
// try again. We log to stderr rather than panic so headless
// tests stay quiet.
eprintln!(
"replay: failed to migrate legacy latest_replay.json into rolling history: {e}",
);
}
}
#[cfg(test)] #[cfg(test)]
// The legacy single-slot tests still exercise `save_latest_replay_to` /
// `load_latest_replay_from` on purpose — they're the round-trip
// guardrails for the migration source format.
#[allow(deprecated)]
mod tests { mod tests {
use super::*; use super::*;
use std::env; use std::env;
@@ -294,4 +514,189 @@ mod tests {
assert!(load_latest_replay_from(&path).is_none()); assert!(load_latest_replay_from(&path).is_none());
let _ = fs::remove_file(&path); let _ = fs::remove_file(&path);
} }
// -----------------------------------------------------------------------
// ReplayHistory — rolling list of recent wins
// -----------------------------------------------------------------------
/// Build a [`Replay`] whose `final_score` carries `id` so tests can
/// assert ordering / identity without writing a deep equality match.
fn replay_with_id(id: i32) -> Replay {
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
Replay::new(
id as u64,
DrawMode::DrawOne,
GameMode::Classic,
60,
id,
date,
vec![ReplayMove::StockClick],
)
}
/// Pushing past [`REPLAY_HISTORY_CAP`] must drop the oldest entries —
/// the on-disk file (and the in-memory mirror returned by the helper)
/// stays bounded so the user's data dir never grows unbounded.
#[test]
fn append_replay_to_history_caps_at_eight() {
let path = tmp_path("history_cap");
let _ = fs::remove_file(&path);
let mut last_returned = ReplayHistory::default();
for i in 0..10 {
last_returned = append_replay_to_history(&path, replay_with_id(i))
.expect("append must succeed");
}
assert_eq!(
last_returned.replays.len(),
REPLAY_HISTORY_CAP,
"history must be capped at REPLAY_HISTORY_CAP entries",
);
// The most recent ten pushes were ids 0..=9; ids 9, 8, ..., 2
// survive (newest first), ids 0 and 1 aged out.
let ids: Vec<i32> = last_returned.replays.iter().map(|r| r.final_score).collect();
assert_eq!(
ids,
vec![9, 8, 7, 6, 5, 4, 3, 2],
"newest entries must survive, oldest must age out",
);
// The on-disk file must agree with the returned in-memory copy.
let loaded = load_replay_history_from(&path).expect("load must succeed");
assert_eq!(loaded, last_returned, "disk must mirror returned history");
let _ = fs::remove_file(&path);
}
/// `append_replay_to_history` must place new entries at index 0 so
/// the Stats overlay's default selector (most recent) lands on the
/// just-saved replay.
#[test]
fn append_replay_inserts_at_front() {
let path = tmp_path("history_front");
let _ = fs::remove_file(&path);
append_replay_to_history(&path, replay_with_id(1)).expect("append 1");
append_replay_to_history(&path, replay_with_id(2)).expect("append 2");
let history = append_replay_to_history(&path, replay_with_id(3)).expect("append 3");
let ids: Vec<i32> = history.replays.iter().map(|r| r.final_score).collect();
assert_eq!(
ids,
vec![3, 2, 1],
"history must be reverse-chronological (newest first)",
);
let _ = fs::remove_file(&path);
}
/// On first launch with the new code, a pre-existing
/// `latest_replay.json` must seed the new rolling history so the
/// player doesn't lose their last winning replay across the upgrade.
#[test]
fn legacy_latest_replay_migrates_to_history_on_first_launch() {
let latest = tmp_path("legacy_migrate_latest");
let history = tmp_path("legacy_migrate_history");
let _ = fs::remove_file(&latest);
let _ = fs::remove_file(&history);
// Seed the legacy file with a real replay.
let legacy_replay = sample_replay();
save_latest_replay_to(&latest, &legacy_replay).expect("seed legacy");
assert!(!history.exists(), "history file must not exist pre-migration");
migrate_legacy_latest_replay(&latest, &history);
assert!(history.exists(), "migration must create the history file");
let loaded = load_replay_history_from(&history)
.expect("post-migration history must load");
assert_eq!(loaded.replays.len(), 1, "history must hold exactly the legacy entry");
assert_eq!(loaded.replays[0], legacy_replay, "entry must equal the legacy replay");
// Legacy file is intentionally retained for one release as a
// safety net — see `migrate_legacy_latest_replay` doc comment.
assert!(latest.exists(), "legacy file must NOT be deleted by migration");
let _ = fs::remove_file(&latest);
let _ = fs::remove_file(&history);
}
/// When the rolling history file already exists, the migration must
/// be a no-op — we never want to overwrite the player's accumulated
/// history with a stale single-slot legacy entry.
#[test]
fn migrate_is_noop_when_history_already_exists() {
let latest = tmp_path("legacy_noop_latest");
let history = tmp_path("legacy_noop_history");
let _ = fs::remove_file(&latest);
let _ = fs::remove_file(&history);
save_latest_replay_to(&latest, &sample_replay()).expect("seed legacy");
let pre_existing = ReplayHistory {
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
replays: vec![replay_with_id(42)],
};
save_replay_history_to(&history, &pre_existing).expect("seed history");
migrate_legacy_latest_replay(&latest, &history);
let loaded = load_replay_history_from(&history).expect("load");
assert_eq!(loaded, pre_existing, "existing history must not be overwritten");
let _ = fs::remove_file(&latest);
let _ = fs::remove_file(&history);
}
/// A populated [`ReplayHistory`] must round-trip byte-identically
/// through `save_replay_history_to` / `load_replay_history_from`.
#[test]
fn replay_history_round_trips_through_save_and_load() {
let path = tmp_path("history_round_trip");
let _ = fs::remove_file(&path);
let history = ReplayHistory {
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
replays: vec![replay_with_id(7), replay_with_id(3), sample_replay()],
};
save_replay_history_to(&path, &history).expect("save");
let loaded = load_replay_history_from(&path).expect("load");
assert_eq!(loaded, history, "round-trip must preserve every field");
let _ = fs::remove_file(&path);
}
/// A file written by an older history schema must be rejected so the
/// player sees a clean empty history rather than a half-loaded one.
#[test]
fn replay_history_legacy_schema_version_falls_through_to_none() {
let path = tmp_path("history_legacy_schema");
let _ = fs::remove_file(&path);
// No `schema_version` key → defaults to 0 via `history_schema_v0()`.
let v0_json = r#"{
"replays": []
}"#;
fs::write(&path, v0_json).expect("write v0 fixture");
assert!(
load_replay_history_from(&path).is_none(),
"v0 history must be rejected (schema gate)",
);
let _ = fs::remove_file(&path);
}
/// Atomic-write contract for the rolling history — `.tmp` must not be
/// left behind after `save_replay_history_to` returns.
#[test]
fn replay_history_save_is_atomic() {
let path = tmp_path("history_atomic");
let _ = fs::remove_file(&path);
save_replay_history_to(&path, &ReplayHistory::default()).expect("save");
let tmp = path.with_extension("json.tmp");
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
let _ = fs::remove_file(&path);
}
} }
+73
View File
@@ -166,6 +166,21 @@ pub struct Settings {
/// `#[serde(default = "default_time_bonus_multiplier")]`. /// `#[serde(default = "default_time_bonus_multiplier")]`.
#[serde(default = "default_time_bonus_multiplier")] #[serde(default = "default_time_bonus_multiplier")]
pub time_bonus_multiplier: f32, pub time_bonus_multiplier: f32,
/// When `true`, the engine rejects new-game deals the
/// [`solitaire_core::solver`] cannot prove winnable, retrying
/// fresh seeds up to [`SOLVER_DEAL_RETRY_CAP`] attempts before
/// giving up and using the last tried seed. Off by default —
/// the solver adds a few hundred milliseconds of latency on the
/// pathological deals that hit the budget cap, and not every
/// player wants to wait. Older `settings.json` files written
/// before this field existed deserialize cleanly to `false` via
/// `#[serde(default)]`.
///
/// Scope: only random-seed Classic-mode deals are filtered.
/// Daily challenges, replays, and explicit-seed requests skip the
/// solver retry loop — see `solitaire_engine::handle_new_game`.
#[serde(default)]
pub winnable_deals_only: bool,
} }
fn default_draw_mode() -> DrawMode { fn default_draw_mode() -> DrawMode {
@@ -223,6 +238,17 @@ fn default_time_bonus_multiplier() -> f32 {
1.0 1.0
} }
/// Maximum number of seed retries [`solitaire_engine::handle_new_game`]
/// is willing to attempt before giving up and accepting the latest
/// candidate seed when [`Settings::winnable_deals_only`] is on. If
/// every retry comes back [`SolverResult::Unwinnable`] (which would
/// be very unusual) we'd rather hand the player a possibly-unwinnable
/// deal than spin forever on the main thread.
///
/// 50 attempts × ~50 ms median per solve = ~2.5 s worst-case stall —
/// the upper bound on UI freeze when the toggle is on.
pub const SOLVER_DEAL_RETRY_CAP: u32 = 50;
impl Default for Settings { impl Default for Settings {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -241,6 +267,7 @@ impl Default for Settings {
shown_achievement_onboarding: false, shown_achievement_onboarding: false,
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,
} }
} }
} }
@@ -428,6 +455,7 @@ mod tests {
shown_achievement_onboarding: false, shown_achievement_onboarding: false,
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,
}; };
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);
@@ -835,4 +863,49 @@ mod tests {
s2.time_bonus_multiplier s2.time_bonus_multiplier
); );
} }
// -----------------------------------------------------------------------
// winnable_deals_only — solver-backed deal filter toggle
// -----------------------------------------------------------------------
#[test]
fn settings_winnable_deals_only_default_is_false() {
// Off by default — the solver adds latency we shouldn't impose
// on every player without their consent.
assert!(
!Settings::default().winnable_deals_only,
"default winnable_deals_only must be false"
);
}
#[test]
fn settings_winnable_deals_only_round_trip() {
let path = tmp_path("winnable_deals_only_round_trip");
let _ = fs::remove_file(&path);
let s = Settings {
winnable_deals_only: true,
..Settings::default()
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
assert!(
loaded.winnable_deals_only,
"winnable_deals_only must survive serde round-trip"
);
let _ = fs::remove_file(&path);
}
#[test]
fn legacy_settings_without_winnable_deals_only_deserializes_to_false() {
// A settings.json written before this field existed must
// deserialize cleanly to `false` (the default-off behaviour)
// rather than failing the whole load or surprising the player
// by switching the toggle on.
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
assert!(
!s.winnable_deals_only,
"legacy settings.json missing winnable_deals_only must deserialize to false"
);
}
} }
+474 -68
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::{
@@ -25,11 +26,13 @@ use crate::events::{
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate}; use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate};
use crate::replay_playback::ReplayPlaybackState;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath}; 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,
@@ -47,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>);
@@ -95,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).
@@ -116,7 +137,13 @@ impl Plugin for AchievementPlugin {
.after(StatsUpdate), .after(StatsUpdate),
) )
.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
// `cinephile` the first time playback runs to natural completion.
// Reads the resource via `Option<Res<_>>` so headless tests that
// omit `ReplayPlaybackPlugin` still build.
.add_systems(Update, evaluate_cinephile_on_replay_completion);
} }
} }
@@ -222,6 +249,66 @@ fn evaluate_on_win(
} }
} }
/// Cinephile unlock observer.
///
/// Watches [`ReplayPlaybackState`] and unlocks the `cinephile` achievement
/// the first time the resource transitions from `Playing` to `Completed` —
/// i.e. the player watched a saved replay all the way through. The Stop
/// button transitions `Playing` → `Inactive` directly (never via
/// `Completed`), so manual aborts do not trigger the unlock.
///
/// Idempotent: once the record is unlocked, subsequent Playing → Completed
/// transitions are a no-op (no extra `AchievementUnlockedEvent`, no extra
/// disk write). The transition itself is debounced by tracking the
/// previous frame's `is_playing()` state in a `Local<bool>` — without
/// this, a freshly-spawned `Completed` state would re-fire each frame
/// during the linger window.
///
/// Reads `ReplayPlaybackState` via `Option<Res<_>>` so achievement tests
/// that omit `ReplayPlaybackPlugin` still build cleanly.
fn evaluate_cinephile_on_replay_completion(
state: Option<Res<ReplayPlaybackState>>,
// `Local` collides with `chrono::Local` imported at the top of this
// module — fully qualify so the Bevy system parameter resolves
// correctly.
mut last_was_playing: bevy::prelude::Local<bool>,
mut achievements: ResMut<AchievementsResource>,
mut unlocks: MessageWriter<AchievementUnlockedEvent>,
path: Res<AchievementsStoragePath>,
) {
let Some(state) = state else {
return;
};
// Detect the Playing → Completed transition: was playing last frame,
// is now completed. Direct Playing → Inactive (Stop button) does not
// satisfy this guard because it never enters `Completed`.
let now_playing = state.is_playing();
let now_completed = state.is_completed();
let just_completed = *last_was_playing && now_completed;
*last_was_playing = now_playing;
if !just_completed {
return;
}
let Some(record) = achievements.0.iter_mut().find(|r| r.id == "cinephile") else {
return;
};
if record.unlocked {
return;
}
record.unlock(Utc::now());
record.reward_granted = true;
unlocks.write(AchievementUnlockedEvent(record.clone()));
if let Some(target) = &path.0
&& let Err(e) = save_achievements_to(target, &achievements.0)
{
warn!("failed to save achievements after cinephile unlock: {e}");
}
}
/// Achievement-onboarding cue. /// Achievement-onboarding cue.
/// ///
/// On the player's very first win — and only their first — fires a single /// On the player's very first win — and only their first — fires a single
@@ -329,6 +416,38 @@ fn handle_achievements_close_button(
} }
} }
/// Routes mouse-wheel events into the Achievements modal's scrollable body
/// while the panel is open.
///
/// `offset_y` increases downward (0 = top). Scrolling down (`ev.y < 0`) adds
/// to the offset; scrolling up subtracts. Clamped to >= 0 so the viewport
/// never scrolls past the top. Mirrors `scroll_settings_panel` in
/// `settings_plugin`. The query is empty when no `AchievementsScrollable`
/// is in the world (modal closed) so this is a no-op outside the open
/// state without an explicit gate resource.
fn scroll_achievements_panel(
mut scroll_evr: MessageReader<MouseWheel>,
mut scrollables: Query<&mut ScrollPosition, With<AchievementsScrollable>>,
) {
if scrollables.is_empty() {
scroll_evr.clear();
return;
}
let delta_y: f32 = scroll_evr
.read()
.map(|ev| match ev.unit {
MouseScrollUnit::Line => ev.y * 50.0,
MouseScrollUnit::Pixel => ev.y,
})
.sum();
if delta_y == 0.0 {
return;
}
for mut sp in scrollables.iter_mut() {
sp.0.y = (sp.0.y - delta_y).max(0.0);
}
}
fn spawn_achievements_screen( fn spawn_achievements_screen(
commands: &mut Commands, commands: &mut Commands,
records: &[AchievementRecord], records: &[AchievementRecord],
@@ -355,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(
@@ -439,6 +578,9 @@ fn spawn_achievements_screen(
); );
}); });
}); });
// Achievements is a read-only list — clicking the scrim outside
// the card dismisses alongside the existing A / Done paths.
commands.entity(scrim).insert(ScrimDismissible);
} }
fn format_reward(reward: Reward) -> String { fn format_reward(reward: Reward) -> String {
@@ -829,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
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -1149,9 +1349,215 @@ mod tests {
); );
} }
/// Without any `GameWonEvent` arriving the system must be a no-op: // -----------------------------------------------------------------------
/// no toast, no flag flip — even on update ticks where stats happen // Cinephile (event-driven via ReplayPlaybackState)
/// to read `games_won == 1`. // -----------------------------------------------------------------------
use crate::replay_playback::ReplayPlaybackState;
use solitaire_data::{Replay, ReplayMove};
use chrono::NaiveDate;
use solitaire_core::game_state::{DrawMode, GameMode};
/// Headless app variant that injects a default `ReplayPlaybackState`
/// directly (no `ReplayPlaybackPlugin`) so we can drive the resource
/// by hand. The achievement plugin's cinephile observer reads it via
/// `Option<Res<_>>` so the absence of the playback plugin is safe.
fn cinephile_app() -> App {
let mut app = headless_app();
app.init_resource::<ReplayPlaybackState>();
app
}
fn dummy_replay() -> Replay {
Replay::new(
1,
DrawMode::DrawOne,
GameMode::Classic,
10,
100,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![ReplayMove::StockClick],
)
}
fn cinephile_unlocked(app: &App) -> bool {
app.world()
.resource::<AchievementsResource>()
.0
.iter()
.find(|r| r.id == "cinephile")
.map(|r| r.unlocked)
.unwrap_or(false)
}
fn cinephile_unlocks_emitted(app: &App) -> usize {
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
let mut cursor = events.get_cursor();
cursor
.read(events)
.filter(|e| e.0.id == "cinephile")
.count()
}
/// The cinephile record must be seeded on plugin init like every other
/// achievement, so the observer can find and mutate it later.
#[test]
fn cinephile_record_seeded_by_plugin() {
let app = cinephile_app();
let records = &app.world().resource::<AchievementsResource>().0;
assert!(
records.iter().any(|r| r.id == "cinephile" && !r.unlocked),
"cinephile record must be seeded as locked",
);
}
/// Drive Inactive → Playing → Completed and assert the cinephile
/// achievement unlocks and exactly one `AchievementUnlockedEvent` is
/// emitted.
#[test]
fn cinephile_unlocks_on_replay_completion() {
let mut app = cinephile_app();
// Frame 1: enter Playing. The observer's first sample sees
// `last_was_playing = false` and `now_playing = true`.
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
};
app.update();
assert!(
!cinephile_unlocked(&app),
"Playing alone must not unlock cinephile",
);
// Frame 2: transition to Completed. The observer must detect
// `last_was_playing = true && now_completed = true` and unlock.
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Completed;
app.update();
assert!(
cinephile_unlocked(&app),
"cinephile must unlock on Playing → Completed transition",
);
assert_eq!(
cinephile_unlocks_emitted(&app),
1,
"exactly one AchievementUnlockedEvent must fire for cinephile",
);
}
/// Stop button transitions Playing → Inactive directly (not via
/// Completed). Drive that path and assert no cinephile unlock.
#[test]
fn cinephile_does_not_unlock_on_stop_button_abort() {
let mut app = cinephile_app();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
};
app.update();
// Direct Playing → Inactive — the path the Stop button takes via
// `stop_replay_playback`. Must not unlock cinephile.
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Inactive;
app.update();
assert!(
!cinephile_unlocked(&app),
"Stop button (Playing → Inactive) must not unlock cinephile",
);
assert_eq!(
cinephile_unlocks_emitted(&app),
0,
"no AchievementUnlockedEvent for cinephile on a Stop transition",
);
}
/// A second Playing → Completed cycle on an already-unlocked record
/// must be idempotent: no additional `AchievementUnlockedEvent`.
#[test]
fn cinephile_does_not_double_fire() {
let mut app = cinephile_app();
// First completion cycle to unlock.
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
};
app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Completed;
app.update();
assert!(cinephile_unlocked(&app), "precondition: first cycle must unlock");
// Drain the event queue so the next assertion doesn't double-count
// the legitimate first-time unlock event.
app.world_mut()
.resource_mut::<Messages<AchievementUnlockedEvent>>()
.clear();
// Second cycle: Inactive → Playing → Completed once more.
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Inactive;
app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
};
app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Completed;
app.update();
assert_eq!(
cinephile_unlocks_emitted(&app),
0,
"cinephile must not re-fire on a second Playing → Completed cycle",
);
}
/// `Completed` lingers across multiple frames before the auto-clear
/// transitions back to `Inactive`. The observer must fire exactly
/// once during that linger window — not once per frame.
#[test]
fn cinephile_fires_once_across_completed_linger() {
let mut app = cinephile_app();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
};
app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Completed;
app.update();
// Stay in Completed for a few more frames as the real auto-clear
// does. Each subsequent frame the resource is still `Completed`
// but the observer has already counted this transition.
app.update();
app.update();
app.update();
assert_eq!(
cinephile_unlocks_emitted(&app),
1,
"cinephile must fire exactly once across the Completed linger window",
);
}
#[test] #[test]
fn no_win_event_means_no_achievement_onboarding_toast() { fn no_win_event_means_no_achievement_onboarding_toast() {
let mut app = onboarding_test_app(); let mut app = onboarding_test_app();
+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};
+318 -20
View File
@@ -11,10 +11,16 @@ use std::time::{SystemTime, UNIX_EPOCH};
use bevy::prelude::*; use bevy::prelude::*;
use chrono::Utc; use chrono::Utc;
use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::game_state::{DrawMode, GameMode, GameState};
use solitaire_core::pile::PileType; use solitaire_core::pile::PileType;
use solitaire_data::{delete_game_state_at, game_state_file_path, latest_replay_path, use solitaire_core::solver::{try_solve, SolverConfig, SolverResult};
load_game_state_from, save_game_state_to, save_latest_replay_to, Replay, ReplayMove}; use solitaire_data::{
append_replay_to_history, delete_game_state_at, game_state_file_path, load_game_state_from,
migrate_legacy_latest_replay, replay_history_path, save_game_state_to, Replay, ReplayMove,
SOLVER_DEAL_RETRY_CAP,
};
#[allow(deprecated)]
use solitaire_data::latest_replay_path;
use crate::events::{ use crate::events::{
CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent, GameWonEvent, InfoToastEvent, CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent, GameWonEvent, InfoToastEvent,
@@ -54,7 +60,15 @@ pub struct GameMutation;
#[derive(Resource, Debug, Clone)] #[derive(Resource, Debug, Clone)]
pub struct GameStatePath(pub Option<PathBuf>); pub struct GameStatePath(pub Option<PathBuf>);
/// Persistence path for the most recent winning replay. `None` disables I/O. /// Persistence path for the rolling [`solitaire_data::ReplayHistory`]
/// file (`replays.json`). `None` disables I/O — used by tests and on
/// minimal Linux containers without `dirs::data_dir()`.
///
/// Each `GameWonEvent` appends the freshly-frozen [`Replay`] to the
/// history at this path via
/// [`solitaire_data::append_replay_to_history`], capping at
/// [`solitaire_data::REPLAY_HISTORY_CAP`] so the file never grows
/// unbounded.
#[derive(Resource, Debug, Clone)] #[derive(Resource, Debug, Clone)]
pub struct ReplayPath(pub Option<PathBuf>); pub struct ReplayPath(pub Option<PathBuf>);
@@ -101,9 +115,27 @@ impl Plugin for GamePlugin {
.and_then(load_game_state_from) .and_then(load_game_state_from)
.unwrap_or_else(|| GameState::new(seed_from_system_time(), DrawMode::DrawOne)); .unwrap_or_else(|| GameState::new(seed_from_system_time(), DrawMode::DrawOne));
// One-shot migration from the legacy single-slot
// `latest_replay.json` to the rolling history at `replays.json`.
// Runs at plugin construction so the player's last winning
// replay from a pre-history build is the first entry of the
// new history file. The legacy file is intentionally left in
// place for one release as a safety net (see
// `migrate_legacy_latest_replay` doc comment).
let history_path = replay_history_path();
if let (Some(legacy), Some(history)) =
(
#[allow(deprecated)]
latest_replay_path(),
history_path.as_ref(),
)
{
migrate_legacy_latest_replay(&legacy, history);
}
app.insert_resource(GameStateResource(initial_state)) app.insert_resource(GameStateResource(initial_state))
.insert_resource(GameStatePath(path)) .insert_resource(GameStatePath(path))
.insert_resource(ReplayPath(latest_replay_path())) .insert_resource(ReplayPath(history_path))
.init_resource::<RecordingReplay>() .init_resource::<RecordingReplay>()
.init_resource::<DragState>() .init_resource::<DragState>()
.init_resource::<SyncStatusResource>() .init_resource::<SyncStatusResource>()
@@ -188,6 +220,41 @@ fn seed_from_system_time() -> u64 {
.map_or(0, |d| d.as_nanos() as u64) .map_or(0, |d| d.as_nanos() as u64)
} }
/// Walks forward from `initial_seed` (incrementing by 1 with wrapping
/// arithmetic) until the [`solitaire_core::solver`] returns a verdict
/// the engine accepts as winnable, or until [`SOLVER_DEAL_RETRY_CAP`]
/// attempts have elapsed.
///
/// The solver classifies each deal as one of three verdicts:
/// - [`SolverResult::Winnable`] — provably solvable; accept.
/// - [`SolverResult::Inconclusive`] — budget exceeded, no proof
/// either way; accept (we treat "we don't know" as winnable so
/// the toggle never silently drops a player into the retry cap).
/// - [`SolverResult::Unwinnable`] — provably dead; try the next seed.
///
/// If every seed in the retry window is `Unwinnable` (extremely
/// unlikely on real inputs), the function returns the *last* tried
/// seed so the player still gets a deal — better a possibly-unwinnable
/// hand than an infinite loop.
///
/// Pure helper extracted for testability — `new_game_with_solver_*`
/// engine tests in the same file exercise this path.
pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: &DrawMode) -> u64 {
let cfg = SolverConfig::default();
let mut seed = initial_seed;
for _ in 0..SOLVER_DEAL_RETRY_CAP {
match try_solve(seed, draw_mode.clone(), &cfg) {
SolverResult::Winnable | SolverResult::Inconclusive => return seed,
SolverResult::Unwinnable => {
seed = seed.wrapping_add(1);
}
}
}
// Retry cap exhausted — accept the latest tried seed rather than
// recurring forever.
seed
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn handle_new_game( fn handle_new_game(
mut commands: Commands, mut commands: Commands,
@@ -229,7 +296,7 @@ fn handle_new_game(
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} }
let seed = ev.seed.unwrap_or_else(seed_from_system_time); let initial_seed = ev.seed.unwrap_or_else(seed_from_system_time);
// Prefer the draw mode from Settings when starting a fresh game. // Prefer the draw mode from Settings when starting a fresh game.
// Fall back to the current game's draw mode in headless/test contexts // Fall back to the current game's draw mode in headless/test contexts
// where SettingsPlugin is not installed. // where SettingsPlugin is not installed.
@@ -237,7 +304,32 @@ fn handle_new_game(
.as_ref() .as_ref()
.map_or_else(|| game.0.draw_mode.clone(), |s| s.0.draw_mode.clone()); .map_or_else(|| game.0.draw_mode.clone(), |s| s.0.draw_mode.clone());
let mode = ev.mode.unwrap_or(game.0.mode); let mode = ev.mode.unwrap_or(game.0.mode);
game.0 = GameState::new_with_mode(seed, draw_mode, mode);
// Solver-backed retry: when the player has opted in to
// "Winnable deals only" AND this is a random Classic deal
// (no caller-supplied seed), reject deals the solver can
// prove unwinnable and try the next seed. Capped at
// [`SOLVER_DEAL_RETRY_CAP`] so a pathological run can't
// hang the main thread — if every attempt is rejected we
// fall through to the latest tried seed.
//
// **Scope** — the retry deliberately skips:
// - Daily challenges and challenge-mode seeds (caller passes
// `ev.seed = Some(...)` so the player gets the same deal as
// everyone else).
// - Replays (the replay's own seed is authoritative).
// - Any other explicit seed request — the player asked for
// that seed; honour it.
let winnable_only = settings
.as_ref()
.is_some_and(|s| s.0.winnable_deals_only);
let chosen_seed = if winnable_only && mode == GameMode::Classic && ev.seed.is_none() {
choose_winnable_seed(initial_seed, &draw_mode)
} else {
initial_seed
};
game.0 = GameState::new_with_mode(chosen_seed, draw_mode, mode);
// Reset the in-flight replay buffer — a fresh deal starts with // Reset the in-flight replay buffer — a fresh deal starts with
// an empty move list. The previously saved replay on disk // an empty move list. The previously saved replay on disk
// (latest_replay.json) is preserved until the player wins again. // (latest_replay.json) is preserved until the player wins again.
@@ -557,14 +649,15 @@ fn handle_undo(
/// On every `GameWonEvent`, freeze the in-flight [`RecordingReplay`] into /// On every `GameWonEvent`, freeze the in-flight [`RecordingReplay`] into
/// a [`Replay`] tagged with the deal seed/mode, the win's score and /// a [`Replay`] tagged with the deal seed/mode, the win's score and
/// elapsed time, and today's date — then persist it atomically to /// elapsed time, and today's date — then append it to the rolling
/// `<data_dir>/solitaire_quest/latest_replay.json` (or to whichever path /// [`solitaire_data::ReplayHistory`] at the path `ReplayPath` carries
/// `ReplayPath` carries; tests inject a temp path). /// (tests inject a temp path).
/// ///
/// Only the most recent winning replay is retained — the existing file is /// The history is capped at [`solitaire_data::REPLAY_HISTORY_CAP`]
/// overwritten. The recording buffer is left intact after the win so a /// entries; older wins age out automatically when the cap is hit. The
/// subsequent state-change does not erase the move list before the save /// recording buffer is left intact after the win so a subsequent
/// completes; it gets cleared on the next `NewGameRequestEvent`. /// state-change does not erase the move list before the save completes;
/// it gets cleared on the next `NewGameRequestEvent`.
pub fn record_replay_on_win( pub fn record_replay_on_win(
mut wins: MessageReader<GameWonEvent>, mut wins: MessageReader<GameWonEvent>,
game: Res<GameStateResource>, game: Res<GameStateResource>,
@@ -597,8 +690,8 @@ pub fn record_replay_on_win(
// to inspect it without going through the disk. // to inspect it without going through the disk.
continue; continue;
}; };
if let Err(e) = save_latest_replay_to(p, &replay) { if let Err(e) = append_replay_to_history(p, replay) {
warn!("replay: failed to save winning replay: {e}"); warn!("replay: failed to append winning replay to history: {e}");
} }
} }
} }
@@ -1946,11 +2039,13 @@ mod tests {
} }
/// On `GameWonEvent`, the recording is frozen into a `Replay` and /// On `GameWonEvent`, the recording is frozen into a `Replay` and
/// persisted. We point `ReplayPath` at a temp file, fake a win, and /// appended to the rolling [`solitaire_data::ReplayHistory`]. We
/// load the file back to assert the metadata + move list match. /// point `ReplayPath` at a temp file, fake a win, and load the
/// history back to assert the just-saved entry sits at the front
/// with the metadata + move list intact.
#[test] #[test]
fn replay_recording_freezes_into_replay_on_game_won() { fn replay_recording_freezes_into_replay_on_game_won() {
use solitaire_data::load_latest_replay_from; use solitaire_data::load_replay_history_from;
let path = std::env::temp_dir().join("engine_test_replay_freeze.json"); let path = std::env::temp_dir().join("engine_test_replay_freeze.json");
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
@@ -1978,8 +2073,14 @@ mod tests {
}); });
app.update(); app.update();
let loaded = load_latest_replay_from(&path) let history = load_replay_history_from(&path)
.expect("a winning replay must be persisted to ReplayPath"); .expect("a winning replay must be persisted to ReplayPath");
assert_eq!(
history.replays.len(),
1,
"fresh history must contain exactly the just-recorded win",
);
let loaded = &history.replays[0];
assert_eq!(loaded.seed, 7654, "seed must match the live game state"); assert_eq!(loaded.seed, 7654, "seed must match the live game state");
assert_eq!(loaded.draw_mode, DrawMode::DrawOne, "draw_mode must be captured"); assert_eq!(loaded.draw_mode, DrawMode::DrawOne, "draw_mode must be captured");
assert_eq!(loaded.final_score, 4321, "final_score must come from the win event"); assert_eq!(loaded.final_score, 4321, "final_score must come from the win event");
@@ -1998,6 +2099,53 @@ mod tests {
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
} }
/// Successive `GameWonEvent`s must accumulate in the rolling
/// history rather than overwriting one another. Pre-cap, every win
/// joins the front of `history.replays`.
#[test]
fn replay_recording_appends_to_history_across_wins() {
use solitaire_data::load_replay_history_from;
let path = std::env::temp_dir().join("engine_test_replay_history_append.json");
let _ = std::fs::remove_file(&path);
let mut app = test_app(11);
app.insert_resource(ReplayPath(Some(path.clone())));
// First win.
{
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
recording.moves.clear();
recording.moves.push(ReplayMove::StockClick);
}
app.world_mut().write_message(GameWonEvent {
score: 100,
time_seconds: 60,
});
app.update();
// Second win — different score so we can distinguish.
{
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
recording.moves.clear();
recording.moves.push(ReplayMove::StockClick);
recording.moves.push(ReplayMove::StockClick);
}
app.world_mut().write_message(GameWonEvent {
score: 200,
time_seconds: 120,
});
app.update();
let history = load_replay_history_from(&path).expect("history must exist");
assert_eq!(history.replays.len(), 2, "both wins must be retained");
// Newest first — second win lands at index 0.
assert_eq!(history.replays[0].final_score, 200);
assert_eq!(history.replays[1].final_score, 100);
let _ = std::fs::remove_file(&path);
}
/// `GameWonEvent` with an empty recording must NOT touch disk. /// `GameWonEvent` with an empty recording must NOT touch disk.
/// Without this guard, parallel-plugin tests that synthesise /// Without this guard, parallel-plugin tests that synthesise
/// win events for XP / streak / weekly-goal logic (without /// win events for XP / streak / weekly-goal logic (without
@@ -2022,4 +2170,154 @@ mod tests {
"no replay must be written when recording is empty", "no replay must be written when recording is empty",
); );
} }
// -----------------------------------------------------------------------
// Solver-backed "Winnable deals only" toggle
//
// Exercises [`choose_winnable_seed`] and the wiring inside
// `handle_new_game` that consults [`Settings::winnable_deals_only`].
// -----------------------------------------------------------------------
/// Inject a `SettingsResource` with the given `winnable_deals_only`
/// flag. The handle_new_game system already reads this resource via
/// `Option<Res<...>>`, so no `SettingsPlugin` boot is needed.
fn insert_settings(app: &mut App, winnable_deals_only: bool) {
let settings = solitaire_data::Settings {
winnable_deals_only,
..solitaire_data::Settings::default()
};
app.insert_resource(crate::settings_plugin::SettingsResource(settings));
}
#[test]
fn new_game_with_solver_toggle_off_uses_requested_seed() {
// Toggle off — the engine must use the seed it was handed and
// never invoke the solver. Seed 999 is just an arbitrary
// deterministic seed; the test asserts the resulting deal
// matches `GameState::new(999, DrawOne)`.
let mut app = test_app(1);
insert_settings(&mut app, false);
app.world_mut().write_message(NewGameRequestEvent {
seed: Some(999),
mode: None,
confirmed: false,
});
app.update();
let actual_seed = app.world().resource::<GameStateResource>().0.seed;
assert_eq!(
actual_seed, 999,
"with solver toggle off, the requested seed must be honoured exactly"
);
// Cross-check: the dealt tableau must match GameState::new(999) byte-for-byte.
let expected = GameState::new(999, DrawMode::DrawOne);
for i in 0..7 {
assert_eq!(
app.world().resource::<GameStateResource>().0.piles[&PileType::Tableau(i)].cards,
expected.piles[&PileType::Tableau(i)].cards,
"tableau column {i} must match the unfiltered seed",
);
}
}
#[test]
fn new_game_with_solver_toggle_off_random_seed_path() {
// When seed is None and toggle is off, the engine uses a
// system-time seed and skips the solver. We can't pin the
// exact seed, but we can assert the seed is *not* the
// sentinel zero (which would only happen if SystemTime is
// before the epoch — practically impossible), AND that no
// resource has been mutated to suggest the solver ran.
// The strongest assertion is "the move runs to completion
// without panicking", which the .update() call covers.
let mut app = test_app(1);
insert_settings(&mut app, false);
app.world_mut().write_message(NewGameRequestEvent {
seed: None,
mode: None,
confirmed: false,
});
app.update();
// Game state was reseeded — move_count is 0 on the new game.
assert_eq!(app.world().resource::<GameStateResource>().0.move_count, 0);
}
#[test]
fn new_game_with_solver_toggle_on_skips_solver_for_specific_seed() {
// Even with the toggle on, an *explicit* seed must be honoured:
// daily challenges, replay seeding, and challenge-mode all
// pass `Some(seed)` and must never be retried.
let mut app = test_app(1);
insert_settings(&mut app, true);
app.world_mut().write_message(NewGameRequestEvent {
seed: Some(123),
mode: None,
confirmed: false,
});
app.update();
assert_eq!(
app.world().resource::<GameStateResource>().0.seed,
123,
"explicit-seed requests must skip the solver retry loop",
);
}
#[test]
fn choose_winnable_seed_skips_unwinnable_seed() {
// Seed 394 was identified by the offline scan
// (`solver::tests::find_unwinnable`) as the only Unwinnable
// seed in 0..500 under the default solver budget. Seed 395
// resolves as Inconclusive — the engine treats Inconclusive
// as winnable (see `choose_winnable_seed` doc), so the
// helper must return 395 when started at 394.
let chosen = choose_winnable_seed(394, &DrawMode::DrawOne);
assert_eq!(
chosen, 395,
"seed 394 is Unwinnable; the next seed (395, Inconclusive) must be accepted"
);
}
#[test]
fn new_game_with_solver_toggle_on_retries_until_winnable() {
// End-to-end: with the toggle on, fire a NewGameRequestEvent
// with seed=None and *manually pre-seed* the system-time
// path by clearing the GameStateResource so handle_new_game
// takes the random branch. We can't easily inject the
// system-time seed here, so we exercise the helper via a
// separate call and assert the *resource* receives the
// post-retry seed when the helper would have rejected.
//
// We test the integration by setting up an alternative
// scenario: pass `seed: Some(394)` with toggle on. Our
// implementation already documents that explicit seeds skip
// the retry, so this *won't* trigger retry. The cleaner
// integration is captured in `choose_winnable_seed_skips_*`.
// Here we verify the default-seed path doesn't crash when
// toggle is on — exercising the live solver call inside
// handle_new_game without depending on the solver picking
// a specific seed.
let mut app = test_app(1);
insert_settings(&mut app, true);
app.world_mut().write_message(NewGameRequestEvent {
seed: None,
mode: None,
confirmed: false,
});
app.update();
// The chosen seed is non-deterministic (system time),
// but the new game must have been started cleanly:
// move_count back to 0, undo stack empty.
assert_eq!(app.world().resource::<GameStateResource>().0.move_count, 0);
assert_eq!(
app.world().resource::<GameStateResource>().0.undo_stack_len(),
0
);
}
} }
+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
+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();
+12 -1
View File
@@ -24,6 +24,8 @@ pub mod onboarding_plugin;
pub mod pause_plugin; pub mod pause_plugin;
pub mod profile_plugin; pub mod profile_plugin;
pub mod radial_menu; pub mod radial_menu;
pub mod replay_overlay;
pub mod replay_playback;
pub mod settings_plugin; pub mod settings_plugin;
pub mod progress_plugin; pub mod progress_plugin;
pub mod resources; pub mod resources;
@@ -112,6 +114,14 @@ pub use radial_menu::{
legal_destinations_for_card, radial_anchor_for_index, radial_hovered_index, RadialIcon, legal_destinations_for_card, radial_anchor_for_index, radial_hovered_index, RadialIcon,
RadialMenuPlugin, RightClickRadialState, Z_RADIAL_MENU, RadialMenuPlugin, RightClickRadialState, Z_RADIAL_MENU,
}; };
pub use replay_overlay::{
ReplayOverlayBannerText, ReplayOverlayPlugin, ReplayOverlayProgressText, ReplayOverlayRoot,
ReplayStopButton, Z_REPLAY_OVERLAY,
};
pub use replay_playback::{
start_replay_playback, stop_replay_playback, ReplayPlaybackPlugin, ReplayPlaybackState,
REPLAY_COMPLETION_LINGER_SECS, REPLAY_MOVE_INTERVAL_SECS,
};
pub use settings_plugin::{ pub use settings_plugin::{
PendingWindowGeometry, SettingsChangedEvent, SettingsPlugin, SettingsResource, SettingsScreen, PendingWindowGeometry, SettingsChangedEvent, SettingsPlugin, SettingsResource, SettingsScreen,
SFX_STEP, WINDOW_GEOMETRY_DEBOUNCE_SECS, SFX_STEP, WINDOW_GEOMETRY_DEBOUNCE_SECS,
@@ -123,7 +133,8 @@ pub use selection_plugin::{
}; };
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot}; pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
pub use stats_plugin::{ pub use stats_plugin::{
format_replay_caption, LatestReplayPath, LatestReplayResource, StatsPlugin, StatsResource, format_replay_caption, LatestReplayPath, ReplayHistoryResource, ReplayNextButton,
ReplayPrevButton, ReplaySelectorCaption, SelectedReplayIndex, StatsPlugin, StatsResource,
StatsScreen, StatsUpdate, WatchReplayButton, StatsScreen, StatsUpdate, WatchReplayButton,
}; };
pub use sync_plugin::{SyncPlugin, SyncProviderResource}; pub use sync_plugin::{SyncPlugin, SyncProviderResource};
+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();
+565
View File
@@ -0,0 +1,565 @@
//! On-screen overlay shown while a recorded [`Replay`] plays back.
//!
//! The overlay is a thin top-of-window banner with three pieces of UI:
//!
//! - A "Replay" label on the left so the player knows the surface is
//! under playback control rather than live input.
//! - A "Move N of M" progress indicator in the centre, recomputed every
//! frame the cursor advances.
//! - A "Stop" button on the right that aborts playback and returns
//! control to the player.
//!
//! When playback finishes ([`ReplayPlaybackState::Completed`]) the banner
//! label swaps to "Replay complete" and stays visible until the playback
//! core auto-clears the resource back to [`ReplayPlaybackState::Inactive`]
//! a few seconds later, at which point the overlay despawns.
//!
//! The overlay sits at z-layer [`Z_REPLAY_OVERLAY`] — above gameplay but
//! below every modal layer ([`Z_MODAL_SCRIM`] and up). That ordering lets
//! the player still open Settings, Pause, and Help during a replay; those
//! modals will render on top of the banner as expected.
//!
//! [`Replay`]: solitaire_data::Replay
//! [`Z_MODAL_SCRIM`]: crate::ui_theme::Z_MODAL_SCRIM
use bevy::prelude::*;
use crate::font_plugin::FontResource;
use crate::replay_playback::{stop_replay_playback, ReplayPlaybackState};
use crate::ui_modal::{spawn_modal_button, ButtonVariant};
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED_HI, TEXT_PRIMARY, TYPE_BODY, TYPE_HEADLINE, VAL_SPACE_2,
VAL_SPACE_4, Z_DROP_OVERLAY,
};
// ---------------------------------------------------------------------------
// Z-index — see `ui_theme::Z_MODAL_SCRIM` (200) for the next layer above.
// ---------------------------------------------------------------------------
/// `bevy::ui` `ZIndex` value for the replay overlay banner.
///
/// Numeric value is `Z_DROP_OVERLAY as i32 + 5 = 55`; chosen so the banner
/// sits clearly above the HUD top layer (`Z_HUD_TOP = 60` is intentionally
/// **below** modals, but the overlay needs to be above HUD readouts) yet
/// well below `Z_MODAL_SCRIM = 200` so Settings, Pause, and Help modals
/// continue to render on top of the overlay during a replay.
///
/// The `Z_DROP_OVERLAY + 5` formula in the spec is reproduced here as an
/// integer because `Z_DROP_OVERLAY` itself is a `f32` Sprite-space z used
/// for the drop-target overlay sprites — UI nodes use `i32` `ZIndex`, so
/// we materialise a separate constant rather than reuse the `f32` value.
pub const Z_REPLAY_OVERLAY: i32 = Z_DROP_OVERLAY as i32 + 5;
/// Total height of the banner in pixels. Thin enough to leave the
/// gameplay surface visible underneath, tall enough to comfortably fit
/// the headline-sized "Replay" label.
const BANNER_HEIGHT: f32 = 48.0;
/// Background colour alpha for the banner. `BG_ELEVATED_HI` at this alpha
/// reads as a clear "this is a UI strip" callout while still letting the
/// felt show through enough to anchor the banner to the play surface.
const BANNER_ALPHA: f32 = 0.92;
// ---------------------------------------------------------------------------
// Marker components
// ---------------------------------------------------------------------------
/// Marker on the banner's root `Node`. Used by the spawn / despawn /
/// progress-update systems to find the overlay.
#[derive(Component, Debug)]
pub struct ReplayOverlayRoot;
/// Marker on the left-hand banner label `Text`. Carries either "Replay"
/// (during playback) or "Replay complete" (once finished); the
/// completion-text-update system swaps the contents in place.
#[derive(Component, Debug)]
pub struct ReplayOverlayBannerText;
/// Marker on the centre progress `Text`. Updated every frame to reflect
/// the current `(cursor, total)` returned by
/// [`ReplayPlaybackState::progress`].
#[derive(Component, Debug)]
pub struct ReplayOverlayProgressText;
/// Marker on the right-hand "Stop" button. Click handler queries for this
/// and calls [`stop_replay_playback`] when an `Interaction::Pressed`
/// transition is seen.
#[derive(Component, Debug)]
pub struct ReplayStopButton;
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
/// Bevy plugin that registers every system needed to drive the replay
/// overlay's lifecycle.
///
/// The plugin is independent of [`crate::replay_playback::ReplayPlaybackPlugin`]
/// — it only reads the shared `ReplayPlaybackState` resource. Tests insert
/// the resource manually and exercise the overlay in isolation.
pub struct ReplayOverlayPlugin;
impl Plugin for ReplayOverlayPlugin {
fn build(&self, app: &mut App) {
// The systems are ordered so that, on a single frame:
// 1. The state-watcher spawns or despawns the overlay if the
// `ReplayPlaybackState` resource changed.
// 2. The completion-text update swaps the banner label when the
// state is `Completed`.
// 3. The progress-text update writes the latest "Move N of M".
// 4. The Stop-button click handler reads `Interaction::Pressed`
// and calls `stop_replay_playback` (which mutates the state).
// Putting Stop last means a click in frame N is observed by
// `react_to_state_change` in frame N+1, which then despawns the
// overlay in response — a clean state-driven loop.
app.add_systems(
Update,
(
react_to_state_change,
update_banner_label,
update_progress_text,
handle_stop_button,
)
.chain(),
);
}
}
// ---------------------------------------------------------------------------
// Spawning
// ---------------------------------------------------------------------------
/// Reads [`ReplayPlaybackState`] every time the resource changes and either
/// spawns or despawns the overlay accordingly. Treats the resource as the
/// single source of truth — the spawn / despawn decision is derived from
/// `is_playing() || is_completed()` rather than tracking previous-state
/// transitions explicitly, which keeps the system stateless.
fn react_to_state_change(
mut commands: Commands,
state: Res<ReplayPlaybackState>,
existing: Query<Entity, With<ReplayOverlayRoot>>,
font_res: Option<Res<FontResource>>,
) {
if !state.is_changed() {
return;
}
let should_be_visible = state.is_playing() || state.is_completed();
let already_spawned = existing.iter().next().is_some();
if should_be_visible && !already_spawned {
spawn_overlay(&mut commands, font_res.as_deref(), &state);
} else if !should_be_visible && already_spawned {
for entity in &existing {
commands.entity(entity).despawn();
}
}
// The `should_be_visible && already_spawned` branch is a no-op here —
// the per-frame text update systems below repaint the banner label
// and progress readout in place without a respawn.
}
/// Spawns the banner — a flex-row Node anchored to the top edge of the
/// window with three children: the "Replay" / "Replay complete" label,
/// the centred progress text, and the right-aligned Stop button.
fn spawn_overlay(
commands: &mut Commands,
font_res: Option<&FontResource>,
state: &ReplayPlaybackState,
) {
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let banner_label = if state.is_completed() {
"Replay complete"
} else {
"Replay"
};
let progress_label = format_progress(state);
let banner_bg = Color::srgba(
BG_ELEVATED_HI.to_srgba().red,
BG_ELEVATED_HI.to_srgba().green,
BG_ELEVATED_HI.to_srgba().blue,
BANNER_ALPHA,
);
commands
.spawn((
ReplayOverlayRoot,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
top: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Px(BANNER_HEIGHT),
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
justify_content: JustifyContent::SpaceBetween,
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
column_gap: VAL_SPACE_4,
..default()
},
BackgroundColor(banner_bg),
// Pin the banner to its z layer in both the local and the
// global stacking context — `GlobalZIndex` matters because
// the overlay is a top-level Node (no parent), and Bevy 0.18
// has historically had subtle stacking-context drift here.
ZIndex(Z_REPLAY_OVERLAY),
GlobalZIndex(Z_REPLAY_OVERLAY),
))
.with_children(|banner| {
// Left: "Replay" label in the loud yellow accent so it reads
// unmistakably as a non-gameplay surface.
banner.spawn((
ReplayOverlayBannerText,
Text::new(banner_label),
TextFont {
font: font_handle.clone(),
font_size: TYPE_HEADLINE,
..default()
},
TextColor(ACCENT_PRIMARY),
));
// Centre: progress readout — neutral primary text colour so
// the eye treats it as data, not a callout.
banner.spawn((
ReplayOverlayProgressText,
Text::new(progress_label),
TextFont {
font: font_handle,
font_size: TYPE_BODY,
..default()
},
TextColor(TEXT_PRIMARY),
));
// Right: Stop button. Tertiary variant — the action is
// available but not the loudest element in the banner; the
// "Replay" yellow accent owns that slot. `spawn_modal_button`
// gives us hover / press paint and focus rings for free via
// the existing `UiModalPlugin` paint system.
banner
.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_2,
..default()
})
.with_children(|wrap| {
spawn_modal_button(
wrap,
ReplayStopButton,
"Stop",
None,
ButtonVariant::Tertiary,
font_res,
);
});
});
}
// ---------------------------------------------------------------------------
// Per-frame text updates
// ---------------------------------------------------------------------------
/// Overwrites the banner label whenever the resource changes — covers the
/// `Playing → Completed` transition by swapping "Replay" for
/// "Replay complete" in place without despawning the overlay.
fn update_banner_label(
state: Res<ReplayPlaybackState>,
mut q: Query<&mut Text, With<ReplayOverlayBannerText>>,
) {
if !state.is_changed() {
return;
}
let label = if state.is_completed() {
"Replay complete"
} else if state.is_playing() {
"Replay"
} else {
return;
};
for mut text in &mut q {
**text = label.to_string();
}
}
/// Repaints the "Move N of M" centre readout every frame the cursor moves.
/// Cheap — early-exits if the resource has not changed since the last
/// frame so idle replays don't churn the text mesh.
fn update_progress_text(
state: Res<ReplayPlaybackState>,
mut q: Query<&mut Text, With<ReplayOverlayProgressText>>,
) {
if !state.is_changed() {
return;
}
let label = format_progress(&state);
for mut text in &mut q {
**text = label.clone();
}
}
/// Pure helper — formats the centre progress readout for the given state.
/// Exposed at module scope so the spawn path and the per-frame update
/// path produce the exact same string.
fn format_progress(state: &ReplayPlaybackState) -> String {
match state.progress() {
Some((cursor, total)) => format!("Move {cursor} of {total}"),
None if state.is_completed() => "Replay complete".to_string(),
None => String::new(),
}
}
// ---------------------------------------------------------------------------
// Stop button handler
// ---------------------------------------------------------------------------
/// Watches the Stop button for `Interaction::Pressed` transitions. On a
/// click, calls [`stop_replay_playback`] which resets the state to
/// `Inactive`; the next frame's `react_to_state_change` then despawns
/// the overlay.
fn handle_stop_button(
mut commands: Commands,
mut state: ResMut<ReplayPlaybackState>,
buttons: Query<&Interaction, (With<ReplayStopButton>, Changed<Interaction>)>,
) {
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
stop_replay_playback(&mut commands, &mut state);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_data::{Replay, ReplayMove};
/// Build a minimal but well-formed [`Replay`] with `move_count` no-op
/// `StockClick` entries. Tests only ever read `replay.moves.len()`
/// (denominator of the progress indicator), so the move kind is
/// irrelevant beyond producing the right count.
fn synthetic_replay(move_count: usize) -> Replay {
Replay::new(
42,
DrawMode::DrawOne,
GameMode::Classic,
120,
1_000,
NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
(0..move_count).map(|_| ReplayMove::StockClick).collect(),
)
}
/// Build a test app that has the overlay plugin but **not** the
/// playback plugin — tests insert `ReplayPlaybackState` manually so
/// they can drive every state transition deterministically.
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(ReplayOverlayPlugin);
app.init_resource::<ReplayPlaybackState>();
app
}
/// Count `ReplayOverlayRoot` entities in the world — the overlay's
/// presence/absence is the spawn-test's primary observable.
fn overlay_root_count(app: &mut App) -> usize {
app.world_mut()
.query::<&ReplayOverlayRoot>()
.iter(app.world())
.count()
}
/// Read the current text content of the unique progress-text entity.
fn progress_text(app: &mut App) -> String {
let mut q = app
.world_mut()
.query_filtered::<&Text, With<ReplayOverlayProgressText>>();
q.iter(app.world())
.next()
.map(|t| t.0.clone())
.unwrap_or_default()
}
/// Read the current text content of the unique banner-label entity.
fn banner_text(app: &mut App) -> String {
let mut q = app
.world_mut()
.query_filtered::<&Text, With<ReplayOverlayBannerText>>();
q.iter(app.world())
.next()
.map(|t| t.0.clone())
.unwrap_or_default()
}
/// Set the playback resource without going through the playback core.
fn set_state(app: &mut App, state: ReplayPlaybackState) {
app.world_mut().insert_resource(state);
}
/// Find the unique `ReplayStopButton` entity for the click-handler
/// test. There must be exactly one.
fn stop_button_entity(app: &mut App) -> Entity {
let mut q = app
.world_mut()
.query_filtered::<Entity, With<ReplayStopButton>>();
q.iter(app.world())
.next()
.expect("Stop button must exist while overlay is spawned")
}
/// Going `Inactive → Playing` spawns exactly one overlay root and
/// the banner label reads "Replay".
#[test]
fn overlay_spawns_when_playback_starts() {
let mut app = headless_app();
// First update with the default `Inactive` resource — overlay
// must not exist yet.
app.update();
assert_eq!(overlay_root_count(&mut app), 0);
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
},
);
app.update();
assert_eq!(
overlay_root_count(&mut app),
1,
"exactly one ReplayOverlayRoot must spawn on Inactive → Playing",
);
assert_eq!(banner_text(&mut app), "Replay");
}
/// The progress-text entity reads `"Move {cursor} of {total}"` for a
/// well-formed `Playing` state.
#[test]
fn overlay_progress_text_reflects_cursor() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 5,
secs_to_next: 0.5,
},
);
app.update();
assert_eq!(progress_text(&mut app), "Move 5 of 10");
}
/// Pressing the Stop button resets the state back to `Inactive` and
/// the next frame's `react_to_state_change` despawns the overlay.
/// Mirrors the synthetic `Interaction::Pressed` insertion pattern
/// used elsewhere in the engine for headless click tests.
#[test]
fn overlay_stop_button_click_clears_playback() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
},
);
app.update();
assert_eq!(overlay_root_count(&mut app), 1);
let stop = stop_button_entity(&mut app);
app.world_mut()
.entity_mut(stop)
.insert(Interaction::Pressed);
// Tick once: the click handler runs late in the frame and resets
// the state to `Inactive`.
app.update();
// State must be back to Inactive.
let state = app.world().resource::<ReplayPlaybackState>();
assert!(
matches!(state, ReplayPlaybackState::Inactive),
"Stop click must reset ReplayPlaybackState to Inactive; got {state:?}",
);
// One more tick — `react_to_state_change` sees the resource
// change to Inactive and despawns the overlay.
app.update();
assert_eq!(
overlay_root_count(&mut app),
0,
"overlay must despawn the frame after state returns to Inactive",
);
}
/// Manually flipping the resource back to `Inactive` (e.g. via the
/// playback core's auto-clear after `Completed`) tears the overlay
/// down without any further input.
#[test]
fn overlay_despawns_when_playback_returns_to_inactive() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(3),
cursor: 1,
secs_to_next: 0.5,
},
);
app.update();
assert_eq!(overlay_root_count(&mut app), 1);
set_state(&mut app, ReplayPlaybackState::Inactive);
app.update();
assert_eq!(
overlay_root_count(&mut app),
0,
"overlay must despawn on Playing → Inactive transition",
);
}
/// On `Playing → Completed` the banner label updates in place rather
/// than respawning. The overlay must still be present, and the label
/// must read "Replay complete".
#[test]
fn overlay_text_changes_on_completed() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(7),
cursor: 7,
secs_to_next: 0.0,
},
);
app.update();
assert_eq!(banner_text(&mut app), "Replay");
set_state(&mut app, ReplayPlaybackState::Completed);
app.update();
assert_eq!(
overlay_root_count(&mut app),
1,
"overlay must remain spawned while in Completed state",
);
assert_eq!(
banner_text(&mut app),
"Replay complete",
"banner label must swap on Playing → Completed",
);
}
}
+682
View File
@@ -0,0 +1,682 @@
//! In-engine replay playback core.
//!
//! When the player clicks "Watch replay" on the Stats overlay, the live
//! game state is reset to the deal seeded from the replay's `seed` /
//! `mode` / `draw_mode`, and the engine ticks through `replay.moves` at a
//! steady cadence — firing the canonical [`MoveRequestEvent`] /
//! [`DrawRequestEvent`] for each one. The existing animation pipeline
//! plays back identically to a live game.
//!
//! ## Public surface
//!
//! - [`ReplayPlaybackState`] — single source of truth for whether
//! playback is live, how far through the move list we've ticked, and
//! how long until the next advance.
//! - [`start_replay_playback`] — public entry point; the Stats
//! "Watch replay" button calls this. Resets the game to the recorded
//! deal and transitions the state machine to
//! [`ReplayPlaybackState::Playing`].
//! - [`stop_replay_playback`] — interrupts playback at any time. Safe to
//! call when [`ReplayPlaybackState::Inactive`].
//! - [`ReplayPlaybackPlugin`] — registers the resource and the tick /
//! linger systems.
//!
//! ## Coordination note
//!
//! This module is built in parallel with the Stats-side overlay. The
//! resource shape, helper signatures, and plugin marker match the
//! contract the overlay agent reads against — see also the docs on the
//! enum variants.
//!
//! ## Recording is paused during playback
//!
//! Playback fires the same [`MoveRequestEvent`] / [`DrawRequestEvent`]
//! the live engine handles. Without intervention, [`RecordingReplay`]
//! would re-record those events and a replay would re-record itself
//! indefinitely. To prevent that, [`record_replay_skip_during_playback`]
//! snapshots the recording's length at the start of playback and
//! truncates the buffer back to that length every frame. This keeps
//! the recording contract opaque to `game_plugin` — no event-source
//! flag is threaded through, no every-callsite gate is added.
use bevy::prelude::*;
use solitaire_data::{Replay, ReplayMove};
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent};
use crate::game_plugin::{GameMutation, RecordingReplay};
use crate::resources::GameStateResource;
/// Per-move duration during playback. Tunable in Settings later;
/// hardcoded for v1.
pub const REPLAY_MOVE_INTERVAL_SECS: f32 = 0.45;
/// How long the [`ReplayPlaybackState::Completed`] state lingers before
/// the auto-clear system transitions it back to
/// [`ReplayPlaybackState::Inactive`]. Gives the overlay UI time to
/// display "Replay complete" before dismissing.
pub const REPLAY_COMPLETION_LINGER_SECS: f32 = 5.0;
/// Lifecycle state of an in-flight replay playback.
///
/// The default state is [`Inactive`](Self::Inactive) — no replay is
/// running. The overlay (and any other consumer) reads this resource to
/// decide whether the "Replay" banner should be visible and what
/// progress to display.
///
/// Lifecycle:
/// 1. Default state is [`Inactive`](Self::Inactive).
/// 2. [`start_replay_playback`] transitions to
/// [`Playing`](Self::Playing) and resets the live `GameState` to the
/// replay's recorded deal.
/// 3. The tick system [`tick_replay_playback`] advances `cursor` once
/// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event
/// for each [`ReplayMove`].
/// 4. When `cursor == replay.moves.len()`, the state transitions to
/// [`Completed`](Self::Completed). It lingers for
/// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by
/// [`auto_clear_completed_replay`]) before returning to
/// [`Inactive`](Self::Inactive).
/// 5. [`stop_replay_playback`] interrupts at any time and forces the
/// state back to [`Inactive`](Self::Inactive).
#[derive(Resource, Debug, Default)]
pub enum ReplayPlaybackState {
/// No replay is being played back. The overlay despawns itself when
/// the resource transitions back to this variant.
#[default]
Inactive,
/// A replay is currently being played back. The overlay reads
/// `replay.moves.len()` for the denominator of the progress
/// indicator and `cursor` for the numerator.
Playing {
/// The replay being played back. Owned so the state is the
/// only place playback metadata lives — no separate resource
/// needed.
replay: Replay,
/// Index of the next move to apply, in `[0, replay.moves.len()]`.
cursor: usize,
/// Seconds remaining until the next move is dispatched.
secs_to_next: f32,
},
/// The replay finished playing back. The overlay swaps the banner
/// label to "Replay complete" until [`auto_clear_completed_replay`]
/// transitions back to [`Inactive`](Self::Inactive) a few seconds
/// later.
Completed,
}
impl ReplayPlaybackState {
/// Returns `true` when a replay is currently being played back.
pub fn is_playing(&self) -> bool {
matches!(self, Self::Playing { .. })
}
/// Returns `true` when the replay has finished but the resource has
/// not yet been auto-cleared back to [`Self::Inactive`].
pub fn is_completed(&self) -> bool {
matches!(self, Self::Completed)
}
/// Returns `(cursor, total)` when a replay is in progress so the
/// overlay can render `"Move N of M"`. Returns `None` while
/// [`Inactive`](Self::Inactive) or [`Completed`](Self::Completed) —
/// the replay is consumed when transitioning out of `Playing`, so
/// the total is no longer available in `Completed`.
pub fn progress(&self) -> Option<(usize, usize)> {
match self {
Self::Playing { replay, cursor, .. } => Some((*cursor, replay.moves.len())),
Self::Inactive | Self::Completed => None,
}
}
}
/// Public entry point — call from the Stats "Watch replay" button
/// handler.
///
/// Resets the live [`GameStateResource`] to a fresh deal seeded from
/// `replay.seed` / `replay.draw_mode` / `replay.mode` (via
/// [`Commands::insert_resource`]), then transitions the state machine
/// to [`ReplayPlaybackState::Playing`] with `cursor: 0` and
/// `secs_to_next: REPLAY_MOVE_INTERVAL_SECS`.
///
/// `commands` is used to overwrite [`GameStateResource`] in a deferred
/// flush — equivalent to what `handle_new_game` does, minus the
/// [`crate::events::NewGameRequestEvent`] round-trip and the
/// abandon-current-game confirmation modal (which would block playback
/// indefinitely). Using `Commands` rather than [`crate::events::NewGameRequestEvent`]
/// also sidesteps the fact that `NewGameRequestEvent` has no
/// `draw_mode_override` field — `handle_new_game` always reads
/// `draw_mode` from `Settings`, which would silently coerce a Draw-1
/// replay into a Draw-3 game (or vice versa) when the player's
/// settings disagree with the recording.
///
/// Safe to call from any state — if a replay is already playing it is
/// dropped and the new one starts immediately.
pub fn start_replay_playback(
commands: &mut Commands,
state: &mut ResMut<ReplayPlaybackState>,
replay: Replay,
) {
use solitaire_core::game_state::GameState;
let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode.clone(), replay.mode);
commands.insert_resource(GameStateResource(fresh));
**state = ReplayPlaybackState::Playing {
replay,
cursor: 0,
secs_to_next: REPLAY_MOVE_INTERVAL_SECS,
};
}
/// Aborts an in-flight replay playback and resets
/// [`ReplayPlaybackState`] back to [`ReplayPlaybackState::Inactive`].
///
/// Safe to call from any state — when already
/// [`ReplayPlaybackState::Inactive`] it simply re-asserts inactivity.
///
/// The current [`GameStateResource`] is left as-is: the player sees the
/// replay's most-recently-applied state until they start a fresh game
/// manually. This avoids forcing an extra deal animation in their face
/// the moment they cancel.
///
/// `commands` is currently unused but accepted to match the
/// [`start_replay_playback`] signature — leaves room to hook in
/// cleanup (e.g. despawning playback-only overlays) without a future
/// API break.
pub fn stop_replay_playback(
_commands: &mut Commands,
state: &mut ResMut<ReplayPlaybackState>,
) {
**state = ReplayPlaybackState::Inactive;
}
/// Tick system. Runs every frame; only does work when
/// [`ReplayPlaybackState::is_playing`].
///
/// Drains `secs_to_next` by `time.delta_secs()`. When the countdown
/// expires, fires the canonical event for the move at `cursor`,
/// increments `cursor`, and resets `secs_to_next`. When `cursor`
/// reaches `replay.moves.len()`, transitions to
/// [`ReplayPlaybackState::Completed`].
///
/// The advance loop is a `while`, not an `if`, so coarse time steps
/// (e.g. test-driven 200 ms ticks against a 450 ms interval) still
/// fire the right number of events — accumulated debt is paid off
/// across as many advances as needed in the same frame. In normal
/// gameplay frame deltas are well below `REPLAY_MOVE_INTERVAL_SECS`,
/// so the loop runs at most once per frame.
fn tick_replay_playback(
time: Res<Time>,
mut state: ResMut<ReplayPlaybackState>,
mut moves_writer: MessageWriter<MoveRequestEvent>,
mut draws_writer: MessageWriter<DrawRequestEvent>,
) {
let dt = time.delta_secs();
let mut transition_to_completed = false;
if let ReplayPlaybackState::Playing {
replay,
cursor,
secs_to_next,
} = state.as_mut()
{
*secs_to_next -= dt;
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
match &replay.moves[*cursor] {
ReplayMove::Move { from, to, count } => {
moves_writer.write(MoveRequestEvent {
from: from.clone(),
to: to.clone(),
count: *count,
});
}
ReplayMove::StockClick => {
draws_writer.write(DrawRequestEvent);
}
}
*cursor += 1;
*secs_to_next += REPLAY_MOVE_INTERVAL_SECS;
}
if *cursor >= replay.moves.len() {
transition_to_completed = true;
}
}
if transition_to_completed {
*state = ReplayPlaybackState::Completed;
}
}
/// Local timer for the [`ReplayPlaybackState::Completed`] linger.
/// Resets to zero whenever the state transitions out of
/// [`ReplayPlaybackState::Completed`].
#[derive(Default)]
struct CompletionLinger(f32);
/// Auto-clear system. While [`ReplayPlaybackState::Completed`],
/// accumulates time and transitions back to
/// [`ReplayPlaybackState::Inactive`] once
/// [`REPLAY_COMPLETION_LINGER_SECS`] has elapsed.
fn auto_clear_completed_replay(
time: Res<Time>,
mut state: ResMut<ReplayPlaybackState>,
mut linger: Local<CompletionLinger>,
) {
if state.is_completed() {
linger.0 += time.delta_secs();
if linger.0 >= REPLAY_COMPLETION_LINGER_SECS {
*state = ReplayPlaybackState::Inactive;
linger.0 = 0.0;
}
} else {
// Reset whenever we're not in Completed so the next completion
// measures from zero rather than accumulating across cycles.
linger.0 = 0.0;
}
}
/// Local cache of the recording buffer's length at the start of
/// playback. Lets us roll back any growth during playback without
/// touching `game_plugin`'s recording call sites.
#[derive(Default)]
struct RecordingSnapshot {
/// `Some(len)` while playback is active. The recording is
/// truncated back to this length every frame so playback-driven
/// events leak no entries into the recorded move list. `None`
/// when not playing — recording behaves normally.
snapshot_len: Option<usize>,
}
/// Recording-pause system. While [`ReplayPlaybackState::is_playing`],
/// snapshots the recording's length on entry and truncates the
/// recording back to that length every frame. This keeps the live
/// [`RecordingReplay`] opaque to `game_plugin`'s `handle_move` /
/// `handle_draw` — those still push unconditionally; we just wipe the
/// playback-driven entries before any other system can read them.
///
/// Implemented this way because [`RecordingReplay`] is mutated inside
/// the [`GameMutation`] system set (the schedule set that owns
/// `handle_move` / `handle_draw`). We schedule this system
/// `.after(GameMutation)` so the truncation runs each frame *after*
/// the unconditional push, removing the same entry the playback tick
/// caused.
fn record_replay_skip_during_playback(
state: Res<ReplayPlaybackState>,
mut recording: ResMut<RecordingReplay>,
mut snap: Local<RecordingSnapshot>,
) {
// Treat `Playing` and `Completed` identically for the purpose of
// recording suppression. The tick system's final advance fires
// its event in the same frame it transitions to `Completed`; the
// event is then consumed by `handle_move` / `handle_draw` either
// this frame (race-dependent on system order) or the next. By
// suppressing recording growth across both states, we close that
// window cleanly: the snapshot survives until the resource is
// back to `Inactive` (auto-cleared after
// `REPLAY_COMPLETION_LINGER_SECS`).
if state.is_playing() || state.is_completed() {
let baseline = match snap.snapshot_len {
Some(n) => n,
None => {
let n = recording.moves.len();
snap.snapshot_len = Some(n);
n
}
};
if recording.moves.len() > baseline {
recording.moves.truncate(baseline);
}
} else {
// Drop the snapshot when neither playing nor completed so
// the next playback cycle re-anchors to whatever the
// recording is at that point.
snap.snapshot_len = None;
}
}
/// On-completion side effect: fire a single [`StateChangedEvent`] when
/// playback transitions from `Playing` to `Completed` so any UI that
/// listens for state mutations refreshes one final time. Cheap and
/// idempotent — `StateChangedEvent` is a one-shot signal.
fn fire_state_changed_on_completion(
state: Res<ReplayPlaybackState>,
mut last_was_completed: Local<bool>,
mut writer: MessageWriter<StateChangedEvent>,
) {
let now_completed = state.is_completed();
if now_completed && !*last_was_completed {
writer.write(StateChangedEvent);
}
*last_was_completed = now_completed;
}
/// Bevy plugin that initialises [`ReplayPlaybackState`] and drives
/// playback ticks, completion linger, and the recording-pause guard.
///
/// Register this in the main app alongside [`crate::game_plugin::GamePlugin`].
/// Tests can install it under [`MinimalPlugins`] to exercise the public
/// API without spinning up the full client.
pub struct ReplayPlaybackPlugin;
impl Plugin for ReplayPlaybackPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ReplayPlaybackState>()
.add_systems(
Update,
(
tick_replay_playback,
auto_clear_completed_replay,
fire_state_changed_on_completion,
)
.chain(),
)
.add_systems(
Update,
record_replay_skip_during_playback.after(GameMutation),
);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use bevy::time::TimeUpdateStrategy;
use chrono::NaiveDate;
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_core::pile::PileType;
use std::time::Duration;
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
/// `ReplayPlaybackPlugin`. `GamePlugin` brings the canonical
/// `MoveRequestEvent` / `DrawRequestEvent` registrations along with
/// `RecordingReplay` so the recording-pause test can read it.
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(GamePlugin::headless())
.add_plugins(ReplayPlaybackPlugin);
// Disable game-state persistence so tests don't touch the
// real ~/.local/share/solitaire_quest/game_state.json.
app.insert_resource(crate::game_plugin::GameStatePath(None));
app.insert_resource(crate::game_plugin::ReplayPath(None));
// Tick once so any startup systems flush before the first
// assertion.
app.update();
app
}
/// `Time<Virtual>` clamps each tick to `max_delta` (default 250 ms),
/// so we drive 200 ms steps and call `update` enough times to pass
/// the requested duration.
fn advance_by(app: &mut App, total_secs: f32) {
app.insert_resource(TimeUpdateStrategy::ManualDuration(
Duration::from_secs_f32(0.2),
));
let ticks = (total_secs / 0.2).ceil() as usize + 1;
for _ in 0..ticks {
app.update();
}
}
/// A 3-move replay covering both `Move` and `StockClick` variants.
/// Seed 12345 is arbitrary — the test asserts on event counts and
/// move shapes, not on board positions.
fn sample_replay_three_moves() -> Replay {
Replay::new(
12345,
DrawMode::DrawOne,
GameMode::Classic,
60,
500,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![
ReplayMove::StockClick,
ReplayMove::Move {
from: PileType::Waste,
to: PileType::Tableau(3),
count: 1,
},
ReplayMove::StockClick,
],
)
}
/// Scoped helper to invoke `start_replay_playback` from within the
/// app's `World` (the public API takes `Commands`, which only
/// exists inside systems). We use a one-shot system to obtain the
/// `Commands`.
fn start_playback(app: &mut App, replay: Replay) {
#[derive(Resource)]
struct ReplayInbox(Option<Replay>);
app.insert_resource(ReplayInbox(Some(replay)));
fn run(
mut commands: Commands,
mut state: ResMut<ReplayPlaybackState>,
mut inbox: ResMut<ReplayInbox>,
) {
if let Some(replay) = inbox.0.take() {
start_replay_playback(&mut commands, &mut state, replay);
}
}
let id = app.world_mut().register_system(run);
app.world_mut()
.run_system(id)
.expect("one-shot start_playback");
}
fn stop_playback(app: &mut App) {
fn run(mut commands: Commands, mut state: ResMut<ReplayPlaybackState>) {
stop_replay_playback(&mut commands, &mut state);
}
let id = app.world_mut().register_system(run);
app.world_mut()
.run_system(id)
.expect("one-shot stop_playback");
}
/// Fresh state must be `Inactive`. After `start_replay_playback`
/// the state must be `Playing { cursor: 0, .. }` carrying the
/// supplied replay.
#[test]
fn start_replay_playback_transitions_inactive_to_playing() {
let mut app = headless_app();
assert!(matches!(
*app.world().resource::<ReplayPlaybackState>(),
ReplayPlaybackState::Inactive
));
let replay = sample_replay_three_moves();
start_playback(&mut app, replay.clone());
// Apply the deferred Commands flush.
app.update();
let state = app.world().resource::<ReplayPlaybackState>();
match state {
ReplayPlaybackState::Playing {
cursor,
replay: r,
..
} => {
assert_eq!(*cursor, 0);
assert_eq!(r.seed, replay.seed);
assert_eq!(r.moves.len(), 3);
}
other => panic!("expected Playing, got {other:?}"),
}
assert_eq!(state.progress(), Some((0, 3)));
}
/// One full interval (plus a small margin to clear the boundary)
/// must advance the cursor by at least one.
#[test]
fn tick_advances_cursor_after_interval() {
let mut app = headless_app();
start_playback(&mut app, sample_replay_three_moves());
app.update();
// Drive virtual time forward by one interval.
advance_by(&mut app, REPLAY_MOVE_INTERVAL_SECS + 0.05);
let state = app.world().resource::<ReplayPlaybackState>();
match state {
ReplayPlaybackState::Playing { cursor, .. } => {
assert!(
*cursor >= 1,
"expected cursor advanced past one move, got {cursor}",
);
}
other => panic!("expected Playing, got {other:?}"),
}
}
/// Driving past `n * REPLAY_MOVE_INTERVAL_SECS` must produce
/// `n` events that match the recorded move kinds. We register a
/// pair of accumulator systems that drain `MoveRequestEvent` /
/// `DrawRequestEvent` into resources every frame — using a
/// detached cursor across many `app.update()` calls is unreliable
/// because Bevy's `Messages` double-buffer drops events older
/// than two frames.
#[test]
fn tick_fires_canonical_event_for_each_move() {
#[derive(Resource, Default)]
struct CapturedMoves(Vec<MoveRequestEvent>);
#[derive(Resource, Default)]
struct CapturedDraws(usize);
fn collect_moves(
mut events: MessageReader<MoveRequestEvent>,
mut sink: ResMut<CapturedMoves>,
) {
for ev in events.read() {
sink.0.push(ev.clone());
}
}
fn collect_draws(
mut events: MessageReader<DrawRequestEvent>,
mut sink: ResMut<CapturedDraws>,
) {
for _ in events.read() {
sink.0 += 1;
}
}
let mut app = headless_app();
app.init_resource::<CapturedMoves>()
.init_resource::<CapturedDraws>()
.add_systems(Update, (collect_moves, collect_draws));
start_playback(&mut app, sample_replay_three_moves());
app.update();
// Drive through 3 intervals. Add a small margin to ensure the
// last firing isn't sitting exactly on the boundary.
advance_by(&mut app, REPLAY_MOVE_INTERVAL_SECS * 3.0 + 0.1);
let captured_moves = app.world().resource::<CapturedMoves>();
let captured_draws = app.world().resource::<CapturedDraws>();
// Sample replay: StockClick, Move { Waste -> Tableau(3), 1 }, StockClick.
assert_eq!(
captured_draws.0, 2,
"expected 2 DrawRequestEvent (two StockClicks)",
);
assert_eq!(
captured_moves.0.len(),
1,
"expected 1 MoveRequestEvent (the single Move variant)",
);
let m = &captured_moves.0[0];
assert!(matches!(m.from, PileType::Waste));
assert!(matches!(m.to, PileType::Tableau(3)));
assert_eq!(m.count, 1);
}
/// Driving past one interval on a single-move replay must
/// transition to `Completed`.
#[test]
fn playback_completes_when_cursor_reaches_end() {
let mut app = headless_app();
let one_move = Replay::new(
42,
DrawMode::DrawOne,
GameMode::Classic,
10,
100,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![ReplayMove::StockClick],
);
start_playback(&mut app, one_move);
app.update();
advance_by(&mut app, REPLAY_MOVE_INTERVAL_SECS + 0.1);
let state = app.world().resource::<ReplayPlaybackState>();
assert!(
state.is_completed(),
"expected Completed after consuming the only move, got {state:?}",
);
}
/// `stop_replay_playback` must force the state back to `Inactive`
/// even mid-playback.
#[test]
fn stop_replay_playback_returns_to_inactive() {
let mut app = headless_app();
start_playback(&mut app, sample_replay_three_moves());
app.update();
// Tick once so the state is well and truly `Playing`.
advance_by(&mut app, 0.1);
assert!(app.world().resource::<ReplayPlaybackState>().is_playing());
stop_playback(&mut app);
app.update();
assert!(matches!(
*app.world().resource::<ReplayPlaybackState>(),
ReplayPlaybackState::Inactive
));
}
/// Recording must remain frozen during playback. Pre-populate the
/// recording with one entry, start playback, and assert the
/// recording's move list is unchanged after several ticks.
#[test]
fn recording_paused_during_playback() {
let mut app = headless_app();
// Pre-populate the recording with one entry that should
// survive playback unchanged. Mirrors the situation where the
// player partway through a game opens stats and clicks Watch
// Replay — their in-flight recording must not get clobbered.
{
let mut rec = app.world_mut().resource_mut::<RecordingReplay>();
rec.moves.push(ReplayMove::StockClick);
}
start_playback(&mut app, sample_replay_three_moves());
app.update();
let baseline_len = app.world().resource::<RecordingReplay>().moves.len();
assert_eq!(
baseline_len, 1,
"preconditions: recording starts with one entry",
);
// Drive playback through every move in the replay. Each move
// would normally append to `RecordingReplay`; the pause
// system must clamp the recording back to `baseline_len` on
// every frame.
advance_by(&mut app, REPLAY_MOVE_INTERVAL_SECS * 4.0 + 0.1);
let after_len = app.world().resource::<RecordingReplay>().moves.len();
assert_eq!(
after_len, baseline_len,
"recording must not grow while playback is active",
);
}
}
+51
View File
@@ -132,6 +132,11 @@ struct TooltipDelayText;
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct TimeBonusMultiplierText; struct TimeBonusMultiplierText;
/// Marks the `Text` node showing the current "Winnable deals only"
/// state ("ON" / "OFF") in the Gameplay section.
#[derive(Component, Debug)]
struct WinnableDealsOnlyText;
/// Marks the scrollable inner card so the mouse-wheel system can target it. /// Marks the scrollable inner card so the mouse-wheel system can target it.
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct SettingsPanelScrollable; struct SettingsPanelScrollable;
@@ -176,6 +181,11 @@ enum SettingsButton {
TimeBonusUp, TimeBonusUp,
ToggleTheme, ToggleTheme,
ToggleColorBlind, ToggleColorBlind,
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
/// random Classic-mode deals are filtered through
/// [`solitaire_core::solver::try_solve`] until one is provably
/// winnable (or the retry cap is hit). Off by default.
ToggleWinnableDealsOnly,
SyncNow, SyncNow,
Done, Done,
/// Select a specific card-back by index from the picker row. /// Select a specific card-back by index from the picker row.
@@ -203,6 +213,7 @@ impl SettingsButton {
SettingsButton::MusicUp => 21, SettingsButton::MusicUp => 21,
// Gameplay section // Gameplay section
SettingsButton::ToggleDrawMode => 30, SettingsButton::ToggleDrawMode => 30,
SettingsButton::ToggleWinnableDealsOnly => 35,
SettingsButton::CycleAnimSpeed => 40, SettingsButton::CycleAnimSpeed => 40,
SettingsButton::TooltipDelayDown => 45, SettingsButton::TooltipDelayDown => 45,
SettingsButton::TooltipDelayUp => 46, SettingsButton::TooltipDelayUp => 46,
@@ -299,6 +310,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_winnable_deals_only_text,
attach_focusable_to_settings_buttons, attach_focusable_to_settings_buttons,
scroll_focus_into_view, scroll_focus_into_view,
), ),
@@ -549,6 +561,21 @@ fn update_color_blind_text(
} }
} }
/// Refreshes the live "Winnable deals only" toggle value in the
/// Gameplay section whenever `SettingsResource` changes (button click,
/// hand-edited `settings.json` reload, etc.).
fn update_winnable_deals_only_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<WinnableDealsOnlyText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = winnable_deals_only_label(settings.0.winnable_deals_only);
}
}
/// Refreshes the live tooltip-delay value in the Gameplay section /// Refreshes the live tooltip-delay value in the Gameplay section
/// whenever `SettingsResource` changes (slider buttons, hand-edited /// whenever `SettingsResource` changes (slider buttons, hand-edited
/// settings.json reload, etc.). /// settings.json reload, etc.).
@@ -758,6 +785,13 @@ fn handle_settings_buttons(
**t = color_blind_label(settings.0.color_blind_mode); **t = color_blind_label(settings.0.color_blind_mode);
} }
} }
SettingsButton::ToggleWinnableDealsOnly => {
settings.0.winnable_deals_only = !settings.0.winnable_deals_only;
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
// The Text node is refreshed by `update_winnable_deals_only_text`
// on the next frame via `settings.is_changed()`.
}
SettingsButton::SelectCardBack(idx) => { SettingsButton::SelectCardBack(idx) => {
settings.0.selected_card_back = *idx; settings.0.selected_card_back = *idx;
persist(&path, &settings.0); persist(&path, &settings.0);
@@ -812,6 +846,13 @@ fn color_blind_label(enabled: bool) -> String {
if enabled { "ON".into() } else { "OFF".into() } if enabled { "ON".into() } else { "OFF".into() }
} }
/// Display string for the "Winnable deals only" toggle. Mirrors
/// [`color_blind_label`] — "ON" / "OFF" — so the layout is uniform
/// with the rest of the Gameplay-section toggles.
fn winnable_deals_only_label(enabled: bool) -> String {
if enabled { "ON".into() } else { "OFF".into() }
}
/// Formats the tooltip-hover delay for display in the Settings panel. /// Formats the tooltip-hover delay for display in the Settings panel.
/// `0.0` reads as `"Instant"` so the zero-delay case has a name; any /// `0.0` reads as `"Instant"` so the zero-delay case has a name; any
/// other value prints as `"{n:.1} s"` (e.g. `"0.5 s"`, `"1.2 s"`). /// other value prints as `"{n:.1} s"` (e.g. `"0.5 s"`, `"1.2 s"`).
@@ -1158,6 +1199,16 @@ fn spawn_settings_panel(
"Switch between Draw 1 and Draw 3. Takes effect next deal.", "Switch between Draw 1 and Draw 3. Takes effect next deal.",
font_res, font_res,
); );
toggle_row(
body,
"Winnable deals only",
WinnableDealsOnlyText,
winnable_deals_only_label(settings.winnable_deals_only),
SettingsButton::ToggleWinnableDealsOnly,
"When on, fresh Classic deals are filtered through a solver \
(may take a moment when on).",
font_res,
);
toggle_row( toggle_row(
body, body,
"Anim Speed", "Anim Speed",
+417 -192
View File
@@ -8,11 +8,12 @@
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::{
latest_replay_path, load_latest_replay_from, load_stats_from, save_stats_to, stats_file_path, load_replay_history_from, load_stats_from, replay_history_path, save_stats_to,
PlayerProgress, Replay, StatsExt, StatsSnapshot, WEEKLY_GOALS, stats_file_path, PlayerProgress, Replay, ReplayHistory, StatsExt, StatsSnapshot, WEEKLY_GOALS,
}; };
use crate::auto_complete_plugin::AutoCompleteState; use crate::auto_complete_plugin::AutoCompleteState;
@@ -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,
@@ -58,30 +60,57 @@ pub struct StatsScreen;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct StatsCell; pub struct StatsCell;
/// Resource holding the most recently loaded winning [`Replay`], if any. /// Resource holding the rolling [`ReplayHistory`] of recent winning
/// replays.
/// ///
/// Populated from `<data_dir>/solitaire_quest/latest_replay.json` at /// Populated from `<data_dir>/solitaire_quest/replays.json` at startup
/// startup and refreshed in-place whenever the engine writes a new /// and refreshed in-place whenever the engine writes a new winning
/// winning replay (the path the Stats UI calls into is unchanged so a /// replay so the Stats overlay's selector always reflects the current
/// re-open of the modal sees the latest record). /// on-disk history.
/// ///
/// The Stats overlay reads this to decide whether to render the /// `replays[0]` is the most recent win — the Stats overlay's selector
/// "Watch replay" call-to-action or the "No replay recorded yet" /// defaults to that entry and lets the player step backwards through
/// caption. /// up to [`solitaire_data::REPLAY_HISTORY_CAP`] older entries.
#[derive(Resource, Debug, Default, Clone)] #[derive(Resource, Debug, Default, Clone)]
pub struct LatestReplayResource(pub Option<Replay>); pub struct ReplayHistoryResource(pub ReplayHistory);
/// Persistence path for the latest winning replay file. `None` disables /// Currently-selected index into [`ReplayHistoryResource::0`].`replays`.
/// I/O — used by tests and by `StatsPlugin::headless`. ///
/// `0` is the most recent win and is the default on every modal open.
/// The Prev / Next chips wrap-around within the bounds of the current
/// history so the selector is always sat on a valid replay (or on `0`
/// when the history is empty — the chips paint disabled in that case).
#[derive(Resource, Debug, Default, Clone, Copy)]
pub struct SelectedReplayIndex(pub usize);
/// Persistence path for the rolling replay history file
/// (`replays.json`). `None` disables I/O — used by tests and by
/// `StatsPlugin::headless`.
#[derive(Resource, Debug, Clone)] #[derive(Resource, Debug, Clone)]
pub struct LatestReplayPath(pub Option<PathBuf>); pub struct LatestReplayPath(pub Option<PathBuf>);
/// Marker on the "Watch replay" button inside the Stats modal. Clicking /// Marker on the "Watch replay" button inside the Stats modal. Clicking
/// it currently fires an [`InfoToastEvent`] indicating playback ships /// it starts in-engine playback of the selected replay — see
/// in a future build — see [`handle_watch_replay_button`]. /// [`handle_watch_replay_button`].
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct WatchReplayButton; pub struct WatchReplayButton;
/// Marker on the selector's "Previous replay" chip — steps the
/// selection backwards (toward older replays) within
/// [`ReplayHistoryResource`].
#[derive(Component, Debug)]
pub struct ReplayPrevButton;
/// Marker on the selector's "Next replay" chip — steps the selection
/// forwards (toward more recent replays).
#[derive(Component, Debug)]
pub struct ReplayNextButton;
/// Marker on the selector's `"Replay N / M"` caption text node so the
/// repaint system can rewrite the label as the selection changes.
#[derive(Component, Debug)]
pub struct ReplaySelectorCaption;
/// Marker component on each per-mode bests row in the stats overlay. /// Marker component on each per-mode bests row in the stats overlay.
/// ///
/// One row per supported [`solitaire_core::game_state::GameMode`] (Classic, /// One row per supported [`solitaire_core::game_state::GameMode`] (Classic,
@@ -91,6 +120,18 @@ pub struct WatchReplayButton;
#[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).
@@ -123,14 +164,16 @@ impl Plugin for StatsPlugin {
// Replay file lives next to stats.json — when the StatsPlugin // Replay file lives next to stats.json — when the StatsPlugin
// is in headless mode (storage_path = None), we mirror that // is in headless mode (storage_path = None), we mirror that
// policy and disable replay I/O too. Otherwise resolve the // policy and disable replay I/O too. Otherwise resolve the
// platform-default path via `latest_replay_path()`. // platform-default path via `replay_history_path()`.
let replay_path = self.storage_path.as_ref().and(latest_replay_path()); let replay_path = self.storage_path.as_ref().and(replay_history_path());
let initial_replay = replay_path let initial_history = replay_path
.as_deref() .as_deref()
.and_then(load_latest_replay_from); .and_then(load_replay_history_from)
.unwrap_or_default();
app.insert_resource(StatsResource(loaded)) app.insert_resource(StatsResource(loaded))
.insert_resource(StatsStoragePath(self.storage_path.clone())) .insert_resource(StatsStoragePath(self.storage_path.clone()))
.insert_resource(LatestReplayResource(initial_replay)) .insert_resource(ReplayHistoryResource(initial_history))
.init_resource::<SelectedReplayIndex>()
.insert_resource(LatestReplayPath(replay_path)) .insert_resource(LatestReplayPath(replay_path))
.add_message::<GameWonEvent>() .add_message::<GameWonEvent>()
.add_message::<NewGameRequestEvent>() .add_message::<NewGameRequestEvent>()
@@ -138,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
@@ -160,19 +207,52 @@ impl Plugin for StatsPlugin {
.add_systems(Update, handle_stats_close_button) .add_systems(Update, handle_stats_close_button)
.add_systems( .add_systems(
Update, Update,
refresh_latest_replay_on_win.after(GameMutation), refresh_replay_history_on_win.after(GameMutation),
) )
.add_systems(Update, handle_watch_replay_button); .add_systems(Update, handle_watch_replay_button)
.add_systems(
Update,
(handle_replay_selector_buttons, repaint_replay_selector_caption).chain(),
)
.add_systems(Update, scroll_stats_panel);
} }
} }
/// After a win, the engine has just persisted a fresh winning replay. /// Routes mouse-wheel events into the Stats modal's scrollable body
/// Re-load it so the next time the player opens the Stats overlay, the /// while the panel is open. No-op when no `StatsScrollable` exists in
/// "Watch replay" call-to-action reflects the most recent victory /// the world (modal closed). Mirrors `scroll_settings_panel`.
/// rather than an older session. fn scroll_stats_panel(
fn refresh_latest_replay_on_win( mut scroll_evr: MessageReader<MouseWheel>,
mut scrollables: Query<&mut ScrollPosition, With<StatsScrollable>>,
) {
if scrollables.is_empty() {
scroll_evr.clear();
return;
}
let delta_y: f32 = scroll_evr
.read()
.map(|ev| match ev.unit {
MouseScrollUnit::Line => ev.y * 50.0,
MouseScrollUnit::Pixel => ev.y,
})
.sum();
if delta_y == 0.0 {
return;
}
for mut sp in scrollables.iter_mut() {
sp.0.y = (sp.0.y - delta_y).max(0.0);
}
}
/// After a win, the engine has just appended a fresh winning replay to
/// the rolling history file. Re-load it so the next time the player
/// opens the Stats overlay the selector reflects the new entry, and
/// reset [`SelectedReplayIndex`] to `0` so the default selection is the
/// just-recorded win.
fn refresh_replay_history_on_win(
mut wins: MessageReader<GameWonEvent>, mut wins: MessageReader<GameWonEvent>,
mut latest: ResMut<LatestReplayResource>, mut history: ResMut<ReplayHistoryResource>,
mut selected: ResMut<SelectedReplayIndex>,
path: Res<LatestReplayPath>, path: Res<LatestReplayPath>,
) { ) {
// Only re-load when at least one win actually fired. // Only re-load when at least one win actually fired.
@@ -182,32 +262,123 @@ fn refresh_latest_replay_on_win(
let Some(p) = path.0.as_deref() else { let Some(p) = path.0.as_deref() else {
return; return;
}; };
latest.0 = load_latest_replay_from(p); history.0 = load_replay_history_from(p).unwrap_or_default();
// Snap the selector back to the most recent win — that's the one
// the player just earned.
selected.0 = 0;
} }
/// Click handler for the "Watch replay" button. /// Click handler for the "Watch replay" button.
/// ///
/// Replay playback lives on the sync server's web UI rather than in /// Starts in-engine replay playback for the currently-selected entry in
/// the desktop client. This handler currently surfaces a clear toast /// [`ReplayHistoryResource`] (per [`SelectedReplayIndex`]). If the
/// pointing the player there once the upload + URL is wired; until /// history is empty or the selector points past the end (defensive
/// then it acknowledges the click and signals that the feature is on /// guard), surfaces an [`InfoToastEvent`] instead. The playback path
/// the way. /// resets the live game to the recorded deal and ticks through the
/// move list via [`crate::replay_playback`]; the
/// [`crate::replay_overlay`] banner surfaces while playback runs.
fn handle_watch_replay_button( fn handle_watch_replay_button(
mut commands: Commands,
buttons: Query<&Interaction, (With<WatchReplayButton>, Changed<Interaction>)>, buttons: Query<&Interaction, (With<WatchReplayButton>, Changed<Interaction>)>,
latest: Res<LatestReplayResource>, history: Res<ReplayHistoryResource>,
selected: Res<SelectedReplayIndex>,
playback: Option<ResMut<crate::replay_playback::ReplayPlaybackState>>,
mut toast: MessageWriter<InfoToastEvent>, mut toast: MessageWriter<InfoToastEvent>,
) { ) {
if !buttons.iter().any(|i| *i == Interaction::Pressed) { if !buttons.iter().any(|i| *i == Interaction::Pressed) {
return; return;
} }
let message = match &latest.0 { let chosen = history.0.replays.get(selected.0);
Some(replay) => format!( match (chosen, playback) {
"Replay ready ({}) \u{2014} web playback coming in a future build", (Some(replay), Some(mut playback)) => {
format_replay_caption(replay), crate::replay_playback::start_replay_playback(
), &mut commands,
None => "No replay recorded yet \u{2014} win a game first.".to_string(), &mut playback,
}; replay.clone(),
toast.write(InfoToastEvent(message)); );
}
(Some(replay), None) => {
// ReplayPlaybackPlugin not registered (headless test
// fixtures); fall back to a descriptive toast.
toast.write(InfoToastEvent(format!(
"Replay ready ({})",
format_replay_caption(replay)
)));
}
(None, _) => {
toast.write(InfoToastEvent(
"No replay recorded yet \u{2014} win a game first.".to_string(),
));
}
}
}
/// Click handler for the Prev / Next chips on the Stats overlay's
/// replay selector. Steps [`SelectedReplayIndex`] within the bounds of
/// the current [`ReplayHistoryResource`]; selection wraps so the
/// chooser is always sat on a valid replay.
///
/// No-op when the history is empty — the selector chips paint disabled
/// in that case but a defensive bounds check here keeps things tidy if
/// the click somehow lands.
fn handle_replay_selector_buttons(
prev: Query<&Interaction, (With<ReplayPrevButton>, Changed<Interaction>)>,
next: Query<&Interaction, (With<ReplayNextButton>, Changed<Interaction>)>,
history: Res<ReplayHistoryResource>,
mut selected: ResMut<SelectedReplayIndex>,
) {
let len = history.0.replays.len();
if len == 0 {
return;
}
let prev_pressed = prev.iter().any(|i| *i == Interaction::Pressed);
let next_pressed = next.iter().any(|i| *i == Interaction::Pressed);
if prev_pressed {
// Step toward older replays — wrap to the oldest when at the
// newest (index 0).
selected.0 = if selected.0 == 0 { len - 1 } else { selected.0 - 1 };
}
if next_pressed {
// Step toward more recent replays — wrap to the newest when at
// the oldest.
selected.0 = (selected.0 + 1) % len;
}
}
/// Live-update the `"Replay N / M"` caption text as the selector
/// changes. The caption sits next to the Prev / Next chips above the
/// Watch button so the player can see at a glance which replay they're
/// about to watch.
fn repaint_replay_selector_caption(
history: Res<ReplayHistoryResource>,
selected: Res<SelectedReplayIndex>,
mut q: Query<&mut Text, With<ReplaySelectorCaption>>,
) {
if !history.is_changed() && !selected.is_changed() {
return;
}
for mut text in &mut q {
**text = replay_selector_caption(selected.0, history.0.replays.len());
}
}
/// Pure helper: render the selector caption shown next to the Prev /
/// Next chips. Returns `"No replays"` when the history is empty,
/// otherwise `"Replay {1-based index} / {total}"`.
///
/// `index` is zero-based as it's stored in [`SelectedReplayIndex`].
/// The display flips it to a one-based ordinal so "Replay 1" reads as
/// "the most recent win" — matching the mental model the chooser
/// surfaces.
pub fn replay_selector_caption(index: usize, total: usize) -> String {
if total == 0 {
return "No replays".to_string();
}
// Defensive clamp — the caller is supposed to keep `index` in
// range, but a stale selector after a cap-driven truncation
// shouldn't crash the renderer.
let one_based = index.min(total.saturating_sub(1)) + 1;
format!("Replay {one_based} / {total}")
} }
/// Pure helper: render a one-line caption for a [`Replay`] suitable /// Pure helper: render a one-line caption for a [`Replay`] suitable
@@ -359,7 +530,8 @@ fn toggle_stats_screen(
progress: Option<Res<ProgressResource>>, progress: Option<Res<ProgressResource>>,
time_attack: Option<Res<TimeAttackResource>>, time_attack: Option<Res<TimeAttackResource>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
latest_replay: Res<LatestReplayResource>, latest_replay: Res<ReplayHistoryResource>,
selected_index: Res<SelectedReplayIndex>,
screens: Query<Entity, With<StatsScreen>>, screens: Query<Entity, With<StatsScreen>>,
) { ) {
let button_clicked = requests.read().count() > 0; let button_clicked = requests.read().count() > 0;
@@ -369,13 +541,14 @@ fn toggle_stats_screen(
if let Ok(entity) = screens.single() { if let Ok(entity) = screens.single() {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} else { } else {
let selected = latest_replay.0.replays.get(selected_index.0);
spawn_stats_screen( spawn_stats_screen(
&mut commands, &mut commands,
&stats.0, &stats.0,
progress.as_deref().map(|p| &p.0), progress.as_deref().map(|p| &p.0),
time_attack.as_deref(), time_attack.as_deref(),
font_res.as_deref(), font_res.as_deref(),
latest_replay.0.as_ref(), selected,
); );
} }
} }
@@ -430,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,
@@ -541,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
@@ -628,6 +821,8 @@ fn spawn_stats_screen(
); );
}); });
}); });
// Stats is read-only — opt into click-outside-to-dismiss.
commands.entity(scrim).insert(ScrimDismissible);
} }
/// Spawn one row of the "Per-mode bests" section: the mode label on the /// Spawn one row of the "Per-mode bests" section: the mode label on the
@@ -960,6 +1155,36 @@ mod tests {
); );
} }
#[test]
fn stats_modal_body_is_scrollable() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyS);
app.update();
let count = app
.world_mut()
.query::<&StatsScrollable>()
.iter(app.world())
.count();
assert_eq!(
count, 1,
"Stats modal must spawn exactly one StatsScrollable body"
);
let mut q = app
.world_mut()
.query_filtered::<&Node, With<StatsScrollable>>();
let nodes: Vec<&Node> = q.iter(app.world()).collect();
assert_ne!(
nodes[0].max_height,
Val::Auto,
"scrollable body must set a non-default max_height"
);
assert_eq!(nodes[0].overflow, Overflow::scroll_y());
}
#[test] #[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"
);
}
} }