Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bfcd05fbb5 | |||
| c497c3193c | |||
| 9aa0dd23b1 | |||
| d065d49fe7 | |||
| c30b04ec72 | |||
| 40d6e0ab17 | |||
| 9fe650fa20 | |||
| b73d246b4c | |||
| ae40a1db7a | |||
| b7c3a4996f | |||
| d48b9489db | |||
| 08b006ff30 | |||
| 17e0737a10 | |||
| dd63261999 | |||
| 93660c2217 | |||
| 56e2e6f151 | |||
| cc635328be | |||
| a4bc063497 | |||
| 540869c851 | |||
| bdac754b26 | |||
| f863d85c35 | |||
| 3c7a0eb4fb | |||
| d489e7a31b | |||
| f2f30c8002 | |||
| a49a340a30 | |||
| 27cdf78ce0 | |||
| faa6c5efc4 | |||
| 487b99bbc9 | |||
| 53e3b816cf | |||
| 87275bf340 | |||
| 56647d7f0d | |||
| cbf2483028 | |||
| a54201e97b | |||
| 48e412177c | |||
| cd54ce1bb0 | |||
| 7a3032b74c | |||
| 89699a8a86 |
+2
-3
@@ -47,11 +47,10 @@ Solitaire Quest is a cross-platform Klondike Solitaire game written in Rust, tar
|
|||||||
### Design Principles
|
### Design Principles
|
||||||
|
|
||||||
- **Offline first.** The local file is always the source of truth. Sync is additive, never destructive.
|
- **Offline first.** The local file is always the source of truth. Sync is additive, never destructive.
|
||||||
- **Pure core.** All game logic lives in a dependency-free Rust crate with no Bevy, no network, and no I/O. This keeps it fully unit-testable and portable.
|
|
||||||
- **No panics in game logic.** Every state transition returns `Result<_, MoveError>`. Panics are only acceptable in startup/configuration code.
|
|
||||||
- **One language, one repo.** The game client, sync client, shared types, and sync server are all Rust crates in a single Cargo workspace.
|
- **One language, one repo.** The game client, sync client, shared types, and sync server are all Rust crates in a single Cargo workspace.
|
||||||
- **Plugin-based Bevy architecture.** Each major feature is a Bevy `Plugin`. Systems are small and single-purpose. Cross-system communication uses Bevy `Event`s.
|
- **Plugin-based Bevy architecture.** Each major feature is a Bevy `Plugin`. Systems are small and single-purpose. Cross-system communication uses Bevy `Event`s.
|
||||||
- **UI-first interaction.** Every player-triggered action — new game, undo, draw, pause, open stats / settings / help / profile / leaderboard, etc. — must be reachable from a visible UI control. Keyboard shortcuts exist only as optional accelerators for power users; they are never the sole entry point. A player using only mouse or touch must be able to perform every action. New gameplay features ship with the UI control alongside the system that backs it.
|
|
||||||
|
Pure-core, no-panics-in-game-logic, and UI-first-interaction constraints are enforced by CLAUDE.md §2.1, §2.3, and §3.3 respectively — those are the canonical statements; this file describes the design that motivates them.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+286
-1
@@ -8,6 +8,290 @@ project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
_Nothing yet._
|
_Nothing yet._
|
||||||
|
|
||||||
|
## [0.18.0] — 2026-05-06
|
||||||
|
|
||||||
|
The launch-experience round. The engine used to drop the player on a
|
||||||
|
silent default Classic deal whether they had unfinished work or not;
|
||||||
|
v0.18.0 replaces that with two stacked decision points — a Restore
|
||||||
|
prompt for in-progress saves, then an MSSC-style Home / mode picker
|
||||||
|
that surfaces Daily / Zen / Challenge / Time Attack as picture tiles
|
||||||
|
with live stats. The same round closes the last solver-on-main-thread
|
||||||
|
hot path (winnable-only seed selection moves to
|
||||||
|
`AsyncComputeTaskPool`), wires "Copy share link" into Stats, lights a
|
||||||
|
"Won before" HUD chip on re-deals of beaten seeds, and tidies the
|
||||||
|
unified-3.0 rule set across CLAUDE.md / CLAUDE_SPEC.md /
|
||||||
|
CLAUDE_WORKFLOW.md / CLAUDE_PROMPT_PACK.md.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Restore prompt on launch** (`3c7a0eb`). When `game_state.json`
|
||||||
|
holds an in-progress game (`move_count > 0`, not won), the engine
|
||||||
|
now seeds `GameStateResource` with a fresh deal and holds the saved
|
||||||
|
game in a new `PendingRestoredGame` resource. After the splash
|
||||||
|
clears, a "Welcome back" modal offers **Continue** (Enter / C /
|
||||||
|
click) or **New game** (N / click). Fresh-deal saves
|
||||||
|
(`move_count == 0`) skip the prompt and load directly.
|
||||||
|
- **Save preservation while the prompt is unanswered** (`f863d85`).
|
||||||
|
Both `save_game_state_on_exit` and `auto_save_game_state` consult
|
||||||
|
`PendingRestoredGame` first: if it still holds a pending saved
|
||||||
|
game, that's what gets persisted (or the auto-save is skipped),
|
||||||
|
so exiting before answering the prompt no longer overwrites the
|
||||||
|
meaningful save with the placeholder fresh deal.
|
||||||
|
- **Home / mode picker auto-shows on launch** (`dd63261`). The mode
|
||||||
|
picker was only reachable via **M** during gameplay; players who
|
||||||
|
hadn't discovered the hotkey never saw the Daily / Zen / Challenge
|
||||||
|
/ Time Attack entry points after the splash cleared. `HomePlugin`
|
||||||
|
gains an `auto_show_on_launch` flag (default true) and a
|
||||||
|
one-shot `LaunchHomeShown` gate. Skips when the Restore prompt is
|
||||||
|
on screen so Welcome-back still takes precedence.
|
||||||
|
- **MSSC-style Home picker — header / chips / score chips / draw
|
||||||
|
mode** (`ae40a1d`). Player-stats header strip (Level / XP /
|
||||||
|
Lifetime Score, compact-formatted as `1.2M` / `12.3K` / `1,234`)
|
||||||
|
acts as a clickable shortcut to Profile. Draw-mode chip row above
|
||||||
|
the mode cards lets the player flip Draw 1 / Draw 3 from the
|
||||||
|
picker itself; persists `settings.json` and respawns the modal so
|
||||||
|
the active state repaints cleanly. Per-mode best-score / streak
|
||||||
|
chips on each card; hidden on a 0 best so a fresh profile doesn't
|
||||||
|
read "Best 0" everywhere.
|
||||||
|
- **Today's Event callout on the Daily card** (`b73d246`). "Today,
|
||||||
|
May 6" date line plus the server-fetched goal (when SyncPlugin is
|
||||||
|
wired). Once today's daily is recorded as completed, the date
|
||||||
|
flips to `Today, May 6 • Done` in `ACCENT_PRIMARY` so the picker
|
||||||
|
reads as a reward state rather than a TODO.
|
||||||
|
- **Picture-tile mode cards** (`9fe650f` + glyph-picking follow-ups
|
||||||
|
`40d6e0a`, `c30b04e`, `d065d49`). Mode cards become a wrapping
|
||||||
|
2-up grid (`FlexWrap::Wrap`, tiles 48 % wide, `min_height: 180px`)
|
||||||
|
with a centred Unicode-glyph centrepiece per tile. Final glyph set
|
||||||
|
picked from FiraMono-Medium's actual coverage: ♣ Classic, ◆ Daily,
|
||||||
|
○ Zen, ▲ Challenge, → TimeAttack. `ACCENT_PRIMARY` when the mode is
|
||||||
|
unlocked, `TEXT_DISABLED` when locked. Centrepiece is a `Text` node
|
||||||
|
for now — when real per-mode artwork lands, swap to `Image` without
|
||||||
|
touching tile layout, focus order, or chip rendering.
|
||||||
|
- **Solver-vetted seed selection on `AsyncComputeTaskPool`**
|
||||||
|
(`d489e7a`). Closes the worst-case 6 s UI stall on a New Game
|
||||||
|
click with "Winnable deals only" enabled. New `PendingNewGameSeed`
|
||||||
|
resource holds the in-flight `Task<u64>` plus the original
|
||||||
|
request's `mode` / `confirmed` flags. `poll_pending_new_game_seed`
|
||||||
|
runs `.before(GameMutation)` and replays a synthetic
|
||||||
|
`NewGameRequestEvent` once the task resolves — the player sees no
|
||||||
|
extra-frame visual lag. Cancel-on-replace: a fresh
|
||||||
|
`NewGameRequestEvent` while a task is in flight drops the old
|
||||||
|
task, letting Bevy's `Task` Drop cancel cooperatively at the next
|
||||||
|
await point.
|
||||||
|
- **"Won before" HUD indicator** (`bdac754`). When the current
|
||||||
|
deal's `(seed, draw_mode, mode)` triple matches an entry in the
|
||||||
|
rolling `ReplayHistory`, the HUD's tier-2 context row shows
|
||||||
|
**✓ Won before** in `STATE_SUCCESS`. Cleared on win (the on-screen
|
||||||
|
victory cue is enough) and on first-time deals. New
|
||||||
|
`HudWonPreviously` marker driven by a separate
|
||||||
|
`update_won_previously` system; gracefully no-ops in headless
|
||||||
|
tests that don't load `StatsPlugin`.
|
||||||
|
- **"Copy share link" Stats button** (`540869c`). End-to-end replay
|
||||||
|
sharing on a server-backed sync backend:
|
||||||
|
`sync_plugin::push_replay_on_win` spawns the upload on
|
||||||
|
`AsyncComputeTaskPool` and stores the handle in
|
||||||
|
`PendingReplayUpload` (drops any in-flight predecessor — the most
|
||||||
|
recent win is what the player wants the link for);
|
||||||
|
`poll_replay_upload_result` writes `<server>/replays/<id>` to
|
||||||
|
`LastSharedReplayUrl` on success; the Stats overlay's action bar
|
||||||
|
gains a button that writes the URL to the OS clipboard via
|
||||||
|
`arboard` and surfaces a "Copied: \<url\>" toast. URL is in-memory
|
||||||
|
only — sharing must happen within the session of the win.
|
||||||
|
- **Empty-state copy + onboarding hints** (`56e2e6f`). Leaderboard
|
||||||
|
empty state: two-tier "Be the first on the leaderboard." headline
|
||||||
|
+ body invite. Achievements panel: first-launch hint above the
|
||||||
|
grid until the first unlock. Volume hotkeys (`[` / `]`) now emit
|
||||||
|
an `InfoToastEvent` with the new percentage so off-panel
|
||||||
|
adjustments give visible feedback (previously silent).
|
||||||
|
- **Enter dismisses the Win Summary and starts a fresh deal**
|
||||||
|
(`17e0737`). The post-win modal's "Play Again" was click-only;
|
||||||
|
keyboard-only players had to reach for the mouse to leave the
|
||||||
|
celebration screen. The button label gains a trailing return-key
|
||||||
|
glyph so the keyboard path is discoverable on first sight.
|
||||||
|
- **`N` opens the real Confirm/Cancel modal** (`93660c2`). The old
|
||||||
|
"Press N again" double-tap pattern was a UI-first violation (only
|
||||||
|
continuation was another keystroke). `N` now fires
|
||||||
|
`NewGameRequestEvent::default()` directly; `handle_new_game`'s
|
||||||
|
active-game check spawns the existing `ConfirmNewGameScreen`. The
|
||||||
|
HUD button already routed through the same modal — keyboard and
|
||||||
|
mouse paths are unified. `Shift+N` keeps the keyboard power-user
|
||||||
|
bypass (`confirmed: true`).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Settings row layout** (`a4bc063`). All five
|
||||||
|
slider/toggle row helpers (volume × 2, tooltip delay, time-bonus
|
||||||
|
multiplier, replay-move interval, generic toggle) restructured to
|
||||||
|
a label-spacer-cluster layout (`width: 100%`, label gets
|
||||||
|
`flex-grow: 1`, controls cluster sits flush right). Stable across
|
||||||
|
varying value-text widths ("0.80" → "1.00", "Instant" vs "1.5 s")
|
||||||
|
and narrow windows.
|
||||||
|
- **Docs adopt the unified-3.0 rule set** (`f2f30c8`). `CLAUDE.md`
|
||||||
|
grows from a 114-line pointer doc to a 571-line rulebook (hard
|
||||||
|
global constraints §2, engine rules §3, asset rules §4, code
|
||||||
|
standards §5, build + verification §6, git workflow §7, the ASK
|
||||||
|
BEFORE list §8, Context Injection System §14). New companions:
|
||||||
|
`CLAUDE_SPEC.md` (formal architecture spec — crate dependency
|
||||||
|
graph, data ownership, state-machine invariants, sync merge /
|
||||||
|
server contracts, validation checklist),
|
||||||
|
`CLAUDE_WORKFLOW.md` (two-agent Builder/Guardian pipeline with
|
||||||
|
hard-fail patterns), `CLAUDE_PROMPT_PACK.md` (task-type
|
||||||
|
templates). Three duplicate rule passages removed across
|
||||||
|
`CLAUDE_SPEC.md` and `ARCHITECTURE.md`.
|
||||||
|
- **Test discipline pruning** (`a49a340`). Removed 43 low-value
|
||||||
|
tests across `solitaire_data` and `solitaire_core` (default-value
|
||||||
|
tests, serde-derive round-trips on plain structs, single-field
|
||||||
|
clamp tests, near-duplicates, constant-equals-itself tests). None
|
||||||
|
pinned a behaviour contract or a regression on a real bug. Future
|
||||||
|
agent briefs request tests for behaviour contracts or real-bug
|
||||||
|
regressions, not a count of N.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Esc on a modal no longer opens Pause underneath** (`08b006f`).
|
||||||
|
A single Esc press on Confirm New Game / Restore / Home /
|
||||||
|
Onboarding / Settings used to both close the modal and spawn the
|
||||||
|
Pause overlay on top in the same frame. `toggle_pause` now skips
|
||||||
|
when any non-Pause `ModalScrim` is in the world; the HUD-button
|
||||||
|
path is gated too. The four modal queries are bundled into a
|
||||||
|
`PauseModalQueries` `SystemParam` to stay under Bevy's
|
||||||
|
16-parameter cap.
|
||||||
|
- **Esc dismisses Home / accepts the Restore-prompt default**
|
||||||
|
(`d48b948`). Both screens previously ignored Esc, leaving the
|
||||||
|
player no keyboard-only escape after the previous fix. Home: Esc
|
||||||
|
behaves like Cancel (despawns the modal, keeps the underlying
|
||||||
|
default deal). Restore: Esc maps to Continue (preserves the saved
|
||||||
|
game, matching how the primary action already advertises Enter).
|
||||||
|
- **Esc dismisses the topmost modal when Profile stacks on Home**
|
||||||
|
(`9aa0dd2`). Clicking the Home header chip opens Profile on top
|
||||||
|
of Home; Esc used to close Home (because
|
||||||
|
`handle_home_cancel_button` fired with no awareness of layered
|
||||||
|
modals) and leave Profile orphaned over the game.
|
||||||
|
`profile_plugin` now splits P/button (toggle) from Esc
|
||||||
|
(close-only); `handle_home_cancel_button` skips its Esc branch
|
||||||
|
when any other `ModalScrim` exists.
|
||||||
|
- **Restore-prompt resolution suppresses Home auto-show**
|
||||||
|
(`b7c3a49`). Resolving the Welcome-back prompt cleared
|
||||||
|
`PendingRestoredGame` and despawned the modal, but the
|
||||||
|
launch-time Home auto-show then fired the next frame and stacked
|
||||||
|
itself over the player's chosen path. `LaunchHomeShown` becomes
|
||||||
|
`pub` so `handle_restore_prompt` flips it to `true` after either
|
||||||
|
resolution; **M** still re-opens the picker on demand.
|
||||||
|
- **Game timers freeze while the Home picker is up** (`c497c31`).
|
||||||
|
The HUD's elapsed-time counter ticked from the moment the default
|
||||||
|
Classic deal landed at startup, even though the auto-show Home
|
||||||
|
picker was still up — the player saw "0:11" before they had
|
||||||
|
chosen a mode. `tick_elapsed_time` and `advance_time_attack` now
|
||||||
|
also gate on the absence of `HomeScreen`, mirroring their
|
||||||
|
existing `PausedResource` check.
|
||||||
|
- **Popover rows stay visible regardless of action-bar fade**
|
||||||
|
(`cc63532`). Opening Modes / Menu showed a solid dark-purple
|
||||||
|
block in the top-right with no readable content — the action-bar
|
||||||
|
auto-fade was matching the popover rows by their shared
|
||||||
|
`ActionButton` marker and dropping their alpha to the
|
||||||
|
cursor-position-based fade value (typically 0). New `PopoverRow`
|
||||||
|
marker on rows in `spawn_modes_popover` / `spawn_menu_popover`;
|
||||||
|
`apply_action_fade` excludes them via `Without<PopoverRow>`.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- 1166 passing tests (was 1208 at v0.17.0 close — 43 net removals
|
||||||
|
from the test-discipline prune plus 1 net-new test from the
|
||||||
|
async-seed work, no behaviour regressions).
|
||||||
|
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||||
|
|
||||||
|
## [0.17.0] — 2026-05-06
|
||||||
|
|
||||||
|
A short follow-up round on top of v0.16.0: the H-key hint is no
|
||||||
|
longer a heuristic guess but the actual best first move suggested by
|
||||||
|
the v0.15.0 solver, and the in-engine replay player now has a
|
||||||
|
player-tunable playback rate.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Replay-rate slider** in Settings → Gameplay. Tunes
|
||||||
|
`replay_move_interval_secs` from 0.10 s to 1.00 s in 0.05 s steps;
|
||||||
|
default 0.45 s. `tick_replay_playback` reads the value from
|
||||||
|
`SettingsResource` per frame so the slider takes effect on the
|
||||||
|
next playback tick — no restart required.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Solver-driven hints.** Pressing **H** used to surface a
|
||||||
|
heuristic-best move (foundation moves preferred, then
|
||||||
|
tableau-to-tableau by depth-of-flip-revealed). It now asks the
|
||||||
|
v0.15.0 solver for the actual provably-best first move via the
|
||||||
|
new `solitaire_core::solver::try_solve_with_first_move` /
|
||||||
|
`try_solve_from_state` APIs. When the solver returns inconclusive
|
||||||
|
(rare deals where the bound runs out before a result), the old
|
||||||
|
heuristic remains the fallback. Median 2 ms per H press.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- 1208 passing tests (was 1196 at v0.16.0 close).
|
||||||
|
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||||
|
|
||||||
|
## [0.16.0] — 2026-05-06
|
||||||
|
|
||||||
|
A modal-feel polish round. Every overlay screen now scrolls when its
|
||||||
|
content overflows the 800×600 minimum window, every clickable button
|
||||||
|
shows a hand cursor on hover, keyboard focus lands on the primary
|
||||||
|
button on the same frame the modal opens, and read-only modals
|
||||||
|
dismiss when the player clicks the scrim outside the card.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Pointer cursor on hover** for every interactive `Button` entity
|
||||||
|
(modal buttons, HUD action bar, mode-launcher cards, settings
|
||||||
|
toggles, Stats selectors). `update_cursor_icon` gains a fourth
|
||||||
|
branch sitting between Grabbing (active drag) and Grab
|
||||||
|
(draggable card hover): when no drag is active and any
|
||||||
|
`Interaction::Hovered`/`Pressed` button is detected, the window
|
||||||
|
cursor swaps to `SystemCursorIcon::Pointer`. A pure
|
||||||
|
`pick_cursor_icon` helper makes the priority logic
|
||||||
|
unit-testable.
|
||||||
|
- **Click-outside-to-dismiss** for the six read-only modals: Stats,
|
||||||
|
Achievements, Help, Profile, Leaderboard, Home. New
|
||||||
|
`ScrimDismissible` marker on `ModalScrim` opts a modal in;
|
||||||
|
`dismiss_modal_on_scrim_click` runs in `Update`, despawns the
|
||||||
|
topmost dismissible scrim on a left-mouse press whose cursor
|
||||||
|
lands on the scrim and outside every `ModalCard`. Bevy's
|
||||||
|
hierarchy despawn cascades to the card and children.
|
||||||
|
Settings, Onboarding, Pause, Forfeit confirm, and Confirm New
|
||||||
|
Game intentionally don't opt in — they carry unsaved or
|
||||||
|
destructive state.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Modal content scrolls when it overflows** (Achievements, Help,
|
||||||
|
Stats, Profile, Leaderboard). Each modal's body Node now
|
||||||
|
carries `Overflow::scroll_y()` plus a `max_height` constraint
|
||||||
|
(`Val::Vh(70.0)` for most, `Val::Vh(50.0)` for the
|
||||||
|
leaderboard's variable-length ranking section) and a marker
|
||||||
|
component (`AchievementsScrollable`, `HelpScrollable`,
|
||||||
|
`StatsScrollable`, `ProfileScrollable`,
|
||||||
|
`LeaderboardScrollable`). A sibling `scroll_*_panel` system
|
||||||
|
per modal routes `MouseWheel` events into the body's
|
||||||
|
`ScrollPosition`. Mirrors the existing `SettingsPanelScrollable`
|
||||||
|
pattern. Home modal intentionally not scrolled — its five
|
||||||
|
mode cards + Cancel are sized to fit at 800×600 by design.
|
||||||
|
- **Modal focus arrives on the same frame the modal opens.**
|
||||||
|
Previously `attach_focusable_to_modal_buttons` and
|
||||||
|
`auto_focus_on_modal_open` ran in `Update` alongside arbitrary
|
||||||
|
click-handlers that spawn modals; with no ordering edge,
|
||||||
|
Bevy's deferred `Commands` queued the new entities but the
|
||||||
|
attach system couldn't see them on the same tick. Both systems
|
||||||
|
moved to `PostUpdate` so the schedule boundary itself supplies
|
||||||
|
the sync point — `FocusedButton` is always populated before
|
||||||
|
`app.update()` returns. The very next Tab/Enter press lands on
|
||||||
|
a populated resource instead of wasting itself moving focus
|
||||||
|
from None to the primary.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- 1196 passing tests (was 1178 at v0.15.0 close).
|
||||||
|
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||||
|
|
||||||
## [0.15.0] — 2026-05-02
|
## [0.15.0] — 2026-05-02
|
||||||
|
|
||||||
In-engine replay playback, the Klondike solver + "Winnable deals
|
In-engine replay playback, the Klondike solver + "Winnable deals
|
||||||
@@ -465,7 +749,8 @@ with no PNG artwork yet.
|
|||||||
CREDITS.md, persistent window geometry, mode-launcher Home repurpose,
|
CREDITS.md, persistent window geometry, mode-launcher Home repurpose,
|
||||||
client-side sync round-trip integration tests.
|
client-side sync round-trip integration tests.
|
||||||
|
|
||||||
[Unreleased]: https://github.com/funman300/Rusty_Solitaire/compare/v0.15.0...HEAD
|
[Unreleased]: https://github.com/funman300/Rusty_Solitaire/compare/v0.16.0...HEAD
|
||||||
|
[0.16.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.15.0...v0.16.0
|
||||||
[0.15.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.14.0...v0.15.0
|
[0.15.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.14.0...v0.15.0
|
||||||
[0.14.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.13.0...v0.14.0
|
[0.14.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.13.0...v0.14.0
|
||||||
[0.13.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.12.0...v0.13.0
|
[0.13.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.12.0...v0.13.0
|
||||||
|
|||||||
@@ -1,114 +1,571 @@
|
|||||||
# Solitaire Quest — Claude Code Instructions
|
# CLAUDE.md
|
||||||
|
|
||||||
See @ARCHITECTURE.md for full project design, crate responsibilities, data models, and API reference.
|
version: unified-3.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project Layout
|
# 0. Role of This File
|
||||||
|
|
||||||
```text
|
This document defines:
|
||||||
solitaire_core/ # Pure Rust game logic — NO Bevy, NO network, NO I/O
|
|
||||||
solitaire_sync/ # Shared API types — NO Bevy, serde/uuid/chrono only
|
* **Execution rules (what Claude must do)**
|
||||||
solitaire_data/ # Persistence + SyncProvider trait + server client
|
* **System constraints (what Claude must never violate)**
|
||||||
solitaire_engine/ # Bevy ECS systems, components, plugins
|
* **Operational architecture (how code is structured)**
|
||||||
solitaire_server/ # Axum sync server binary
|
|
||||||
solitaire_app/ # Thin binary entry point
|
For full system design details:
|
||||||
assets/ # Source assets — embedded at compile time via include_bytes!()
|
→ `ARCHITECTURE.md` (authoritative source of truth)
|
||||||
|
|
||||||
|
This file overrides all conversational assumptions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. System Architecture (Authoritative Mapping)
|
||||||
|
|
||||||
|
## 1.1 Crates
|
||||||
|
|
||||||
|
```text id="crate_map"
|
||||||
|
solitaire_core/ # PURE logic (no IO, no Bevy, deterministic)
|
||||||
|
solitaire_sync/ # Shared API + merge logic
|
||||||
|
solitaire_data/ # Persistence + sync client
|
||||||
|
solitaire_engine/ # Bevy ECS + UI + gameplay orchestration
|
||||||
|
solitaire_server/ # Axum backend (optional sync layer)
|
||||||
|
solitaire_app/ # Entry binary
|
||||||
|
assets/ # Runtime assets (except audio)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Build & Test Commands
|
## 1.2 Architecture Source of Truth
|
||||||
|
|
||||||
```bash
|
* Full system design: `ARCHITECTURE.md`
|
||||||
# Dev run (fast compile via dynamic linking)
|
* This file NEVER redefines system design
|
||||||
cargo run -p solitaire_app --features bevy/dynamic_linking
|
* This file ONLY enforces behavior
|
||||||
|
|
||||||
# Release build
|
---
|
||||||
cargo build --workspace --release
|
|
||||||
|
|
||||||
# All tests — MUST pass before any commit
|
# 2. Hard Global Constraints (NON-NEGOTIABLE)
|
||||||
|
|
||||||
|
These override all other instructions.
|
||||||
|
|
||||||
|
## 2.1 Core Determinism
|
||||||
|
|
||||||
|
* `solitaire_core` MUST:
|
||||||
|
|
||||||
|
* be deterministic
|
||||||
|
* be side-effect free
|
||||||
|
* never depend on Bevy / IO / async
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2.2 Sync Isolation
|
||||||
|
|
||||||
|
* `solitaire_sync`:
|
||||||
|
|
||||||
|
* no Bevy
|
||||||
|
* no IO
|
||||||
|
* no engine dependencies
|
||||||
|
* merge logic must be pure functions only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2.3 Error Policy
|
||||||
|
|
||||||
|
* NO `unwrap()`
|
||||||
|
* NO `panic!()` in runtime/game logic
|
||||||
|
* All state transitions:
|
||||||
|
|
||||||
|
```rust id="err_model"
|
||||||
|
Result<T, MoveError>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2.4 Threading Rules
|
||||||
|
|
||||||
|
* Sync must run on `AsyncComputeTaskPool`
|
||||||
|
* NEVER block Bevy main thread
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2.5 Persistence Rules
|
||||||
|
|
||||||
|
* atomic writes only:
|
||||||
|
|
||||||
|
* write `.tmp`
|
||||||
|
* rename atomically
|
||||||
|
* no partial state writes allowed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2.6 Security Rules
|
||||||
|
|
||||||
|
* credentials ONLY via `keyring`
|
||||||
|
* NEVER store secrets in:
|
||||||
|
|
||||||
|
* files
|
||||||
|
* logs
|
||||||
|
* source code
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2.7 Sync System Rules
|
||||||
|
|
||||||
|
* All sync backends implement:
|
||||||
|
|
||||||
|
```rust id="sync_trait"
|
||||||
|
trait SyncProvider
|
||||||
|
```
|
||||||
|
|
||||||
|
* `SyncPlugin` MUST be backend-agnostic
|
||||||
|
* NEVER match on backend inside ECS systems
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. Engine Rules (Bevy Layer)
|
||||||
|
|
||||||
|
## 3.1 ECS Design
|
||||||
|
|
||||||
|
* systems = single responsibility
|
||||||
|
* communication = Events only
|
||||||
|
* shared state = Resources only
|
||||||
|
* per-entity state = Components only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3.2 Game State Authority
|
||||||
|
|
||||||
|
* ONLY `GameStateResource` can mutate game state
|
||||||
|
* UI systems MUST NOT directly modify core logic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3.3 UI-First Constraint (CRITICAL)
|
||||||
|
|
||||||
|
Every player action MUST:
|
||||||
|
|
||||||
|
* have a visible UI control
|
||||||
|
* NOT rely solely on keyboard shortcuts
|
||||||
|
|
||||||
|
Keyboard shortcuts are:
|
||||||
|
→ optional accelerators only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3.4 Layout System
|
||||||
|
|
||||||
|
* recompute on `WindowResized`
|
||||||
|
* no fixed resolution assumptions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. Asset System Rules
|
||||||
|
|
||||||
|
## 4.1 Runtime Assets (AssetServer)
|
||||||
|
|
||||||
|
Loaded via:
|
||||||
|
|
||||||
|
* `CardImageSet`
|
||||||
|
* `BackgroundImageSet`
|
||||||
|
* `FontResource`
|
||||||
|
|
||||||
|
Includes:
|
||||||
|
|
||||||
|
* cards
|
||||||
|
* backgrounds
|
||||||
|
* fonts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.2 Embedded Assets
|
||||||
|
|
||||||
|
Only audio:
|
||||||
|
|
||||||
|
```text id="audio_rule"
|
||||||
|
include_bytes!()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.3 Test Compatibility Rule
|
||||||
|
|
||||||
|
All asset loaders MUST accept:
|
||||||
|
|
||||||
|
```rust id="asset_fallback"
|
||||||
|
Option<Res<AssetServer>>
|
||||||
|
```
|
||||||
|
|
||||||
|
Must degrade gracefully under `MinimalPlugins`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. Code Standards
|
||||||
|
|
||||||
|
## 5.1 Error Handling
|
||||||
|
|
||||||
|
* use `thiserror`
|
||||||
|
* no `Box<dyn Error>` in libraries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.2 Public API Rules
|
||||||
|
|
||||||
|
* prefer `Into<T>` over concrete types
|
||||||
|
* all public items require doc comments
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.3 Derive Order
|
||||||
|
|
||||||
|
```rust id="derive_order"
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.4 Performance Rules
|
||||||
|
|
||||||
|
* NO `clone()` in hot paths
|
||||||
|
* profile before optimizing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.5 SQL Rules
|
||||||
|
|
||||||
|
* ONLY `sqlx::query!`
|
||||||
|
* NO raw SQL strings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. Build & Verification Rules
|
||||||
|
|
||||||
|
These are mandatory before ANY commit.
|
||||||
|
|
||||||
|
```bash id="build_rules"
|
||||||
cargo test --workspace
|
cargo test --workspace
|
||||||
|
|
||||||
# Lint — MUST pass clean (zero warnings)
|
|
||||||
cargo clippy --workspace -- -D warnings
|
cargo clippy --workspace -- -D warnings
|
||||||
|
|
||||||
# Run sync server locally
|
|
||||||
cargo run -p solitaire_server
|
|
||||||
|
|
||||||
# Check a single crate
|
|
||||||
cargo test -p solitaire_core
|
|
||||||
cargo clippy -p solitaire_core -- -D warnings
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Hard Rules
|
# 7. Git Workflow Rules
|
||||||
|
|
||||||
- `solitaire_core` and `solitaire_sync` must never gain Bevy or network dependencies.
|
## Commit format
|
||||||
- No `unwrap()` or `panic!()` in game logic. All state transitions return `Result<_, MoveError>`.
|
|
||||||
- Audio assets are embedded at compile time using `include_bytes!()` in `audio_plugin.rs`.
|
```text id="commit_fmt"
|
||||||
- Card faces (52 PNGs in `assets/cards/faces/`), card backs (`assets/cards/backs/back_N.png`), board backgrounds (`assets/backgrounds/bg_N.png`), and the UI font (`assets/fonts/main.ttf`) are loaded at runtime via `AssetServer::load()` and stored as `Handle<Image>`/`Handle<Font>` in the `CardImageSet`, `BackgroundImageSet`, and `FontResource` resources. The `assets/` directory must ship alongside the binary.
|
type(scope): description
|
||||||
- Asset-loading systems take `Option<Res<AssetServer>>` so they degrade cleanly under `MinimalPlugins` (tests). When `CardImageSet` is absent, `card_plugin` falls back to a `Text2d` rank+suit overlay; when `BackgroundImageSet` is absent, the board falls back to a solid colour.
|
```
|
||||||
- Atomic file writes only: write to `filename.json.tmp`, then `rename()`.
|
|
||||||
- Passwords and tokens are stored in the OS keychain via the `keyring` crate — never in plaintext files or logs.
|
Examples:
|
||||||
- Sync runs on `AsyncComputeTaskPool` — never block the Bevy main thread.
|
|
||||||
- All sync backends implement the `SyncProvider` trait. The `SyncPlugin` is backend-agnostic — never `match` on `SyncBackend` inside a Bevy system.
|
* feat(core): add draw-three rules
|
||||||
- `cargo clippy --workspace -- -D warnings` must pass clean after every change.
|
* fix(engine): correct drag z-order
|
||||||
- `cargo test --workspace` must pass after every change.
|
* test(core): undo boundary cases
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Code Style
|
## Commit conditions
|
||||||
|
|
||||||
- Use `thiserror` for error types. Never `Box<dyn Error>` in library crates.
|
* tests must pass
|
||||||
- Prefer `Into<T>` over concrete types in public API function parameters.
|
* clippy must be clean
|
||||||
- All public items must have doc comments (`///`). Private items: comment only when non-obvious.
|
|
||||||
- Derive order convention: `#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]`
|
NEVER commit otherwise
|
||||||
- Bevy systems: one responsibility per system. Use `Events` for cross-system communication, never shared mutable state.
|
|
||||||
- SQL queries: use `sqlx::query!` macros (compile-time checked), not raw string queries.
|
|
||||||
- No `clone()` calls in hot paths (game loop systems). Profile before optimising elsewhere.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Bevy Conventions
|
# 8. Change Control (ASK BEFORE DOING)
|
||||||
|
|
||||||
- One `Plugin` per major feature: `CardPlugin`, `AudioPlugin`, `AchievementPlugin`, `UIPlugin`, `SyncPlugin`.
|
Claude must request confirmation before:
|
||||||
- Resources own shared state. Events communicate between systems. Components own per-entity data.
|
|
||||||
- All UI screens are built with Bevy UI (`bevy::ui`). Never mix UI layout and game logic in the same system.
|
* adding dependencies
|
||||||
- Layout is recomputed on `WindowResized` — never assume a fixed window size.
|
* modifying `solitaire_sync`
|
||||||
- **UI-first.** Every player-triggered action (new game, undo, draw, pause, open stats / settings / help / profile / leaderboard, switch mode, etc.) must be reachable from a visible UI control. Keyboard shortcuts are optional accelerators — never the sole entry point. New gameplay features ship with the UI control alongside the system that backs it; do not merge a feature that is keyboard-only.
|
* changing DB schema
|
||||||
|
* introducing `unsafe`
|
||||||
|
* changing merge strategy
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Git Workflow
|
# 9. System Mental Model (IMPORTANT)
|
||||||
|
|
||||||
- Commit after each passing phase, not after every file change.
|
```text id="mental_model"
|
||||||
- Commit message format: `type(scope): description`
|
Core (rules + deterministic logic)
|
||||||
- `feat(core): add draw-three mode validation`
|
↓
|
||||||
- `fix(engine): card z-order during drag`
|
Engine (Bevy orchestration)
|
||||||
- `test(core): undo stack boundary conditions`
|
↓
|
||||||
- `chore(server): add sqlx migration 002`
|
Data layer (persistence + sync)
|
||||||
- Never commit with failing tests or clippy warnings.
|
↓
|
||||||
- Never commit secrets, `.env` files, or `*.db` files.
|
Server (optional external system)
|
||||||
|
```
|
||||||
|
|
||||||
|
Core is always the source of truth.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Ask Before Doing
|
# 10. Known Platform Pitfalls
|
||||||
|
|
||||||
- Adding a new crate dependency (discuss alternatives first).
|
Must always be handled explicitly:
|
||||||
- Changing a type in `solitaire_sync` (breaking change on both client and server).
|
|
||||||
- Altering the database schema (requires a new sqlx migration).
|
* Bevy `Time` uses `f32`
|
||||||
- Introducing `unsafe` code anywhere.
|
* `sqlx::migrate!()` path is crate-relative
|
||||||
- Changing the merge strategy in `solitaire_sync::merge()`.
|
* `dirs::data_dir()` may return `None`
|
||||||
|
* Linux may lack keyring backend
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Lessons Learned
|
# 11. Forbidden Patterns
|
||||||
|
|
||||||
> Add entries here when Claude makes a mistake so it isn't repeated.
|
* game logic inside Bevy systems
|
||||||
|
* duplication across crates
|
||||||
|
* blocking async calls in ECS
|
||||||
|
* insecure credential storage
|
||||||
|
* bypassing core logic layer
|
||||||
|
|
||||||
- Bevy's `Time` resource uses `f32` seconds; convert to `u64` only when writing to `StatsSnapshot`.
|
---
|
||||||
- `sqlx::migrate!()` macro path is relative to the crate root, not the workspace root.
|
|
||||||
- `keyring` on Linux requires a running secret service (e.g. GNOME Keyring or KWallet) — handle `Error::NoStorageAccess` gracefully and fall back to prompting the user.
|
# 12. Execution Rules for Claude
|
||||||
- `dirs::data_dir()` returns `None` on some minimal Linux environments — always handle the `None` case explicitly, do not unwrap.
|
|
||||||
|
When generating code:
|
||||||
|
|
||||||
|
1. respect crate boundaries
|
||||||
|
2. minimize diff size
|
||||||
|
3. do not expand scope
|
||||||
|
4. follow existing patterns
|
||||||
|
5. preserve invariants
|
||||||
|
|
||||||
|
If unclear:
|
||||||
|
→ ask before acting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 13. Relationship to ARCHITECTURE.md
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
| --------------- | ------------------------- |
|
||||||
|
| CLAUDE.md | execution + constraints |
|
||||||
|
| ARCHITECTURE.md | system design truth |
|
||||||
|
| Both combined | full system understanding |
|
||||||
|
|
||||||
|
---
|
||||||
|
# 14. Context Injection System (AUTOMATIC SCOPE FILTER)
|
||||||
|
|
||||||
|
## 14.1 Purpose
|
||||||
|
|
||||||
|
Before generating any response, Claude MUST construct a **minimal relevant context set**.
|
||||||
|
|
||||||
|
This prevents:
|
||||||
|
|
||||||
|
* architectural drift
|
||||||
|
* irrelevant spec loading
|
||||||
|
* over-engineering
|
||||||
|
* cross-crate confusion
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14.2 Input Classification Step (MANDATORY)
|
||||||
|
|
||||||
|
Every request MUST be classified into exactly one task type:
|
||||||
|
|
||||||
|
```text id="task_types"
|
||||||
|
feature
|
||||||
|
bugfix
|
||||||
|
refactor
|
||||||
|
system_design
|
||||||
|
bevy_system
|
||||||
|
core_logic
|
||||||
|
sync
|
||||||
|
optimization
|
||||||
|
test
|
||||||
|
debug
|
||||||
|
```
|
||||||
|
|
||||||
|
If uncertain → ask clarification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14.3 Context Selection Engine
|
||||||
|
|
||||||
|
After classification, Claude MUST include ONLY the relevant sections below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14.4 Context Map (CORE RULESET)
|
||||||
|
|
||||||
|
### feature
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
* §2 Hard Global Constraints
|
||||||
|
* §3 Engine Rules
|
||||||
|
* ARCHITECTURE.md (crate of target feature only)
|
||||||
|
* relevant data models (GameState, SyncPayload if needed)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### bugfix
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
* §2 Hard Global Constraints
|
||||||
|
* §5 Code Standards
|
||||||
|
* affected crate boundaries
|
||||||
|
* relevant system (engine/core/sync only)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### refactor
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
* §3 Engine Rules
|
||||||
|
* §5 Code Standards
|
||||||
|
* §11 Forbidden Patterns
|
||||||
|
* target crate boundaries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### system_design
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
* ARCHITECTURE.md (FULL)
|
||||||
|
* §9 Mental Model
|
||||||
|
* §1 System Architecture Mapping
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### core_logic
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
* solitaire_core rules only
|
||||||
|
* GameState model
|
||||||
|
* MoveError model
|
||||||
|
* §2.1–2.3 constraints
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### bevy_system
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
* §3 Engine Rules
|
||||||
|
* ECS rules (Events/Resources/Components)
|
||||||
|
* UI-first constraint
|
||||||
|
* relevant plugin system only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### sync
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
* SyncProvider trait
|
||||||
|
* merge strategy rules
|
||||||
|
* solitaire_sync models
|
||||||
|
* §2.6 Sync Rules
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### optimization
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
* target crate only
|
||||||
|
* §5.4 Performance Rules
|
||||||
|
* hot path constraints
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### test
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
* §6 Build Rules
|
||||||
|
* relevant module
|
||||||
|
* expected invariants
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### debug
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
* target file/module only
|
||||||
|
* §2.3 Error Policy
|
||||||
|
* runtime assumptions relevant to failure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14.5 Context Compression Rules
|
||||||
|
|
||||||
|
Claude MUST obey:
|
||||||
|
|
||||||
|
* never include full ARCHITECTURE.md unless system_design
|
||||||
|
* max 2 crates per response unless explicitly required
|
||||||
|
* prefer function-level context over file-level context
|
||||||
|
* exclude unrelated plugins/systems
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14.6 Context Priority Order
|
||||||
|
|
||||||
|
When space is limited:
|
||||||
|
|
||||||
|
1. Hard Constraints (§2)
|
||||||
|
2. Target crate rules
|
||||||
|
3. Data models
|
||||||
|
4. Only then: architecture snippets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14.7 “No Context Pollution” Rule
|
||||||
|
|
||||||
|
Claude must NOT include:
|
||||||
|
|
||||||
|
* unrelated crates
|
||||||
|
* unrelated plugins
|
||||||
|
* unused data models
|
||||||
|
* full architecture dumps
|
||||||
|
* speculative systems
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14.8 Self-Check Before Execution
|
||||||
|
|
||||||
|
Before writing code, Claude MUST verify:
|
||||||
|
|
||||||
|
* [ ] Is only relevant context included?
|
||||||
|
* [ ] Is at least one hard constraint present?
|
||||||
|
* [ ] Am I touching more than one crate unnecessarily?
|
||||||
|
* [ ] Am I duplicating ARCHITECTURE.md content?
|
||||||
|
|
||||||
|
If any fail → revise context selection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14.9 Injection Output Format (Internal Model)
|
||||||
|
|
||||||
|
Claude should behave as if it constructed:
|
||||||
|
|
||||||
|
```text id="ctx_format"
|
||||||
|
[SELECTED TASK TYPE]
|
||||||
|
|
||||||
|
[MINIMAL REQUIRED RULES]
|
||||||
|
|
||||||
|
[MINIMAL ARCHITECTURE SLICES]
|
||||||
|
|
||||||
|
[RELEVANT MODELS]
|
||||||
|
|
||||||
|
[REQUEST]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14.10 Relationship to ARCHITECTURE.md
|
||||||
|
|
||||||
|
* ARCHITECTURE.md = source of truth
|
||||||
|
* CLAUDE.md = execution constraints
|
||||||
|
* THIS SECTION = filtering layer between them
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# END CONTEXT INJECTION SYSTEM
|
||||||
|
|||||||
@@ -0,0 +1,497 @@
|
|||||||
|
# CLAUDE_PROMPT_PACK.md
|
||||||
|
|
||||||
|
version: 1.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0. GLOBAL INSTRUCTION (prepend to every prompt)
|
||||||
|
|
||||||
|
```
|
||||||
|
You must follow CLAUDE_SPEC.md strictly.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Do not expand scope beyond what is defined
|
||||||
|
- Do not refactor unrelated code
|
||||||
|
- Do not introduce new dependencies
|
||||||
|
- Prefer minimal, surgical changes
|
||||||
|
- Use existing patterns in the codebase
|
||||||
|
- Return minimal diffs or changed functions only
|
||||||
|
|
||||||
|
Before writing code:
|
||||||
|
1. List relevant constraints from CLAUDE_SPEC.md
|
||||||
|
2. Identify risks
|
||||||
|
3. Then implement
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. FEATURE IMPLEMENTATION
|
||||||
|
|
||||||
|
```
|
||||||
|
# TASK: Feature Implementation
|
||||||
|
|
||||||
|
feature: "<name>"
|
||||||
|
|
||||||
|
goal:
|
||||||
|
"<clear outcome>"
|
||||||
|
|
||||||
|
scope:
|
||||||
|
crates: []
|
||||||
|
systems: []
|
||||||
|
files: []
|
||||||
|
|
||||||
|
non_goals:
|
||||||
|
- ""
|
||||||
|
|
||||||
|
constraints:
|
||||||
|
- must follow CLAUDE_SPEC.md
|
||||||
|
- event-driven architecture required
|
||||||
|
- no blocking operations
|
||||||
|
- no cross-crate leakage
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- ""
|
||||||
|
- ""
|
||||||
|
|
||||||
|
edge_cases:
|
||||||
|
- ""
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Patterns
|
||||||
|
|
||||||
|
Use this pattern for systems:
|
||||||
|
<PASTE EXISTING SYSTEM SNIPPET HERE>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
intent:
|
||||||
|
plan:
|
||||||
|
constraints_used:
|
||||||
|
risks:
|
||||||
|
|
||||||
|
code_changes:
|
||||||
|
(minimal diffs only)
|
||||||
|
|
||||||
|
notes:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. BUGFIX
|
||||||
|
|
||||||
|
```
|
||||||
|
# TASK: Bug Fix
|
||||||
|
|
||||||
|
bug_description:
|
||||||
|
"<what is broken>"
|
||||||
|
|
||||||
|
expected_behavior:
|
||||||
|
"<correct behavior>"
|
||||||
|
|
||||||
|
root_cause_hint (optional):
|
||||||
|
""
|
||||||
|
|
||||||
|
scope:
|
||||||
|
crates: []
|
||||||
|
files: []
|
||||||
|
|
||||||
|
constraints:
|
||||||
|
- minimal fix only
|
||||||
|
- no refactors unless required
|
||||||
|
- must add regression protection if applicable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
1. Identify root cause
|
||||||
|
2. Fix it minimally
|
||||||
|
3. Preserve all invariants
|
||||||
|
4. Do not change unrelated logic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
analysis:
|
||||||
|
root_cause:
|
||||||
|
fix_strategy:
|
||||||
|
|
||||||
|
code_changes:
|
||||||
|
(minimal diff)
|
||||||
|
|
||||||
|
regression_test (only if high-value):
|
||||||
|
|
||||||
|
notes:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. REFACTOR
|
||||||
|
|
||||||
|
```
|
||||||
|
# TASK: Refactor
|
||||||
|
|
||||||
|
target:
|
||||||
|
"<what is being improved>"
|
||||||
|
|
||||||
|
goal:
|
||||||
|
"<what improves>"
|
||||||
|
|
||||||
|
scope:
|
||||||
|
crates: []
|
||||||
|
files: []
|
||||||
|
|
||||||
|
non_goals:
|
||||||
|
- no behavior changes
|
||||||
|
- no new features
|
||||||
|
|
||||||
|
constraints:
|
||||||
|
- must preserve behavior exactly
|
||||||
|
- must respect crate boundaries
|
||||||
|
- must not duplicate logic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refactor Type
|
||||||
|
|
||||||
|
- [ ] simplify logic
|
||||||
|
- [ ] reduce duplication
|
||||||
|
- [ ] improve readability
|
||||||
|
- [ ] performance (non-invasive)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
analysis:
|
||||||
|
issues_found:
|
||||||
|
|
||||||
|
refactor_plan:
|
||||||
|
|
||||||
|
code_changes:
|
||||||
|
(diff only)
|
||||||
|
|
||||||
|
verification:
|
||||||
|
- behavior unchanged: yes/no
|
||||||
|
- invariants preserved: yes/no
|
||||||
|
|
||||||
|
notes:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. SYSTEM DESIGN (NEW FEATURE)
|
||||||
|
|
||||||
|
```
|
||||||
|
# TASK: System Design
|
||||||
|
|
||||||
|
feature:
|
||||||
|
"<name>"
|
||||||
|
|
||||||
|
goal:
|
||||||
|
"<what problem it solves>"
|
||||||
|
|
||||||
|
constraints:
|
||||||
|
- must fit existing architecture
|
||||||
|
- must follow plugin + event model
|
||||||
|
- must not violate crate boundaries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Output
|
||||||
|
|
||||||
|
design:
|
||||||
|
|
||||||
|
components:
|
||||||
|
- plugins:
|
||||||
|
- systems:
|
||||||
|
- events:
|
||||||
|
- resources:
|
||||||
|
|
||||||
|
data_flow:
|
||||||
|
(step-by-step)
|
||||||
|
|
||||||
|
integration_points:
|
||||||
|
- where it connects to existing systems
|
||||||
|
|
||||||
|
risks:
|
||||||
|
- ""
|
||||||
|
|
||||||
|
tradeoffs:
|
||||||
|
- ""
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DO NOT
|
||||||
|
|
||||||
|
- write full implementation
|
||||||
|
- modify unrelated systems
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. NEW BEVY SYSTEM
|
||||||
|
|
||||||
|
```
|
||||||
|
# TASK: Add Bevy System
|
||||||
|
|
||||||
|
system_name:
|
||||||
|
""
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
(event or condition)
|
||||||
|
|
||||||
|
reads:
|
||||||
|
[Resources]
|
||||||
|
|
||||||
|
writes:
|
||||||
|
[Resources]
|
||||||
|
|
||||||
|
emits:
|
||||||
|
[Events]
|
||||||
|
|
||||||
|
constraints:
|
||||||
|
- must be event-driven
|
||||||
|
- must not directly mutate unrelated state
|
||||||
|
- must be single responsibility
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
system_signature:
|
||||||
|
|
||||||
|
implementation:
|
||||||
|
(code only)
|
||||||
|
|
||||||
|
notes:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. CORE LOGIC FUNCTION (solitaire_core)
|
||||||
|
|
||||||
|
```
|
||||||
|
# TASK: Core Logic Implementation
|
||||||
|
|
||||||
|
function:
|
||||||
|
"<name>"
|
||||||
|
|
||||||
|
goal:
|
||||||
|
"<what it does>"
|
||||||
|
|
||||||
|
rules:
|
||||||
|
- no IO
|
||||||
|
- no async
|
||||||
|
- no Bevy
|
||||||
|
- deterministic
|
||||||
|
|
||||||
|
invariants:
|
||||||
|
- ""
|
||||||
|
- ""
|
||||||
|
|
||||||
|
errors:
|
||||||
|
- ""
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
constraints_checked:
|
||||||
|
|
||||||
|
implementation:
|
||||||
|
(code only)
|
||||||
|
|
||||||
|
edge_case_handling:
|
||||||
|
|
||||||
|
notes:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 7. SYNC / MERGE LOGIC
|
||||||
|
|
||||||
|
```
|
||||||
|
# TASK: Sync Logic
|
||||||
|
|
||||||
|
goal:
|
||||||
|
"<what is being merged or synced>"
|
||||||
|
|
||||||
|
constraints:
|
||||||
|
- must be deterministic
|
||||||
|
- must be idempotent
|
||||||
|
- must be lossless
|
||||||
|
- must not delete data
|
||||||
|
|
||||||
|
rules:
|
||||||
|
- counters → max
|
||||||
|
- times → min
|
||||||
|
- collections → union
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
analysis:
|
||||||
|
|
||||||
|
merge_logic:
|
||||||
|
|
||||||
|
code_changes:
|
||||||
|
|
||||||
|
invariants_verified:
|
||||||
|
- deterministic
|
||||||
|
- idempotent
|
||||||
|
- lossless
|
||||||
|
|
||||||
|
notes:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 8. PERFORMANCE OPTIMIZATION
|
||||||
|
|
||||||
|
```
|
||||||
|
# TASK: Optimization
|
||||||
|
|
||||||
|
target:
|
||||||
|
"<what is slow>"
|
||||||
|
|
||||||
|
constraints:CLAUDE_WORKFLOW.md
|
||||||
|
- no behavior change
|
||||||
|
- no architecture change
|
||||||
|
- minimal code changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
analysis:
|
||||||
|
bottleneck:
|
||||||
|
|
||||||
|
optimization_strategy:
|
||||||
|
|
||||||
|
code_changes:
|
||||||
|
|
||||||
|
impact_estimate:
|
||||||
|
|
||||||
|
notes:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 9. TEST GENERATION (STRICT MODE)
|
||||||
|
|
||||||
|
```
|
||||||
|
# TASK: Test Generation
|
||||||
|
|
||||||
|
target:
|
||||||
|
"<function/system>"
|
||||||
|
|
||||||
|
reason:
|
||||||
|
- bugfix | complex logic | invariant protection
|
||||||
|
|
||||||
|
constraints:
|
||||||
|
- no redundant tests
|
||||||
|
- must test real behavior
|
||||||
|
- must fail if logic breaks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
test_cases:
|
||||||
|
- ""
|
||||||
|
|
||||||
|
test_code:
|
||||||
|
|
||||||
|
notes:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 10. DEBUGGING / INVESTIGATION
|
||||||
|
|
||||||
|
```
|
||||||
|
# TASK: Debug
|
||||||
|
|
||||||
|
problem:
|
||||||
|
"<symptom>"
|
||||||
|
|
||||||
|
context:
|
||||||
|
"<relevant code or system>"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Steps
|
||||||
|
|
||||||
|
1. List possible causes
|
||||||
|
2. Narrow down most likely
|
||||||
|
3. Suggest verification steps
|
||||||
|
4. Provide minimal fix
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
hypotheses:
|
||||||
|
|
||||||
|
most_likely:
|
||||||
|
|
||||||
|
verification_steps:
|
||||||
|
|
||||||
|
fix:
|
||||||
|
|
||||||
|
notes:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 11. HARD CONSTRAINT OVERRIDE (RARE)
|
||||||
|
|
||||||
|
```
|
||||||
|
# TASK: Exception Handling
|
||||||
|
|
||||||
|
reason:
|
||||||
|
"<why constraints must be bent>"
|
||||||
|
|
||||||
|
requested_exception:
|
||||||
|
"<rule being broken>"
|
||||||
|
|
||||||
|
justification:
|
||||||
|
"<why unavoidable>"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
analysis:
|
||||||
|
|
||||||
|
alternatives_considered:
|
||||||
|
|
||||||
|
final_decision:
|
||||||
|
|
||||||
|
risk:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 12. STOP CONDITIONS (always append)
|
||||||
|
|
||||||
|
```
|
||||||
|
Stop when:
|
||||||
|
- acceptance criteria are met
|
||||||
|
- code is minimal and correct
|
||||||
|
|
||||||
|
Do NOT:
|
||||||
|
- expand scope
|
||||||
|
- refactor unrelated code
|
||||||
|
- optimize prematurely
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# END
|
||||||
+292
@@ -0,0 +1,292 @@
|
|||||||
|
# CLAUDE_SPEC.md
|
||||||
|
|
||||||
|
version: 1.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Global Rules
|
||||||
|
|
||||||
|
(Core determinism, panic policy, and event-driven engine constraints live in CLAUDE.md §2.1, §2.3, §3.1. Listed here only when they add information CLAUDE.md doesn't carry.)
|
||||||
|
|
||||||
|
rules:
|
||||||
|
|
||||||
|
* id: single_source_of_truth
|
||||||
|
description: "GameStateResource is the only mutable game state in runtime"
|
||||||
|
|
||||||
|
* id: sync_is_additive
|
||||||
|
description: "Remote data must never destructively overwrite local data"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Crate Graph
|
||||||
|
|
||||||
|
crates:
|
||||||
|
solitaire_core:
|
||||||
|
depends_on: [rand, serde, chrono]
|
||||||
|
forbidden_deps: [bevy, reqwest, tokio, std::fs]
|
||||||
|
|
||||||
|
solitaire_sync:
|
||||||
|
depends_on: [serde, serde_json, uuid, chrono]
|
||||||
|
role: "shared_types"
|
||||||
|
|
||||||
|
solitaire_data:
|
||||||
|
depends_on: [solitaire_core, solitaire_sync, reqwest, tokio, keyring]
|
||||||
|
role: "persistence_and_sync"
|
||||||
|
|
||||||
|
solitaire_engine:
|
||||||
|
depends_on: [bevy, kira, solitaire_core, solitaire_data]
|
||||||
|
role: "runtime_engine"
|
||||||
|
|
||||||
|
solitaire_server:
|
||||||
|
depends_on: [solitaire_sync, axum, sqlx, jsonwebtoken]
|
||||||
|
role: "backend"
|
||||||
|
|
||||||
|
solitaire_app:
|
||||||
|
depends_on: [solitaire_engine]
|
||||||
|
role: "entrypoint"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Data Ownership
|
||||||
|
|
||||||
|
ownership:
|
||||||
|
GameState:
|
||||||
|
owner: solitaire_core
|
||||||
|
mutable_in: solitaire_engine
|
||||||
|
access_pattern: "via GameStateResource only"
|
||||||
|
|
||||||
|
StatsSnapshot:
|
||||||
|
owner: solitaire_data
|
||||||
|
|
||||||
|
PlayerProgress:
|
||||||
|
owner: solitaire_data
|
||||||
|
|
||||||
|
AchievementRecord:
|
||||||
|
owner: solitaire_data
|
||||||
|
|
||||||
|
SyncPayload:
|
||||||
|
owner: solitaire_sync
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. State Transitions
|
||||||
|
|
||||||
|
state_machine:
|
||||||
|
GameState:
|
||||||
|
transitions:
|
||||||
|
- action: move_cards
|
||||||
|
returns: Result<GameState, MoveError>
|
||||||
|
|
||||||
|
```
|
||||||
|
- action: draw
|
||||||
|
returns: Result<GameState, MoveError>
|
||||||
|
|
||||||
|
- action: undo
|
||||||
|
returns: Result<GameState, MoveError>
|
||||||
|
|
||||||
|
invariants:
|
||||||
|
- "52 cards always exist"
|
||||||
|
- "no duplicate card IDs"
|
||||||
|
- "all cards belong to exactly one pile"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Event System
|
||||||
|
|
||||||
|
events:
|
||||||
|
|
||||||
|
input:
|
||||||
|
- MoveRequestEvent
|
||||||
|
- DrawRequestEvent
|
||||||
|
- UndoRequestEvent
|
||||||
|
- NewGameRequestEvent
|
||||||
|
|
||||||
|
state:
|
||||||
|
- StateChangedEvent
|
||||||
|
- GameWonEvent
|
||||||
|
|
||||||
|
meta:
|
||||||
|
- AchievementUnlockedEvent
|
||||||
|
- SyncCompleteEvent
|
||||||
|
|
||||||
|
rules:
|
||||||
|
|
||||||
|
* "Input events trigger core logic"
|
||||||
|
* "Core logic emits state events"
|
||||||
|
* "UI reacts to state events only"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Sync Contract
|
||||||
|
|
||||||
|
sync:
|
||||||
|
|
||||||
|
provider_trait:
|
||||||
|
methods:
|
||||||
|
- pull() -> SyncPayload
|
||||||
|
- push(payload) -> SyncResponse
|
||||||
|
|
||||||
|
guarantees:
|
||||||
|
- "non-blocking during gameplay"
|
||||||
|
- "blocking allowed on exit only"
|
||||||
|
|
||||||
|
merge:
|
||||||
|
rules:
|
||||||
|
counters: "max"
|
||||||
|
best_times: "min"
|
||||||
|
collections: "union"
|
||||||
|
achievements: "never removed"
|
||||||
|
|
||||||
|
```
|
||||||
|
properties:
|
||||||
|
- deterministic
|
||||||
|
- idempotent
|
||||||
|
- lossless
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Persistence
|
||||||
|
|
||||||
|
storage:
|
||||||
|
|
||||||
|
format: json
|
||||||
|
|
||||||
|
files:
|
||||||
|
- stats.json
|
||||||
|
- progress.json
|
||||||
|
- achievements.json
|
||||||
|
- settings.json
|
||||||
|
- game_state.json
|
||||||
|
|
||||||
|
guarantees:
|
||||||
|
- atomic_write: true
|
||||||
|
- crash_safe: true
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Engine Rules
|
||||||
|
|
||||||
|
engine:
|
||||||
|
|
||||||
|
mutation_rules:
|
||||||
|
- "Only GameLogicSystem mutates GameState"
|
||||||
|
- "UI systems are read-only"
|
||||||
|
|
||||||
|
threading:
|
||||||
|
- "sync runs on AsyncComputeTaskPool"
|
||||||
|
- "main thread must never block"
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
pattern: "feature_isolation"
|
||||||
|
communication: "events"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Server Contract
|
||||||
|
|
||||||
|
server:
|
||||||
|
|
||||||
|
auth:
|
||||||
|
method: jwt
|
||||||
|
access_expiry: 24h
|
||||||
|
refresh_expiry: 30d
|
||||||
|
|
||||||
|
endpoints:
|
||||||
|
- POST /api/auth/register
|
||||||
|
- POST /api/auth/login
|
||||||
|
- GET /api/sync/pull
|
||||||
|
- POST /api/sync/push
|
||||||
|
|
||||||
|
limits:
|
||||||
|
payload_max: 1MB
|
||||||
|
rate_limit: "10 req/min auth routes"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Achievement System
|
||||||
|
|
||||||
|
achievements:
|
||||||
|
|
||||||
|
definition_location: solitaire_core
|
||||||
|
state_location: solitaire_data
|
||||||
|
|
||||||
|
types:
|
||||||
|
- condition_based
|
||||||
|
- event_driven
|
||||||
|
|
||||||
|
rule:
|
||||||
|
- "achievements cannot be revoked"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Testing Rules
|
||||||
|
|
||||||
|
testing:
|
||||||
|
|
||||||
|
philosophy:
|
||||||
|
- "test real failures"
|
||||||
|
- "avoid redundant tests"
|
||||||
|
|
||||||
|
required_coverage:
|
||||||
|
solitaire_core:
|
||||||
|
- move_validation
|
||||||
|
- undo_integrity
|
||||||
|
- win_detection
|
||||||
|
|
||||||
|
```
|
||||||
|
solitaire_sync:
|
||||||
|
- merge_correctness
|
||||||
|
- idempotency
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Prohibited Patterns
|
||||||
|
|
||||||
|
(See CLAUDE.md §11 for the canonical forbidden-patterns list.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Extension Points
|
||||||
|
|
||||||
|
extensibility:
|
||||||
|
|
||||||
|
sync_backends:
|
||||||
|
pattern: "implement SyncProvider"
|
||||||
|
|
||||||
|
game_modes:
|
||||||
|
location: solitaire_core::GameMode
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
rule: "new feature = new plugin"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Validation Checklist (for Claude)
|
||||||
|
|
||||||
|
validation:
|
||||||
|
|
||||||
|
* check: "crate dependency rules respected"
|
||||||
|
* check: "no panics in core"
|
||||||
|
* check: "events used for cross-system communication"
|
||||||
|
* check: "GameState mutations centralized"
|
||||||
|
* check: "merge function properties preserved"
|
||||||
|
* check: "no blocking operations in main loop"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Mental Model
|
||||||
|
|
||||||
|
model:
|
||||||
|
|
||||||
|
layers:
|
||||||
|
- core
|
||||||
|
- engine
|
||||||
|
- data
|
||||||
|
- server
|
||||||
|
|
||||||
|
flow:
|
||||||
|
- input -> engine -> core -> engine -> ui
|
||||||
|
- data <-> sync <-> server
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
# CLAUDE_WORKFLOW.md
|
||||||
|
|
||||||
|
version: 1.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Overview
|
||||||
|
|
||||||
|
This workflow defines a **two-agent system**:
|
||||||
|
|
||||||
|
* **Builder Agent** → writes and modifies code
|
||||||
|
* **Guardian Agent** → enforces architecture + rejects invalid changes
|
||||||
|
|
||||||
|
No code is considered valid unless it passes Guardian validation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Agent Roles
|
||||||
|
|
||||||
|
### 1.1 Builder Agent
|
||||||
|
|
||||||
|
role: "code_generation"
|
||||||
|
|
||||||
|
responsibilities:
|
||||||
|
|
||||||
|
* implement features
|
||||||
|
* refactor code
|
||||||
|
* generate tests (only when justified)
|
||||||
|
* follow CLAUDE_SPEC.md
|
||||||
|
|
||||||
|
constraints:
|
||||||
|
|
||||||
|
* cannot bypass validation
|
||||||
|
* must declare intent before writing code
|
||||||
|
|
||||||
|
output_contract:
|
||||||
|
must_produce:
|
||||||
|
- change_summary
|
||||||
|
- files_modified
|
||||||
|
- reasoning (short)
|
||||||
|
- code_diff
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 Guardian Agent
|
||||||
|
|
||||||
|
role: "architecture_enforcement"
|
||||||
|
|
||||||
|
responsibilities:
|
||||||
|
|
||||||
|
* validate against CLAUDE_SPEC.md
|
||||||
|
* detect violations
|
||||||
|
* reject or approve changes
|
||||||
|
* suggest minimal fixes (not full rewrites)
|
||||||
|
|
||||||
|
constraints:
|
||||||
|
|
||||||
|
* no feature implementation
|
||||||
|
* no large rewrites
|
||||||
|
* must be deterministic
|
||||||
|
|
||||||
|
output_contract:
|
||||||
|
must_produce:
|
||||||
|
- status: APPROVED | REJECTED
|
||||||
|
- violations[]
|
||||||
|
- required_fixes[]
|
||||||
|
- optional_improvements[]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Workflow Pipeline
|
||||||
|
|
||||||
|
```text
|
||||||
|
User Request
|
||||||
|
↓
|
||||||
|
Builder Agent (proposal + code)
|
||||||
|
↓
|
||||||
|
Guardian Agent (validation)
|
||||||
|
↓
|
||||||
|
IF approved → commit
|
||||||
|
IF rejected → feedback → Builder retry
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Builder Protocol
|
||||||
|
|
||||||
|
### Step 1 — Intent Declaration
|
||||||
|
|
||||||
|
Builder MUST start with:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
intent:
|
||||||
|
feature: "<name>"
|
||||||
|
crates_touched: []
|
||||||
|
systems_affected: []
|
||||||
|
risk_level: low|medium|high
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2 — Plan
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
plan:
|
||||||
|
- step: "..."
|
||||||
|
- step: "..."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3 — Implementation
|
||||||
|
|
||||||
|
* Only modify declared crates
|
||||||
|
* Follow ownership rules
|
||||||
|
* Use events for cross-system communication
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4 — Output
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
change_summary: "..."
|
||||||
|
|
||||||
|
files_modified:
|
||||||
|
- path: ...
|
||||||
|
change: "..."
|
||||||
|
|
||||||
|
violations_self_check:
|
||||||
|
- none | list
|
||||||
|
|
||||||
|
notes: "short reasoning"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Guardian Protocol
|
||||||
|
|
||||||
|
### Step 1 — Spec Validation
|
||||||
|
|
||||||
|
Check against:
|
||||||
|
|
||||||
|
* crate boundaries
|
||||||
|
* mutation rules
|
||||||
|
* event system usage
|
||||||
|
* sync guarantees
|
||||||
|
* forbidden patterns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2 — Invariant Validation
|
||||||
|
|
||||||
|
Must verify:
|
||||||
|
|
||||||
|
* GameState invariants preserved
|
||||||
|
* no new panic paths
|
||||||
|
* no blocking calls in engine
|
||||||
|
* merge properties unchanged
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3 — Output Decision
|
||||||
|
|
||||||
|
#### APPROVED
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
status: APPROVED
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- "no violations"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### REJECTED
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
status: REJECTED
|
||||||
|
|
||||||
|
violations:
|
||||||
|
- id: core_purity_violation
|
||||||
|
file: "solitaire_core/src/..."
|
||||||
|
reason: "uses std::fs"
|
||||||
|
|
||||||
|
required_fixes:
|
||||||
|
- "move IO to solitaire_data"
|
||||||
|
|
||||||
|
optional_improvements:
|
||||||
|
- "simplify event naming"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Enforcement Rules
|
||||||
|
|
||||||
|
### Hard Fail (automatic rejection)
|
||||||
|
|
||||||
|
* core crate uses IO / Bevy / network
|
||||||
|
* GameState mutated outside GameLogicSystem
|
||||||
|
* blocking async on main thread
|
||||||
|
* duplicate logic across crates
|
||||||
|
* merge function altered incorrectly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Soft Fail (allowed but flagged)
|
||||||
|
|
||||||
|
* unnecessary complexity
|
||||||
|
* redundant tests
|
||||||
|
* minor architectural drift
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Iteration Loop
|
||||||
|
|
||||||
|
Max attempts per task: **3**
|
||||||
|
|
||||||
|
```text
|
||||||
|
Attempt 1 → Reject → Fix
|
||||||
|
Attempt 2 → Reject → Fix
|
||||||
|
Attempt 3 → Final decision
|
||||||
|
```
|
||||||
|
|
||||||
|
If still failing:
|
||||||
|
→ escalate to user
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Diff Strategy
|
||||||
|
|
||||||
|
Builder MUST produce:
|
||||||
|
|
||||||
|
* minimal diffs
|
||||||
|
* no unrelated refactors
|
||||||
|
* no formatting-only changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Test Strategy Integration
|
||||||
|
|
||||||
|
Builder rules:
|
||||||
|
|
||||||
|
* only add tests if:
|
||||||
|
|
||||||
|
* fixing a bug
|
||||||
|
* protecting complex logic
|
||||||
|
* validating invariants
|
||||||
|
|
||||||
|
Guardian rejects:
|
||||||
|
|
||||||
|
* redundant tests
|
||||||
|
* no-op tests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Optional Extensions
|
||||||
|
|
||||||
|
### 9.1 Third Agent (Optimizer)
|
||||||
|
|
||||||
|
role: performance + cleanup
|
||||||
|
|
||||||
|
runs AFTER approval:
|
||||||
|
|
||||||
|
* reduce allocations
|
||||||
|
* simplify logic
|
||||||
|
* improve ECS scheduling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.2 CI Integration
|
||||||
|
|
||||||
|
Pipeline:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Builder → Guardian → cargo check → clippy → tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Guardian runs BEFORE compilation to catch structural issues early.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Example Interaction
|
||||||
|
|
||||||
|
### Builder
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
intent:
|
||||||
|
feature: "undo stack limit fix"
|
||||||
|
crates_touched: [solitaire_core]
|
||||||
|
risk_level: low
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
change_summary: "limit undo stack to 64 entries"
|
||||||
|
|
||||||
|
files_modified:
|
||||||
|
- solitaire_core/src/game_state.rs
|
||||||
|
|
||||||
|
notes: "prevents unbounded memory growth"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Guardian
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
status: APPROVED
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- "respects core constraints"
|
||||||
|
- "no invariant violations"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Mental Model
|
||||||
|
|
||||||
|
* Builder = **creative**
|
||||||
|
* Guardian = **strict**
|
||||||
|
|
||||||
|
Builder explores
|
||||||
|
Guardian enforces
|
||||||
|
|
||||||
|
Neither replaces the other.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Success Criteria
|
||||||
|
|
||||||
|
System is working if:
|
||||||
|
|
||||||
|
* architectural violations go to ~0
|
||||||
|
* code stays consistent across features
|
||||||
|
* refactors become safe
|
||||||
|
* complexity grows sub-linearly
|
||||||
Generated
+72
-3
@@ -44,7 +44,7 @@ dependencies = [
|
|||||||
"accesskit_consumer",
|
"accesskit_consumer",
|
||||||
"hashbrown 0.15.5",
|
"hashbrown 0.15.5",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit 0.2.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -313,6 +313,23 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arboard"
|
||||||
|
version = "3.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
|
||||||
|
dependencies = [
|
||||||
|
"clipboard-win",
|
||||||
|
"log",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-app-kit 0.3.2",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
"parking_lot",
|
||||||
|
"percent-encoding",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
"x11rb",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arc-swap"
|
name = "arc-swap"
|
||||||
version = "1.9.1"
|
version = "1.9.1"
|
||||||
@@ -1972,6 +1989,15 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clipboard-win"
|
||||||
|
version = "5.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
|
||||||
|
dependencies = [
|
||||||
|
"error-code",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cmake"
|
name = "cmake"
|
||||||
version = "0.1.58"
|
version = "0.1.58"
|
||||||
@@ -2882,6 +2908,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "error-code"
|
||||||
|
version = "3.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "etcetera"
|
name = "etcetera"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -4968,6 +5000,18 @@ dependencies = [
|
|||||||
"objc2-quartz-core",
|
"objc2-quartz-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-app-kit"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-core-graphics",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-audio-toolbox"
|
name = "objc2-audio-toolbox"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -5065,6 +5109,19 @@ dependencies = [
|
|||||||
"objc2 0.6.4",
|
"objc2 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-graphics"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"dispatch2",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-io-surface",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-core-image"
|
name = "objc2-core-image"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -5121,6 +5178,17 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-io-surface"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-link-presentation"
|
name = "objc2-link-presentation"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -5129,7 +5197,7 @@ checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"block2 0.5.1",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit 0.2.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6856,6 +6924,7 @@ dependencies = [
|
|||||||
name = "solitaire_engine"
|
name = "solitaire_engine"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"arboard",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"bevy",
|
"bevy",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -9516,7 +9585,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"ndk",
|
"ndk",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit 0.2.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
"objc2-ui-kit",
|
"objc2-ui-kit",
|
||||||
"orbclient",
|
"orbclient",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ dirs = "6"
|
|||||||
keyring = "4"
|
keyring = "4"
|
||||||
keyring-core = "1"
|
keyring-core = "1"
|
||||||
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
||||||
|
arboard = { version = "3", default-features = false }
|
||||||
|
|
||||||
solitaire_core = { path = "solitaire_core" }
|
solitaire_core = { path = "solitaire_core" }
|
||||||
solitaire_sync = { path = "solitaire_sync" }
|
solitaire_sync = { path = "solitaire_sync" }
|
||||||
|
|||||||
+131
-92
@@ -1,140 +1,179 @@
|
|||||||
# 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.18.0 draft) — 24 commits since
|
||||||
|
the v0.17.0 tag bundle the launch-experience round (Restore prompt +
|
||||||
|
auto-show Home / mode picker), the MSSC-style Home picker rework
|
||||||
|
(header chips, draw-mode chips, picture-tile mode cards, Today's
|
||||||
|
Event callout, glyph fixes), the last solver hot path moving onto
|
||||||
|
`AsyncComputeTaskPool`, "Won before" HUD chip, "Copy share link"
|
||||||
|
Stats button, the `N` keybinding finally routing through the real
|
||||||
|
Confirm/Cancel modal, Esc-on-modal layering fixes, and the
|
||||||
|
unified-3.0 Claude rule set (CLAUDE.md / CLAUDE_SPEC.md /
|
||||||
|
CLAUDE_WORKFLOW.md / CLAUDE_PROMPT_PACK.md). Test-discipline prune
|
||||||
|
removed 43 low-value tests in the same window.
|
||||||
|
|
||||||
## Status at pause
|
## Status at pause
|
||||||
|
|
||||||
- **HEAD on origin:** v0.14.0's tag commit (CHANGELOG + handoff refresh).
|
- **HEAD on origin:** `v0.17.0-24-gc497c31` (24 ahead of v0.17.0,
|
||||||
- **Working tree:** clean apart from untracked `CARD_PLAN.md` (intentional).
|
not yet tagged).
|
||||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
- **Working tree:** clean.
|
||||||
- **Tests:** **1134 passed / 0 failed** across the workspace.
|
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
|
||||||
- **Tags on origin:** `v0.9.0`, `v0.10.0`, `v0.11.0`, `v0.12.0`, `v0.13.0`, `v0.14.0`.
|
clean (verified this session).
|
||||||
|
- **Tests:** **1166 passing / 0 failing** across the workspace
|
||||||
|
(verified this session). The first run flaked once on
|
||||||
|
`solitaire_engine::game_plugin::tests::auto_save_writes_after_30_seconds`
|
||||||
|
— a one-frame `app.update()` test that depends on `time.delta_secs()`
|
||||||
|
on an otherwise-fresh `App`. Reproduced clean on the second run;
|
||||||
|
passes in isolation. Worth tightening if it flakes again, but
|
||||||
|
not blocking the v0.18.0 cut.
|
||||||
|
- **Tags on origin:** `v0.9.0` through `v0.17.0`.
|
||||||
|
- **CHANGELOG:** v0.18.0 entry drafted in `[Unreleased]`'s slot —
|
||||||
|
ready for tag once build + tests are reverified.
|
||||||
|
|
||||||
## Where we are
|
## Where we are
|
||||||
|
|
||||||
v0.14.0 is the largest release since the card-theme system. Three threads land together:
|
v0.17.0's punch list had four candidates (A–D); two of the three
|
||||||
|
non-packaging items shipped in this round:
|
||||||
|
|
||||||
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.
|
- **B — "Won previously" HUD indicator:** shipped in `bdac754`.
|
||||||
2. **Quat smoke-test bug fixes** — multi-card move validation, softlock detection, deal-tween information leak.
|
- **C — Replay sharing:** shipped in `540869c` ("Copy share link"
|
||||||
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.
|
Stats button + clipboard via `arboard`, in-memory `LastSharedReplayUrl`).
|
||||||
|
|
||||||
The card-flight web animations and replay E2E test coverage close out the pipeline.
|
Item **A** (solver-on-`AsyncComputeTaskPool`) shipped *partially* in
|
||||||
|
`d489e7a` — the winnable-only seed-selection path is now async with
|
||||||
|
cancel-on-replace. The hint path (`H` key,
|
||||||
|
`try_solve_with_first_move` / `try_solve_from_state`) is still
|
||||||
|
synchronous. The proven `PendingNewGameSeed` template is the
|
||||||
|
template for the hint port.
|
||||||
|
|
||||||
|
Item **D** (desktop packaging) is unchanged — still gated on
|
||||||
|
artwork + signing certs from the player.
|
||||||
|
|
||||||
|
The launch experience is also substantially different from v0.17.0:
|
||||||
|
on first launch with a saved game the player now sees the Restore
|
||||||
|
prompt; on every launch (after splash + restore resolution) they see
|
||||||
|
the auto-show Home / mode picker.
|
||||||
|
|
||||||
### Design direction (unchanged)
|
### Design direction (unchanged)
|
||||||
|
|
||||||
- **Tone:** Balatro — chunky readable type, theatrical hierarchy, satisfying micro-interactions.
|
- **Tone:** Balatro — chunky readable type, theatrical hierarchy,
|
||||||
- **Palette:** Midnight Purple base + Balatro yellow primary + warm magenta secondary.
|
satisfying micro-interactions.
|
||||||
- See `~/.claude/projects/-home-manage-Rusty-Solitare/memory/project_ux_overhaul_2026-04.md` (machine-local).
|
- **Palette:** Midnight Purple base + Balatro yellow primary + warm
|
||||||
|
magenta secondary.
|
||||||
|
- See `~/.claude/projects/-home-manage-Rusty-Solitare/memory/project_ux_overhaul_2026-04.md`
|
||||||
|
(machine-local).
|
||||||
|
|
||||||
### Canonical remote
|
### Canonical remote
|
||||||
|
|
||||||
`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.18.0 (drafted 2026-05-06, not yet tagged)
|
||||||
|
|
||||||
### 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. |
|
| Restore prompt | `3c7a0eb` + `f863d85` | Welcome-back modal on launch when an in-progress save exists; save preserved across exits while the prompt is unanswered. |
|
||||||
| 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`. |
|
| Async winnable-only seeds | `d489e7a` | `PendingNewGameSeed` resource + `poll_pending_new_game_seed` running `.before(GameMutation)`. Fixes the worst-case 6 s UI stall on a New Game click. Cancel-on-replace contract covered by tests. |
|
||||||
| 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. |
|
| Won-before HUD chip | `bdac754` | Reads `ReplayHistoryResource`; lights `✓ Won before` on tier-2 row when current `(seed, draw_mode, mode)` is in history. |
|
||||||
| 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. |
|
| Copy share link | `540869c` | `arboard` clipboard + new Stats button + `SyncProvider::push_replay` returning the share URL. In-memory only; per-session sharing. |
|
||||||
| Time-bonus slider | `89c51ab` | Settings → Gameplay slider 0.0–2.0, default 1.0, "Off" at zero. Multiplies the time-bonus shown in the win modal. Cosmetic only — does NOT affect achievement unlock thresholds. |
|
| MSSC Home picker | `ae40a1d`, `b73d246`, `9fe650f`, `40d6e0a`, `c30b04e`, `d065d49` | Header stats strip (clickable → Profile), draw-mode chips, per-mode score/streak chips, Today's Event callout on Daily, picture-tile 2-up grid with FiraMono-covered glyphs (♣ ◆ ○ ▲ →). |
|
||||||
|
| Auto-show Home | `dd63261`, `b7c3a49`, `c497c31` | Auto-shows after splash; gated on Restore prompt; freezes timers (elapsed + Time Attack) while up. |
|
||||||
### Quat smoke-test bug fixes
|
| `N` opens real modal | `93660c2` | Removes the "Press N again" double-tap; routes through `ConfirmNewGameScreen`. `Shift+N` retains the bypass. |
|
||||||
|
| Win Summary keyboard | `17e0737` | Enter dismisses + starts a fresh deal. |
|
||||||
| Area | Commit | What landed |
|
| Esc-on-modal fixes | `08b006f`, `d48b948`, `9aa0dd2` | Esc no longer opens Pause underneath the modal it just closed; Home maps Esc to Cancel; Restore maps Esc to Continue; topmost-modal-wins when Profile stacks on Home. |
|
||||||
|---|---|---|
|
| Layout fixes | `a4bc063`, `cc63532` | Settings rows full-width with label-spacer-cluster; popover rows excluded from action-bar auto-fade. |
|
||||||
| 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. |
|
| Empty-state copy | `56e2e6f` | Leaderboard / Achievements onboarding hints; volume hotkeys emit toast feedback. |
|
||||||
| 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. |
|
| Test prune | `a49a340` | −43 low-value tests; future briefs request behaviour contracts only. |
|
||||||
| 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. |
|
| Docs unified-3.0 | `f2f30c8` | Adopts CLAUDE.md / CLAUDE_SPEC.md / CLAUDE_WORKFLOW.md / CLAUDE_PROMPT_PACK.md; trims duplicated rule passages. |
|
||||||
| 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
|
### Carried forward from v0.17.0
|
||||||
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)
|
- **Solver-on-`AsyncComputeTaskPool` for the H-key hint** —
|
||||||
|
remaining synchronous solver hot path. The seed-selection port
|
||||||
|
in `d489e7a` is the template: `PendingHintTask` resource, polling
|
||||||
|
system running `.before(GameMutation)`, cancel-on-replace, fall
|
||||||
|
back to the heuristic on inconclusive. Diff should stay scoped
|
||||||
|
to `input_plugin.rs` plus a small `pending_hint.rs`.
|
||||||
|
- **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.
|
### New this round
|
||||||
- **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)
|
- **Persistent share link.** `LastSharedReplayUrl` is in-memory only
|
||||||
|
— the player must share within the session of the win. If
|
||||||
|
cross-session sharing turns into a real ask, persist alongside
|
||||||
|
the rolling replay history.
|
||||||
|
- **Per-mode artwork.** Picture tiles use Unicode glyphs as
|
||||||
|
placeholders chosen from FiraMono's actual coverage. When real
|
||||||
|
artwork lands, swap each tile's `Text` node for an `Image` node
|
||||||
|
— tile layout, focus order, click handling, and chip rendering
|
||||||
|
are unchanged.
|
||||||
|
|
||||||
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:
|
### Process notes (from this round)
|
||||||
|
|
||||||
- **Bundled default theme** ships in the binary via `embedded://` — 52 hayeah/playing-cards-assets SVGs + a midnight-purple `back.svg`.
|
- **Test inflation pattern (resolved this round):** older agent
|
||||||
- **User themes** under `themes://`. Drop a directory containing `theme.ron` + 53 SVGs.
|
briefs reflexively asked for ≥3 tests per feature, producing 43
|
||||||
- **Importer** at `solitaire_engine::theme::import_theme(zip)` validates archives and atomically unpacks.
|
low-value coverage entries on stdlib/serde-derive mechanics. Going
|
||||||
- **Picker UI** in Settings → Cosmetic; thumbnails + the active theme's `back` override the legacy `back_N.png` picker when present.
|
forward, ask for tests that pin behaviour contracts or
|
||||||
|
regressions on real bugs only. See
|
||||||
|
`feedback_test_discipline.md` in auto-memory.
|
||||||
|
- **Solver async refactor sequencing (worked this round):** rather
|
||||||
|
than porting the whole solver-on-main-thread surface in one PR
|
||||||
|
(the rollback case from before v0.17.0), the
|
||||||
|
`PendingNewGameSeed` work shipped one well-bounded path with two
|
||||||
|
tests covering the happy path and cancel-on-replace. The hint
|
||||||
|
port should follow the same shape.
|
||||||
|
|
||||||
## Resume prompt
|
## Resume prompt
|
||||||
|
|
||||||
```
|
```
|
||||||
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>.
|
||||||
directory may still be named Rusty_Solitare from earlier; that's fine>.
|
Branch: master. Direction is OPEN — v0.18.0 has been drafted but
|
||||||
Branch: master. Direction is OPEN — v0.14.0 just shipped covering the
|
not tagged: 24 commits past v0.17.0 cover the launch-experience
|
||||||
Quat bug fixes, the v0.13.0 candidate tail, and the entire
|
round, MSSC Home picker, async winnable-only seeds, Won-before
|
||||||
replay-pipeline feature.
|
HUD, Copy share link, N-key flow rework, Esc-layering fixes, and
|
||||||
|
the unified-3.0 Claude rule set.
|
||||||
|
|
||||||
State: HEAD at v0.14.0. Working tree clean apart from untracked
|
State: HEAD at v0.17.0-24-gc497c31. Working tree clean.
|
||||||
CARD_PLAN.md (intentional).
|
CHANGELOG.md has the v0.18.0 entry slotted under [Unreleased].
|
||||||
Build: cargo clippy --workspace --all-targets -- -D warnings clean.
|
|
||||||
Tests: 1134 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 — this file
|
||||||
2. CHANGELOG.md — release-by-release record
|
2. CHANGELOG.md — v0.18.0 draft entry
|
||||||
3. CLAUDE.md — hard rules (UI-first, no panics, etc.)
|
3. CLAUDE.md — unified-3.0 rule set
|
||||||
4. ARCHITECTURE.md — crate responsibilities + data flow
|
4. CLAUDE_SPEC.md — formal architecture spec
|
||||||
5. ~/.claude/projects/<this-project>/memory/MEMORY.md
|
5. ARCHITECTURE.md — crate responsibilities + data flow
|
||||||
— saved feedback / project context (machine-local;
|
6. ~/.claude/projects/<this-project>/memory/MEMORY.md
|
||||||
may be missing on a fresh machine)
|
— saved feedback / project context
|
||||||
|
(machine-local; 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. Tag v0.18.0 — promote `[Unreleased]` to `[0.18.0]` (already
|
||||||
three Quat bug fixes hold up in real gameplay and the replay
|
done in this session's draft), reverify build + clippy +
|
||||||
pipeline works end-to-end (record → upload → web viewer).
|
tests, tag, push. Mechanical close-out.
|
||||||
B. Take the deferred Bevy-audio-feature trim (Quat investigation
|
B. Solver-on-AsyncComputeTaskPool for the H-key hint, using the
|
||||||
#2) — one-line workspace edit, ~50 fewer transitive crates.
|
`d489e7a` seed-selection port as template. Last synchronous
|
||||||
C. Take the deferred solver toggle (Quat investigation #1): add
|
solver hot path. Smallest delta on the open punch list.
|
||||||
"Winnable deals only" Settings toggle. Larger.
|
C. Desktop packaging — needs artwork + signing certs from the
|
||||||
D. Promote the in-engine "Watch replay" button to real playback.
|
player; can't be driven by the agent alone.
|
||||||
E. Pick from the remaining "next-round candidates" in this doc.
|
D. Persistent share link — store the URL alongside replay
|
||||||
F. Take the deferred desktop-packaging item (needs artwork +
|
history so cross-session sharing works.
|
||||||
signing certs from the user).
|
|
||||||
|
|
||||||
WORKFLOW NOTES:
|
WORKFLOW NOTES:
|
||||||
- Commits use:
|
- Commits use:
|
||||||
git -c user.name=funman300 -c user.email=root@vscode.infinity \
|
git -c user.name=funman300 -c user.email=root@vscode.infinity \
|
||||||
commit -m "..."
|
commit -m "..."
|
||||||
- When attributing playtester feedback in commits/docs, use "Quat"
|
- When attributing playtester feedback in commits/docs, use
|
||||||
not "Rhys" (saved feedback memory).
|
"Quat" not "Rhys" (saved feedback memory).
|
||||||
- Sub-agents stage + verify only; orchestrator commits.
|
- Sub-agents stage + verify only; orchestrator commits.
|
||||||
- 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 A–F. Don't pick unilaterally.
|
OPEN AT THE START: ask which of A–D. Don't pick unilaterally.
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ fn main() {
|
|||||||
.add_plugins(TimeAttackPlugin)
|
.add_plugins(TimeAttackPlugin)
|
||||||
.add_plugins(HudPlugin)
|
.add_plugins(HudPlugin)
|
||||||
.add_plugins(HelpPlugin)
|
.add_plugins(HelpPlugin)
|
||||||
.add_plugins(HomePlugin)
|
.add_plugins(HomePlugin::default())
|
||||||
.add_plugins(ProfilePlugin)
|
.add_plugins(ProfilePlugin)
|
||||||
.add_plugins(PausePlugin)
|
.add_plugins(PausePlugin)
|
||||||
.add_plugins(SettingsPlugin::default())
|
.add_plugins(SettingsPlugin::default())
|
||||||
|
|||||||
@@ -77,16 +77,6 @@ pub struct Card {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rank_value_ace_is_one() {
|
|
||||||
assert_eq!(Rank::Ace.value(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rank_value_king_is_thirteen() {
|
|
||||||
assert_eq!(Rank::King.value(), 13);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rank_values_are_sequential() {
|
fn rank_values_are_sequential() {
|
||||||
let ranks = [
|
let ranks = [
|
||||||
@@ -100,26 +90,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn suit_red_is_diamonds_and_hearts() {
|
fn suit_red_and_black_are_complementary() {
|
||||||
assert!(Suit::Diamonds.is_red());
|
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||||
assert!(Suit::Hearts.is_red());
|
assert_ne!(suit.is_red(), suit.is_black(), "{suit:?} must be exactly one of red/black");
|
||||||
assert!(!Suit::Clubs.is_red());
|
}
|
||||||
assert!(!Suit::Spades.is_red());
|
assert!(Suit::Diamonds.is_red() && Suit::Hearts.is_red());
|
||||||
}
|
assert!(Suit::Clubs.is_black() && Suit::Spades.is_black());
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn suit_black_is_clubs_and_spades() {
|
|
||||||
assert!(Suit::Clubs.is_black());
|
|
||||||
assert!(Suit::Spades.is_black());
|
|
||||||
assert!(!Suit::Diamonds.is_black());
|
|
||||||
assert!(!Suit::Hearts.is_black());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn card_face_up_field_reflects_construction() {
|
|
||||||
let card = Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: false };
|
|
||||||
assert!(!card.face_up);
|
|
||||||
let card2 = Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: true };
|
|
||||||
assert!(card2.face_up);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -815,11 +815,6 @@ mod tests {
|
|||||||
assert!(g.undo_stack_len() <= 64);
|
assert!(g.undo_stack_len() <= 64);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn undo_count_starts_at_zero() {
|
|
||||||
assert_eq!(new_game().undo_count, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn undo_count_increments_on_each_undo() {
|
fn undo_count_increments_on_each_undo() {
|
||||||
let mut g = new_game();
|
let mut g = new_game();
|
||||||
@@ -900,11 +895,6 @@ mod tests {
|
|||||||
assert_eq!(g.score, 0);
|
assert_eq!(g.score, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn zen_mode_default_is_classic_via_default_trait() {
|
|
||||||
assert_eq!(GameMode::default(), GameMode::Classic);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn zen_mode_field_persists_through_construction() {
|
fn zen_mode_field_persists_through_construction() {
|
||||||
let g = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Zen);
|
let g = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Zen);
|
||||||
@@ -956,12 +946,6 @@ mod tests {
|
|||||||
assert!(g.undo().is_ok(), "undo must be permitted in TimeAttack mode");
|
assert!(g.undo().is_ok(), "undo must be permitted in TimeAttack mode");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn time_attack_score_starts_at_zero() {
|
|
||||||
let g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::TimeAttack);
|
|
||||||
assert_eq!(g.score, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn time_attack_draw_three_combination() {
|
fn time_attack_draw_three_combination() {
|
||||||
// TimeAttack + DrawThree is a valid combination; verify construction.
|
// TimeAttack + DrawThree is a valid combination; verify construction.
|
||||||
|
|||||||
+397
-43
@@ -65,7 +65,7 @@ use std::hash::{Hash, Hasher};
|
|||||||
|
|
||||||
use crate::card::{Card, Suit};
|
use crate::card::{Card, Suit};
|
||||||
use crate::deck::{deal_klondike, Deck};
|
use crate::deck::{deal_klondike, Deck};
|
||||||
use crate::game_state::DrawMode;
|
use crate::game_state::{DrawMode, GameState};
|
||||||
use crate::pile::{Pile, PileType};
|
use crate::pile::{Pile, PileType};
|
||||||
use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence};
|
use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence};
|
||||||
|
|
||||||
@@ -108,6 +108,42 @@ impl Default for SolverConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A single move the solver can recommend, expressed in terms of the
|
||||||
|
/// engine-level `(source, dest, count)` triple used by `MoveRequestEvent`.
|
||||||
|
///
|
||||||
|
/// Returned as part of [`SolveOutcome::first_move`] when
|
||||||
|
/// [`try_solve_with_first_move`] or [`try_solve_from_state`] proves the
|
||||||
|
/// position winnable. The hint system surfaces this to the player as the
|
||||||
|
/// "provably best" first move.
|
||||||
|
///
|
||||||
|
/// `count` is always `1` for non-tableau-to-tableau moves (foundation moves
|
||||||
|
/// always move a single card; waste moves a single card; draws use a
|
||||||
|
/// dedicated representation that the public API surfaces as
|
||||||
|
/// `source: PileType::Stock, dest: PileType::Waste, count: 1`).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SolverMove {
|
||||||
|
/// Pile the move originates from.
|
||||||
|
pub source: PileType,
|
||||||
|
/// Pile the move lands on.
|
||||||
|
pub dest: PileType,
|
||||||
|
/// Number of cards in the move (1 for non-tableau-to-tableau moves).
|
||||||
|
pub count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Solver verdict plus, when winnable, the first move on a winning path.
|
||||||
|
///
|
||||||
|
/// `result == Winnable` guarantees `first_move == Some(_)`; the inverse
|
||||||
|
/// holds only when the search proved a verdict — `Inconclusive` and
|
||||||
|
/// `Unwinnable` always carry `first_move == None`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SolveOutcome {
|
||||||
|
/// The high-level verdict (Winnable / Unwinnable / Inconclusive).
|
||||||
|
pub result: SolverResult,
|
||||||
|
/// First move on the solution path when `result == Winnable`,
|
||||||
|
/// otherwise `None`.
|
||||||
|
pub first_move: Option<SolverMove>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Tries to solve a fresh Classic-mode game from `seed` + `draw_mode`.
|
/// Tries to solve a fresh Classic-mode game from `seed` + `draw_mode`.
|
||||||
///
|
///
|
||||||
/// This is a pure function — same input always yields the same
|
/// This is a pure function — same input always yields the same
|
||||||
@@ -120,18 +156,47 @@ impl Default for SolverConfig {
|
|||||||
/// [`is_valid_tableau_sequence`]) drive move enumeration so the
|
/// [`is_valid_tableau_sequence`]) drive move enumeration so the
|
||||||
/// solver's notion of "legal" exactly matches the live game.
|
/// solver's notion of "legal" exactly matches the live game.
|
||||||
pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> SolverResult {
|
pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> SolverResult {
|
||||||
|
// Delegate to the path-recording variant and discard the move. The path
|
||||||
|
// recording is cheap (a single Option<SolverMove> per stack frame) so
|
||||||
|
// this preserves `try_solve`'s existing performance characteristics —
|
||||||
|
// the new-game retry loop, which is the hot caller, sees no slowdown.
|
||||||
|
try_solve_with_first_move(seed, draw_mode, config).result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tries to solve a fresh Classic-mode game from `seed` + `draw_mode` and,
|
||||||
|
/// when a win is found, returns the first move on the winning path.
|
||||||
|
///
|
||||||
|
/// Same semantics as [`try_solve`] for the verdict; the only difference is
|
||||||
|
/// that the [`SolveOutcome::first_move`] is populated when `result ==
|
||||||
|
/// SolverResult::Winnable`. `Unwinnable` and `Inconclusive` always carry
|
||||||
|
/// `first_move == None`.
|
||||||
|
///
|
||||||
|
/// Used by the engine hint system to promote H-key suggestions from a
|
||||||
|
/// heuristic to the provably-optimal first move; the hint system falls
|
||||||
|
/// back to its heuristic when this returns `Inconclusive`.
|
||||||
|
pub fn try_solve_with_first_move(
|
||||||
|
seed: u64,
|
||||||
|
draw_mode: DrawMode,
|
||||||
|
config: &SolverConfig,
|
||||||
|
) -> SolveOutcome {
|
||||||
let state = SolverState::initial(seed, draw_mode);
|
let state = SolverState::initial(seed, draw_mode);
|
||||||
let mut visited: HashSet<u64> = HashSet::new();
|
state.solve(config)
|
||||||
let mut moves_consumed: u64 = 0;
|
}
|
||||||
let mut budget_exceeded = false;
|
|
||||||
let won = state.search(config, &mut visited, &mut moves_consumed, &mut budget_exceeded);
|
/// Tries to solve from an existing in-progress [`GameState`].
|
||||||
if won {
|
///
|
||||||
SolverResult::Winnable
|
/// Mirrors [`try_solve_with_first_move`] but takes a live `GameState`
|
||||||
} else if budget_exceeded {
|
/// instead of a fresh seed. The hint system uses this so it can ask the
|
||||||
SolverResult::Inconclusive
|
/// solver about the actual board the player is staring at, not just the
|
||||||
} else {
|
/// initial deal.
|
||||||
SolverResult::Unwinnable
|
///
|
||||||
}
|
/// Reads `state.draw_mode` and the current pile contents. The active
|
||||||
|
/// `GameMode` is irrelevant — the solver only models Classic Klondike
|
||||||
|
/// rules, which are a strict subset of every other mode (Zen / Challenge
|
||||||
|
/// only differ in scoring and undo-availability).
|
||||||
|
pub fn try_solve_from_state(state: &GameState, config: &SolverConfig) -> SolveOutcome {
|
||||||
|
let solver_state = SolverState::from_game_state(state);
|
||||||
|
solver_state.solve(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -141,9 +206,11 @@ pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> Solve
|
|||||||
/// The candidate moves the solver enumerates at each step. Distinct
|
/// The candidate moves the solver enumerates at each step. Distinct
|
||||||
/// from `MoveRequestEvent` (engine-level) and `move_cards` (game-level)
|
/// from `MoveRequestEvent` (engine-level) and `move_cards` (game-level)
|
||||||
/// because the solver also needs to model the stock-draw + recycle as a
|
/// because the solver also needs to model the stock-draw + recycle as a
|
||||||
/// first-class move.
|
/// first-class move. Distinct from the public [`SolverMove`] because the
|
||||||
|
/// internal form encodes each move kind structurally for fast pattern
|
||||||
|
/// matching during enumeration.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum SolverMove {
|
enum InternalMove {
|
||||||
/// Move `count` cards from a tableau column to another tableau column.
|
/// Move `count` cards from a tableau column to another tableau column.
|
||||||
TableauToTableau { from: usize, to: usize, count: usize },
|
TableauToTableau { from: usize, to: usize, count: usize },
|
||||||
/// Move the top of a tableau column to a foundation slot.
|
/// Move the top of a tableau column to a foundation slot.
|
||||||
@@ -156,6 +223,41 @@ enum SolverMove {
|
|||||||
Draw,
|
Draw,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl InternalMove {
|
||||||
|
/// Convert this internal move into the public [`SolverMove`] form
|
||||||
|
/// suitable for handing off to the engine layer. Cheap — `O(1)` field
|
||||||
|
/// rewrites with no allocation.
|
||||||
|
fn to_public(self) -> SolverMove {
|
||||||
|
match self {
|
||||||
|
InternalMove::TableauToTableau { from, to, count } => SolverMove {
|
||||||
|
source: PileType::Tableau(from),
|
||||||
|
dest: PileType::Tableau(to),
|
||||||
|
count,
|
||||||
|
},
|
||||||
|
InternalMove::TableauToFoundation { from, slot } => SolverMove {
|
||||||
|
source: PileType::Tableau(from),
|
||||||
|
dest: PileType::Foundation(slot),
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
InternalMove::WasteToTableau { to } => SolverMove {
|
||||||
|
source: PileType::Waste,
|
||||||
|
dest: PileType::Tableau(to),
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
InternalMove::WasteToFoundation { slot } => SolverMove {
|
||||||
|
source: PileType::Waste,
|
||||||
|
dest: PileType::Foundation(slot),
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
InternalMove::Draw => SolverMove {
|
||||||
|
source: PileType::Stock,
|
||||||
|
dest: PileType::Waste,
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Compact replica of `GameState` tailored for the solver. Strips
|
/// Compact replica of `GameState` tailored for the solver. Strips
|
||||||
/// undo / score / move-count tracking and replaces the `HashMap` of
|
/// undo / score / move-count tracking and replaces the `HashMap` of
|
||||||
/// piles with fixed arrays so the canonical hash is cheap to compute.
|
/// piles with fixed arrays so the canonical hash is cheap to compute.
|
||||||
@@ -232,8 +334,8 @@ impl SolverState {
|
|||||||
/// The order matters — foundation moves shrink the search frontier
|
/// The order matters — foundation moves shrink the search frontier
|
||||||
/// fastest, and stock-draws are the costliest. See the top-of-file
|
/// fastest, and stock-draws are the costliest. See the top-of-file
|
||||||
/// algorithm note.
|
/// algorithm note.
|
||||||
fn enumerate_moves(&self) -> Vec<SolverMove> {
|
fn enumerate_moves(&self) -> Vec<InternalMove> {
|
||||||
let mut moves: Vec<SolverMove> = Vec::new();
|
let mut moves: Vec<InternalMove> = Vec::new();
|
||||||
|
|
||||||
// 1) Foundation moves from tableau tops.
|
// 1) Foundation moves from tableau tops.
|
||||||
for (i, col) in self.tableau.iter().enumerate() {
|
for (i, col) in self.tableau.iter().enumerate() {
|
||||||
@@ -246,7 +348,7 @@ impl SolverState {
|
|||||||
&self.foundation[slot as usize],
|
&self.foundation[slot as usize],
|
||||||
);
|
);
|
||||||
if can_place_on_foundation(top, &foundation_pile) {
|
if can_place_on_foundation(top, &foundation_pile) {
|
||||||
moves.push(SolverMove::TableauToFoundation { from: i, slot });
|
moves.push(InternalMove::TableauToFoundation { from: i, slot });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,7 +362,7 @@ impl SolverState {
|
|||||||
&self.foundation[slot as usize],
|
&self.foundation[slot as usize],
|
||||||
);
|
);
|
||||||
if can_place_on_foundation(top, &foundation_pile) {
|
if can_place_on_foundation(top, &foundation_pile) {
|
||||||
moves.push(SolverMove::WasteToFoundation { slot });
|
moves.push(InternalMove::WasteToFoundation { slot });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,7 +400,7 @@ impl SolverState {
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
moves.push(SolverMove::TableauToTableau { from: src, to: dst, count });
|
moves.push(InternalMove::TableauToTableau { from: src, to: dst, count });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,7 +410,7 @@ impl SolverState {
|
|||||||
for dst in 0..7usize {
|
for dst in 0..7usize {
|
||||||
let dst_pile = Self::pile_view(PileType::Tableau(dst), &self.tableau[dst]);
|
let dst_pile = Self::pile_view(PileType::Tableau(dst), &self.tableau[dst]);
|
||||||
if can_place_on_tableau(top, &dst_pile) {
|
if can_place_on_tableau(top, &dst_pile) {
|
||||||
moves.push(SolverMove::WasteToTableau { to: dst });
|
moves.push(InternalMove::WasteToTableau { to: dst });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,7 +428,7 @@ impl SolverState {
|
|||||||
let cycled_without_progress =
|
let cycled_without_progress =
|
||||||
self.consecutive_draws > stock_cycle_len.saturating_add(1);
|
self.consecutive_draws > stock_cycle_len.saturating_add(1);
|
||||||
if can_draw && !cycled_without_progress {
|
if can_draw && !cycled_without_progress {
|
||||||
moves.push(SolverMove::Draw);
|
moves.push(InternalMove::Draw);
|
||||||
}
|
}
|
||||||
|
|
||||||
moves
|
moves
|
||||||
@@ -334,11 +436,11 @@ impl SolverState {
|
|||||||
|
|
||||||
/// Apply `mv` to `self`, returning the previous `consecutive_draws`
|
/// Apply `mv` to `self`, returning the previous `consecutive_draws`
|
||||||
/// value so the caller can restore it on backtrack.
|
/// value so the caller can restore it on backtrack.
|
||||||
fn apply_move(&mut self, mv: SolverMove) -> SolverStateUndo {
|
fn apply_move(&mut self, mv: InternalMove) -> SolverStateUndo {
|
||||||
let prev_just_drew = self.just_drew;
|
let prev_just_drew = self.just_drew;
|
||||||
let prev_consec = self.consecutive_draws;
|
let prev_consec = self.consecutive_draws;
|
||||||
match mv {
|
match mv {
|
||||||
SolverMove::TableauToTableau { from, to, count } => {
|
InternalMove::TableauToTableau { from, to, count } => {
|
||||||
let start = self.tableau[from].len() - count;
|
let start = self.tableau[from].len() - count;
|
||||||
let moved: Vec<Card> = self.tableau[from].split_off(start);
|
let moved: Vec<Card> = self.tableau[from].split_off(start);
|
||||||
self.tableau[to].extend(moved);
|
self.tableau[to].extend(moved);
|
||||||
@@ -351,7 +453,7 @@ impl SolverState {
|
|||||||
self.just_drew = false;
|
self.just_drew = false;
|
||||||
self.consecutive_draws = 0;
|
self.consecutive_draws = 0;
|
||||||
}
|
}
|
||||||
SolverMove::TableauToFoundation { from, slot } => {
|
InternalMove::TableauToFoundation { from, slot } => {
|
||||||
if let Some(card) = self.tableau[from].pop() {
|
if let Some(card) = self.tableau[from].pop() {
|
||||||
self.foundation[slot as usize].push(card);
|
self.foundation[slot as usize].push(card);
|
||||||
if let Some(top) = self.tableau[from].last_mut()
|
if let Some(top) = self.tableau[from].last_mut()
|
||||||
@@ -363,21 +465,21 @@ impl SolverState {
|
|||||||
self.just_drew = false;
|
self.just_drew = false;
|
||||||
self.consecutive_draws = 0;
|
self.consecutive_draws = 0;
|
||||||
}
|
}
|
||||||
SolverMove::WasteToTableau { to } => {
|
InternalMove::WasteToTableau { to } => {
|
||||||
if let Some(card) = self.waste.pop() {
|
if let Some(card) = self.waste.pop() {
|
||||||
self.tableau[to].push(card);
|
self.tableau[to].push(card);
|
||||||
}
|
}
|
||||||
self.just_drew = false;
|
self.just_drew = false;
|
||||||
self.consecutive_draws = 0;
|
self.consecutive_draws = 0;
|
||||||
}
|
}
|
||||||
SolverMove::WasteToFoundation { slot } => {
|
InternalMove::WasteToFoundation { slot } => {
|
||||||
if let Some(card) = self.waste.pop() {
|
if let Some(card) = self.waste.pop() {
|
||||||
self.foundation[slot as usize].push(card);
|
self.foundation[slot as usize].push(card);
|
||||||
}
|
}
|
||||||
self.just_drew = false;
|
self.just_drew = false;
|
||||||
self.consecutive_draws = 0;
|
self.consecutive_draws = 0;
|
||||||
}
|
}
|
||||||
SolverMove::Draw => {
|
InternalMove::Draw => {
|
||||||
if self.stock.is_empty() {
|
if self.stock.is_empty() {
|
||||||
// Recycle waste back to stock face-down, reversed.
|
// Recycle waste back to stock face-down, reversed.
|
||||||
let mut recycled: Vec<Card> = self.waste.drain(..).collect();
|
let mut recycled: Vec<Card> = self.waste.drain(..).collect();
|
||||||
@@ -415,39 +517,55 @@ impl SolverState {
|
|||||||
/// stack lives on the heap and grows only with `Vec` capacity, not
|
/// stack lives on the heap and grows only with `Vec` capacity, not
|
||||||
/// with thread-stack pages.
|
/// with thread-stack pages.
|
||||||
///
|
///
|
||||||
/// Returns `true` as soon as a winning leaf is found. Sets
|
/// Returns `Some(first_move)` (the move applied at the root that led
|
||||||
/// `*budget_exceeded = true` if either budget trips before a
|
/// to a winning leaf) as soon as a winning leaf is found. Returns
|
||||||
/// verdict.
|
/// `None` if the search exhausts (Unwinnable) or a budget trips —
|
||||||
|
/// callers distinguish those two cases via `*budget_exceeded`.
|
||||||
|
///
|
||||||
|
/// Path recording is implemented by stashing the root-level move on
|
||||||
|
/// each pushed frame and propagating it unchanged into deeper
|
||||||
|
/// children. Cost: one `Option<InternalMove>` (≤ 16 bytes) per
|
||||||
|
/// frame and one branch on push. Negligible on the hot path; the
|
||||||
|
/// new-game retry loop sees no measurable slowdown.
|
||||||
fn search(
|
fn search(
|
||||||
self,
|
self,
|
||||||
config: &SolverConfig,
|
config: &SolverConfig,
|
||||||
visited: &mut HashSet<u64>,
|
visited: &mut HashSet<u64>,
|
||||||
moves_consumed: &mut u64,
|
moves_consumed: &mut u64,
|
||||||
budget_exceeded: &mut bool,
|
budget_exceeded: &mut bool,
|
||||||
) -> bool {
|
) -> Option<SolverMove> {
|
||||||
// Each stack frame keeps a state plus the move iterator we
|
// Each stack frame keeps a state plus the move iterator we
|
||||||
// haven't yet expanded. Popping a frame is the backtrack.
|
// haven't yet expanded. Popping a frame is the backtrack.
|
||||||
struct Frame {
|
struct Frame {
|
||||||
state: SolverState,
|
state: SolverState,
|
||||||
pending: std::vec::IntoIter<SolverMove>,
|
pending: std::vec::IntoIter<InternalMove>,
|
||||||
|
/// First move on the path from the root to this frame's
|
||||||
|
/// state. `None` for the root frame; populated when a child
|
||||||
|
/// frame is pushed. Propagates unchanged from parent to deeper
|
||||||
|
/// children so any winning leaf can read it directly.
|
||||||
|
root_move: Option<InternalMove>,
|
||||||
}
|
}
|
||||||
// Quick exits before allocating the stack.
|
// Quick exits before allocating the stack. An already-won state
|
||||||
|
// surfaces as Winnable with no move to recommend (the player has
|
||||||
|
// nothing left to do); the engine treats this gracefully —
|
||||||
|
// `is_won` callers gate H-key hints on `!is_won` already.
|
||||||
if self.is_won() {
|
if self.is_won() {
|
||||||
return true;
|
return None;
|
||||||
}
|
}
|
||||||
if *moves_consumed >= config.move_budget || visited.len() >= config.state_budget {
|
if *moves_consumed >= config.move_budget || visited.len() >= config.state_budget {
|
||||||
*budget_exceeded = true;
|
*budget_exceeded = true;
|
||||||
return false;
|
return None;
|
||||||
}
|
}
|
||||||
let root_hash = self.canonical_hash();
|
let root_hash = self.canonical_hash();
|
||||||
if !visited.insert(root_hash) {
|
if !visited.insert(root_hash) {
|
||||||
return false;
|
return None;
|
||||||
}
|
}
|
||||||
let root_moves = self.enumerate_moves();
|
let root_moves = self.enumerate_moves();
|
||||||
let mut stack: Vec<Frame> = Vec::new();
|
let mut stack: Vec<Frame> = Vec::new();
|
||||||
stack.push(Frame {
|
stack.push(Frame {
|
||||||
state: self,
|
state: self,
|
||||||
pending: root_moves.into_iter(),
|
pending: root_moves.into_iter(),
|
||||||
|
root_move: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
while let Some(frame) = stack.last_mut() {
|
while let Some(frame) = stack.last_mut() {
|
||||||
@@ -457,7 +575,7 @@ impl SolverState {
|
|||||||
|| visited.len() >= config.state_budget
|
|| visited.len() >= config.state_budget
|
||||||
{
|
{
|
||||||
*budget_exceeded = true;
|
*budget_exceeded = true;
|
||||||
return false;
|
return None;
|
||||||
}
|
}
|
||||||
let Some(mv) = frame.pending.next() else {
|
let Some(mv) = frame.pending.next() else {
|
||||||
// Exhausted this frame's children — backtrack.
|
// Exhausted this frame's children — backtrack.
|
||||||
@@ -465,10 +583,15 @@ impl SolverState {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
*moves_consumed = moves_consumed.saturating_add(1);
|
*moves_consumed = moves_consumed.saturating_add(1);
|
||||||
|
// Determine the root-level move for the *child* we are about
|
||||||
|
// to push: if the current frame is the root (root_move is
|
||||||
|
// None) then the child's root move is `mv` itself; otherwise
|
||||||
|
// it inherits from the parent.
|
||||||
|
let child_root_move = frame.root_move.unwrap_or(mv);
|
||||||
let mut next = frame.state.clone();
|
let mut next = frame.state.clone();
|
||||||
next.apply_move(mv);
|
next.apply_move(mv);
|
||||||
if next.is_won() {
|
if next.is_won() {
|
||||||
return true;
|
return Some(child_root_move.to_public());
|
||||||
}
|
}
|
||||||
let h = next.canonical_hash();
|
let h = next.canonical_hash();
|
||||||
if !visited.insert(h) {
|
if !visited.insert(h) {
|
||||||
@@ -478,9 +601,74 @@ impl SolverState {
|
|||||||
stack.push(Frame {
|
stack.push(Frame {
|
||||||
state: next,
|
state: next,
|
||||||
pending: next_moves.into_iter(),
|
pending: next_moves.into_iter(),
|
||||||
|
root_move: Some(child_root_move),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
false
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive [`SolverState::search`] and convert the raw outcome into a
|
||||||
|
/// public [`SolveOutcome`]. Shared by [`try_solve_with_first_move`]
|
||||||
|
/// and [`try_solve_from_state`].
|
||||||
|
fn solve(self, config: &SolverConfig) -> SolveOutcome {
|
||||||
|
let mut visited: HashSet<u64> = HashSet::new();
|
||||||
|
let mut moves_consumed: u64 = 0;
|
||||||
|
let mut budget_exceeded = false;
|
||||||
|
let already_won = self.is_won();
|
||||||
|
let first_move = self.search(config, &mut visited, &mut moves_consumed, &mut budget_exceeded);
|
||||||
|
let result = if already_won || first_move.is_some() {
|
||||||
|
SolverResult::Winnable
|
||||||
|
} else if budget_exceeded {
|
||||||
|
SolverResult::Inconclusive
|
||||||
|
} else {
|
||||||
|
SolverResult::Unwinnable
|
||||||
|
};
|
||||||
|
SolveOutcome { result, first_move }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `SolverState` from an in-progress [`GameState`].
|
||||||
|
///
|
||||||
|
/// Reads the live pile contents and `draw_mode`. Missing piles are
|
||||||
|
/// treated as empty — the engine's `GameState::new` always populates
|
||||||
|
/// every pile slot, but defensive code keeps this loader safe in the
|
||||||
|
/// face of partially-constructed test fixtures.
|
||||||
|
///
|
||||||
|
/// The search-metadata fields (`just_drew`, `consecutive_draws`)
|
||||||
|
/// reset to "no draws yet" — the solver is concerned with future
|
||||||
|
/// reachability from this position, not the engine's own draw
|
||||||
|
/// history.
|
||||||
|
fn from_game_state(game: &GameState) -> Self {
|
||||||
|
let tableau: [Vec<Card>; 7] = core::array::from_fn(|i| {
|
||||||
|
game.piles
|
||||||
|
.get(&PileType::Tableau(i))
|
||||||
|
.map(|p| p.cards.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
});
|
||||||
|
let foundation: [Vec<Card>; 4] = core::array::from_fn(|i| {
|
||||||
|
game.piles
|
||||||
|
.get(&PileType::Foundation(i as u8))
|
||||||
|
.map(|p| p.cards.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
});
|
||||||
|
let stock = game
|
||||||
|
.piles
|
||||||
|
.get(&PileType::Stock)
|
||||||
|
.map(|p| p.cards.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let waste = game
|
||||||
|
.piles
|
||||||
|
.get(&PileType::Waste)
|
||||||
|
.map(|p| p.cards.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
Self {
|
||||||
|
tableau,
|
||||||
|
foundation,
|
||||||
|
stock,
|
||||||
|
waste,
|
||||||
|
draw_mode: game.draw_mode.clone(),
|
||||||
|
just_drew: false,
|
||||||
|
consecutive_draws: 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a deterministic 64-bit hash of the visible game state.
|
/// Build a deterministic 64-bit hash of the visible game state.
|
||||||
@@ -656,9 +844,9 @@ mod tests {
|
|||||||
let mut moves_consumed: u64 = 0;
|
let mut moves_consumed: u64 = 0;
|
||||||
let mut budget_exceeded = false;
|
let mut budget_exceeded = false;
|
||||||
let cfg = SolverConfig::default();
|
let cfg = SolverConfig::default();
|
||||||
let won = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded);
|
let first_move = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded);
|
||||||
|
|
||||||
assert!(won, "obviously-winnable position must be recognised as Winnable");
|
assert!(first_move.is_some(), "obviously-winnable position must be recognised as Winnable");
|
||||||
assert!(!budget_exceeded);
|
assert!(!budget_exceeded);
|
||||||
assert!(
|
assert!(
|
||||||
moves_consumed < 1000,
|
moves_consumed < 1000,
|
||||||
@@ -699,8 +887,8 @@ mod tests {
|
|||||||
let mut visited: HashSet<u64> = HashSet::new();
|
let mut visited: HashSet<u64> = HashSet::new();
|
||||||
let mut moves_consumed: u64 = 0;
|
let mut moves_consumed: u64 = 0;
|
||||||
let mut budget_exceeded = false;
|
let mut budget_exceeded = false;
|
||||||
let won = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded);
|
let first_move = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded);
|
||||||
assert!(!won, "buried Ace under same-suit Two with no recovery must not solve");
|
assert!(first_move.is_none(), "buried Ace under same-suit Two with no recovery must not solve");
|
||||||
assert!(!budget_exceeded, "small synthetic state must complete within budget");
|
assert!(!budget_exceeded, "small synthetic state must complete within budget");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -890,4 +1078,170 @@ mod tests {
|
|||||||
counts[0], counts[1], counts[2],
|
counts[0], counts[1], counts[2],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// First-move-recording API: try_solve_with_first_move /
|
||||||
|
// try_solve_from_state. Exercised by the engine hint system.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A synthetic GameState with each foundation holding A..Q for its
|
||||||
|
/// suit, the four Kings sitting on tableau columns 0..3, empty stock
|
||||||
|
/// and empty waste. Exactly four legal moves exist — one Tableau→
|
||||||
|
/// Foundation per King — and any one of them is the first move on a
|
||||||
|
/// solution path.
|
||||||
|
fn near_finished_game_state() -> GameState {
|
||||||
|
use crate::card::Rank;
|
||||||
|
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||||
|
// Wipe every pile.
|
||||||
|
for slot in 0..4_u8 {
|
||||||
|
game.piles
|
||||||
|
.get_mut(&PileType::Foundation(slot))
|
||||||
|
.unwrap()
|
||||||
|
.cards
|
||||||
|
.clear();
|
||||||
|
}
|
||||||
|
for i in 0..7_usize {
|
||||||
|
game.piles
|
||||||
|
.get_mut(&PileType::Tableau(i))
|
||||||
|
.unwrap()
|
||||||
|
.cards
|
||||||
|
.clear();
|
||||||
|
}
|
||||||
|
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||||
|
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||||
|
|
||||||
|
// Foundations: A through Q for each suit. Slot 0=Clubs,
|
||||||
|
// 1=Diamonds, 2=Hearts, 3=Spades to match
|
||||||
|
// `target_foundation_slot` ordering.
|
||||||
|
let suit_for_slot = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||||
|
let ranks_below_king = [
|
||||||
|
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
|
||||||
|
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
|
||||||
|
Rank::Jack, Rank::Queen,
|
||||||
|
];
|
||||||
|
for (slot, suit) in suit_for_slot.iter().enumerate() {
|
||||||
|
let pile = game
|
||||||
|
.piles
|
||||||
|
.get_mut(&PileType::Foundation(slot as u8))
|
||||||
|
.unwrap();
|
||||||
|
for (i, rank) in ranks_below_king.iter().enumerate() {
|
||||||
|
pile.cards.push(Card {
|
||||||
|
id: (slot as u32) * 13 + i as u32,
|
||||||
|
suit: *suit,
|
||||||
|
rank: *rank,
|
||||||
|
face_up: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Tableau 0..3: one King each, face-up.
|
||||||
|
for (col, suit) in suit_for_slot.iter().enumerate() {
|
||||||
|
game.piles
|
||||||
|
.get_mut(&PileType::Tableau(col))
|
||||||
|
.unwrap()
|
||||||
|
.cards
|
||||||
|
.push(Card {
|
||||||
|
id: 100 + col as u32,
|
||||||
|
suit: *suit,
|
||||||
|
rank: Rank::King,
|
||||||
|
face_up: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
game
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn try_solve_with_first_move_returns_some_move_for_winnable_state() {
|
||||||
|
let game = near_finished_game_state();
|
||||||
|
let cfg = SolverConfig::default();
|
||||||
|
let outcome = try_solve_from_state(&game, &cfg);
|
||||||
|
assert_eq!(
|
||||||
|
outcome.result,
|
||||||
|
SolverResult::Winnable,
|
||||||
|
"near-finished state must solve as Winnable"
|
||||||
|
);
|
||||||
|
let mv = outcome.first_move.expect("Winnable must include a first_move");
|
||||||
|
// The first move must be a King going from a tableau column to
|
||||||
|
// its matching foundation slot. Single-card move.
|
||||||
|
assert_eq!(mv.count, 1);
|
||||||
|
assert!(matches!(mv.source, PileType::Tableau(c) if c < 4));
|
||||||
|
assert!(matches!(mv.dest, PileType::Foundation(s) if s < 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn try_solve_with_first_move_returns_none_for_unwinnable_state() {
|
||||||
|
use crate::card::Rank;
|
||||||
|
// The "buried Ace under same-suit Two with no recovery" fixture
|
||||||
|
// used by `solver_recognises_obviously_unwinnable_deal`, lifted
|
||||||
|
// into a real `GameState` so we can exercise `try_solve_from_state`.
|
||||||
|
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||||
|
for slot in 0..4_u8 {
|
||||||
|
game.piles
|
||||||
|
.get_mut(&PileType::Foundation(slot))
|
||||||
|
.unwrap()
|
||||||
|
.cards
|
||||||
|
.clear();
|
||||||
|
}
|
||||||
|
for i in 0..7_usize {
|
||||||
|
game.piles
|
||||||
|
.get_mut(&PileType::Tableau(i))
|
||||||
|
.unwrap()
|
||||||
|
.cards
|
||||||
|
.clear();
|
||||||
|
}
|
||||||
|
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||||
|
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||||
|
// Tableau 0: A♠ on the bottom, 2♠ on top — the 2♠ has no legal
|
||||||
|
// destination, so the Ace is buried forever.
|
||||||
|
let t0 = game.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||||
|
t0.cards.push(Card { id: 0, suit: Suit::Spades, rank: Rank::Ace, face_up: true });
|
||||||
|
t0.cards.push(Card { id: 1, suit: Suit::Spades, rank: Rank::Two, face_up: true });
|
||||||
|
// Tableau 1: a face-up King with nothing else — irrelevant; the
|
||||||
|
// pruning check elides "King → empty" no-ops.
|
||||||
|
game.piles
|
||||||
|
.get_mut(&PileType::Tableau(1))
|
||||||
|
.unwrap()
|
||||||
|
.cards
|
||||||
|
.push(Card { id: 2, suit: Suit::Clubs, rank: Rank::King, face_up: true });
|
||||||
|
|
||||||
|
let cfg = SolverConfig::default();
|
||||||
|
let outcome = try_solve_from_state(&game, &cfg);
|
||||||
|
assert_eq!(
|
||||||
|
outcome.result,
|
||||||
|
SolverResult::Unwinnable,
|
||||||
|
"buried-Ace fixture must be proved Unwinnable"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
outcome.first_move.is_none(),
|
||||||
|
"Unwinnable verdict must carry first_move == None"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn try_solve_with_first_move_is_deterministic() {
|
||||||
|
// Same state run multiple times yields the same first_move.
|
||||||
|
let game = near_finished_game_state();
|
||||||
|
let cfg = SolverConfig::default();
|
||||||
|
let a = try_solve_from_state(&game, &cfg);
|
||||||
|
let b = try_solve_from_state(&game, &cfg);
|
||||||
|
let c = try_solve_from_state(&game, &cfg);
|
||||||
|
assert_eq!(a, b, "repeat solves must yield the same outcome");
|
||||||
|
assert_eq!(b, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn try_solve_with_first_move_seed_form_matches_state_form() {
|
||||||
|
// For a fresh seed, the two public entry points must agree —
|
||||||
|
// they share the same internal `solve()` implementation, but
|
||||||
|
// route through different state constructors. This is the
|
||||||
|
// smoke test that catches drift between them.
|
||||||
|
let cfg = SolverConfig {
|
||||||
|
move_budget: 5_000,
|
||||||
|
state_budget: 5_000,
|
||||||
|
};
|
||||||
|
let a = try_solve_with_first_move(7, DrawMode::DrawOne, &cfg);
|
||||||
|
let game = GameState::new(7, DrawMode::DrawOne);
|
||||||
|
let b = try_solve_from_state(&game, &cfg);
|
||||||
|
assert_eq!(a.result, b.result, "verdicts must match across the two entry points");
|
||||||
|
assert_eq!(a.first_move, b.first_move, "first_move must match across the two entry points");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,9 +90,4 @@ mod tests {
|
|||||||
seeds.dedup();
|
seeds.dedup();
|
||||||
assert_eq!(seeds.len(), len_before);
|
assert_eq!(seeds.len(), len_before);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_count_matches_seed_list_length() {
|
|
||||||
assert_eq!(challenge_count() as usize, CHALLENGE_SEEDS.len());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,13 +56,13 @@ pub trait SyncProvider: Send + Sync {
|
|||||||
async fn delete_account(&self) -> Result<(), SyncError> {
|
async fn delete_account(&self) -> Result<(), SyncError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
/// Upload a winning replay to the backend so it's available for web
|
/// Upload a winning replay to the backend. On success, returns the
|
||||||
/// playback at `<server>/replays/<id>`. Default returns
|
/// shareable web URL the player can copy to their clipboard
|
||||||
/// `UnsupportedPlatform` so backends without a server (e.g.
|
/// (`<server>/replays/<id>`). Default returns `UnsupportedPlatform`
|
||||||
/// `LocalOnlyProvider`) are silently no-op'd by the engine's
|
/// so backends without a server (e.g. `LocalOnlyProvider`) are
|
||||||
/// push-on-win system, matching the same pattern `pull` / `push`
|
/// silently no-op'd by the engine's push-on-win system, matching
|
||||||
/// follow.
|
/// the same pattern `pull` / `push` follow.
|
||||||
async fn push_replay(&self, _replay: &crate::replay::Replay) -> Result<(), SyncError> {
|
async fn push_replay(&self, _replay: &crate::replay::Replay) -> Result<String, SyncError> {
|
||||||
Err(SyncError::UnsupportedPlatform)
|
Err(SyncError::UnsupportedPlatform)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,7 +101,7 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
|
|||||||
async fn delete_account(&self) -> Result<(), SyncError> {
|
async fn delete_account(&self) -> Result<(), SyncError> {
|
||||||
(**self).delete_account().await
|
(**self).delete_account().await
|
||||||
}
|
}
|
||||||
async fn push_replay(&self, replay: &crate::replay::Replay) -> Result<(), SyncError> {
|
async fn push_replay(&self, replay: &crate::replay::Replay) -> Result<String, SyncError> {
|
||||||
(**self).push_replay(replay).await
|
(**self).push_replay(replay).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,7 +141,8 @@ pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
|
|||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub use settings::{
|
pub use settings::{
|
||||||
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
|
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
|
||||||
Theme, WindowGeometry, SOLVER_DEAL_RETRY_CAP, TIME_BONUS_MULTIPLIER_MAX,
|
Theme, WindowGeometry, REPLAY_MOVE_INTERVAL_MAX_SECS, REPLAY_MOVE_INTERVAL_MIN_SECS,
|
||||||
|
REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, TIME_BONUS_MULTIPLIER_MAX,
|
||||||
TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_MAX_SECS,
|
TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_MAX_SECS,
|
||||||
TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
|
TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -162,21 +162,6 @@ mod tests {
|
|||||||
|
|
||||||
// --- Persistence ---
|
// --- Persistence ---
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn round_trip_save_and_load() {
|
|
||||||
let path = tmp_path("round_trip");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
|
|
||||||
let mut p = PlayerProgress::default();
|
|
||||||
p.add_xp(1234);
|
|
||||||
p.unlocked_card_backs.push(2);
|
|
||||||
save_progress_to(&path, &p).expect("save");
|
|
||||||
let loaded = load_progress_from(&path);
|
|
||||||
assert_eq!(loaded.total_xp, 1234);
|
|
||||||
assert_eq!(loaded.level, p.level);
|
|
||||||
assert!(loaded.unlocked_card_backs.contains(&2));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_from_missing_file_returns_default() {
|
fn load_from_missing_file_returns_default() {
|
||||||
let path = tmp_path("missing_xyz");
|
let path = tmp_path("missing_xyz");
|
||||||
|
|||||||
+78
-449
@@ -181,6 +181,17 @@ pub struct Settings {
|
|||||||
/// solver retry loop — see `solitaire_engine::handle_new_game`.
|
/// solver retry loop — see `solitaire_engine::handle_new_game`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub winnable_deals_only: bool,
|
pub winnable_deals_only: bool,
|
||||||
|
/// Per-move duration during replay playback, in seconds. Range
|
||||||
|
/// `[REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS]`;
|
||||||
|
/// default mirrors `solitaire_engine::replay_playback::REPLAY_MOVE_INTERVAL_SECS`
|
||||||
|
/// (0.45 s/move) so existing playback behaviour is unchanged for
|
||||||
|
/// players who never touch the slider. Smaller values scrub
|
||||||
|
/// faster through the recorded move list. Older `settings.json`
|
||||||
|
/// files written before this field existed deserialize cleanly to
|
||||||
|
/// the default via
|
||||||
|
/// `#[serde(default = "default_replay_move_interval_secs")]`.
|
||||||
|
#[serde(default = "default_replay_move_interval_secs")]
|
||||||
|
pub replay_move_interval_secs: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_draw_mode() -> DrawMode {
|
fn default_draw_mode() -> DrawMode {
|
||||||
@@ -238,6 +249,33 @@ fn default_time_bonus_multiplier() -> f32 {
|
|||||||
1.0
|
1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Default per-move duration during replay playback, in seconds.
|
||||||
|
/// Mirrors `solitaire_engine::replay_playback::REPLAY_MOVE_INTERVAL_SECS`
|
||||||
|
/// so legacy `settings.json` files load to the existing baseline and
|
||||||
|
/// playback feels identical for players who never touch the slider.
|
||||||
|
/// The constant is duplicated across the data and engine crates
|
||||||
|
/// because `solitaire_data` cannot depend on the engine crate — keep
|
||||||
|
/// the two values in sync when adjusting either.
|
||||||
|
fn default_replay_move_interval_secs() -> f32 {
|
||||||
|
0.45
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lower bound of the player-tunable replay-playback per-move interval,
|
||||||
|
/// in seconds. Below this the cards barely register visually before
|
||||||
|
/// the next move fires; the cap keeps the playback legible.
|
||||||
|
pub const REPLAY_MOVE_INTERVAL_MIN_SECS: f32 = 0.10;
|
||||||
|
|
||||||
|
/// Upper bound of the player-tunable replay-playback per-move interval,
|
||||||
|
/// in seconds. One second per move is a comfortable upper limit for
|
||||||
|
/// players who want to study a recorded game frame by frame.
|
||||||
|
pub const REPLAY_MOVE_INTERVAL_MAX_SECS: f32 = 1.00;
|
||||||
|
|
||||||
|
/// Increment applied by the replay-playback decrement / increment
|
||||||
|
/// buttons. 0.05 s gives 19 stops between MIN and MAX — fine-grained
|
||||||
|
/// enough to land on any "round" speed (0.10 s, 0.25 s, 0.45 s, etc.)
|
||||||
|
/// without making the slider feel stuck on the same value.
|
||||||
|
pub const REPLAY_MOVE_INTERVAL_STEP_SECS: f32 = 0.05;
|
||||||
|
|
||||||
/// Maximum number of seed retries [`solitaire_engine::handle_new_game`]
|
/// Maximum number of seed retries [`solitaire_engine::handle_new_game`]
|
||||||
/// is willing to attempt before giving up and accepting the latest
|
/// is willing to attempt before giving up and accepting the latest
|
||||||
/// candidate seed when [`Settings::winnable_deals_only`] is on. If
|
/// candidate seed when [`Settings::winnable_deals_only`] is on. If
|
||||||
@@ -268,14 +306,16 @@ impl Default for Settings {
|
|||||||
tooltip_delay_secs: default_tooltip_delay(),
|
tooltip_delay_secs: default_tooltip_delay(),
|
||||||
time_bonus_multiplier: default_time_bonus_multiplier(),
|
time_bonus_multiplier: default_time_bonus_multiplier(),
|
||||||
winnable_deals_only: false,
|
winnable_deals_only: false,
|
||||||
|
replay_move_interval_secs: default_replay_move_interval_secs(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Settings {
|
impl Settings {
|
||||||
/// Clamps `sfx_volume`, `music_volume`, `tooltip_delay_secs`, and
|
/// Clamps `sfx_volume`, `music_volume`, `tooltip_delay_secs`,
|
||||||
/// `time_bonus_multiplier` into their respective ranges after
|
/// `time_bonus_multiplier`, and `replay_move_interval_secs` into
|
||||||
/// deserialization or hand-editing of `settings.json`.
|
/// their respective ranges after deserialization or hand-editing of
|
||||||
|
/// `settings.json`.
|
||||||
pub fn sanitized(self) -> Self {
|
pub fn sanitized(self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
sfx_volume: self.sfx_volume.clamp(0.0, 1.0),
|
sfx_volume: self.sfx_volume.clamp(0.0, 1.0),
|
||||||
@@ -286,6 +326,9 @@ impl Settings {
|
|||||||
time_bonus_multiplier: self
|
time_bonus_multiplier: self
|
||||||
.time_bonus_multiplier
|
.time_bonus_multiplier
|
||||||
.clamp(TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX),
|
.clamp(TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX),
|
||||||
|
replay_move_interval_secs: self
|
||||||
|
.replay_move_interval_secs
|
||||||
|
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS),
|
||||||
..self
|
..self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,6 +367,21 @@ impl Settings {
|
|||||||
self.time_bonus_multiplier = (raw * 10.0).round() / 10.0;
|
self.time_bonus_multiplier = (raw * 10.0).round() / 10.0;
|
||||||
self.time_bonus_multiplier
|
self.time_bonus_multiplier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adjust the replay-playback per-move interval by `delta`
|
||||||
|
/// seconds, clamped to
|
||||||
|
/// `[REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS]`.
|
||||||
|
/// The result is rounded to two decimal places so the readout
|
||||||
|
/// stays clean across repeated `±` clicks at the 0.05 s step
|
||||||
|
/// (avoids float drift like `0.45000003`). Returns the new value.
|
||||||
|
pub fn adjust_replay_move_interval(&mut self, delta: f32) -> f32 {
|
||||||
|
let raw = (self.replay_move_interval_secs + delta)
|
||||||
|
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS);
|
||||||
|
// Round to 2 decimal places — the slider step is 0.05, so this
|
||||||
|
// collapses any FP drift introduced by repeated additions.
|
||||||
|
self.replay_move_interval_secs = (raw * 100.0).round() / 100.0;
|
||||||
|
self.replay_move_interval_secs
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the platform-specific path to `settings.json`, or `None` if
|
/// Returns the platform-specific path to `settings.json`, or `None` if
|
||||||
@@ -364,19 +422,6 @@ mod tests {
|
|||||||
env::temp_dir().join(format!("solitaire_settings_test_{name}.json"))
|
env::temp_dir().join(format!("solitaire_settings_test_{name}.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn defaults_are_reasonable() {
|
|
||||||
let s = Settings::default();
|
|
||||||
assert!((s.sfx_volume - 0.8).abs() < 1e-6);
|
|
||||||
assert!((s.music_volume - 0.5).abs() < 1e-6);
|
|
||||||
assert!(!s.first_run_complete);
|
|
||||||
assert_eq!(s.draw_mode, DrawMode::DrawOne);
|
|
||||||
assert_eq!(s.animation_speed, AnimSpeed::Normal);
|
|
||||||
assert_eq!(s.theme, Theme::Green);
|
|
||||||
assert_eq!(s.sync_backend, SyncBackend::Local);
|
|
||||||
assert!((s.tooltip_delay_secs - default_tooltip_delay()).abs() < 1e-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn adjust_sfx_volume_clamps() {
|
fn adjust_sfx_volume_clamps() {
|
||||||
let mut s = Settings { sfx_volume: 0.5, ..Default::default() };
|
let mut s = Settings { sfx_volume: 0.5, ..Default::default() };
|
||||||
@@ -409,76 +454,6 @@ mod tests {
|
|||||||
assert!(s.first_run_complete);
|
assert!(s.first_run_complete);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sanitized_clamps_music_volume() {
|
|
||||||
let s = Settings { music_volume: 2.0, ..Default::default() }.sanitized();
|
|
||||||
assert_eq!(s.music_volume, 1.0);
|
|
||||||
|
|
||||||
let s2 = Settings { music_volume: -0.5, ..Default::default() }.sanitized();
|
|
||||||
assert_eq!(s2.music_volume, 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn round_trip_save_and_load() {
|
|
||||||
let path = tmp_path("round_trip");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
let s = Settings {
|
|
||||||
sfx_volume: 0.42,
|
|
||||||
first_run_complete: true,
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
save_settings_to(&path, &s).expect("save");
|
|
||||||
let loaded = load_settings_from(&path);
|
|
||||||
assert_eq!(loaded, s);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn round_trip_save_and_load_full_settings() {
|
|
||||||
let path = tmp_path("round_trip_full");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
let s = Settings {
|
|
||||||
draw_mode: DrawMode::DrawThree,
|
|
||||||
sfx_volume: 0.3,
|
|
||||||
music_volume: 0.7,
|
|
||||||
animation_speed: AnimSpeed::Fast,
|
|
||||||
theme: Theme::Dark,
|
|
||||||
sync_backend: SyncBackend::SolitaireServer {
|
|
||||||
url: "https://example.com".to_string(),
|
|
||||||
username: "testuser".to_string(),
|
|
||||||
},
|
|
||||||
selected_card_back: 0,
|
|
||||||
selected_background: 0,
|
|
||||||
first_run_complete: true,
|
|
||||||
color_blind_mode: false,
|
|
||||||
window_geometry: None,
|
|
||||||
selected_theme_id: "default".to_string(),
|
|
||||||
shown_achievement_onboarding: false,
|
|
||||||
tooltip_delay_secs: default_tooltip_delay(),
|
|
||||||
time_bonus_multiplier: default_time_bonus_multiplier(),
|
|
||||||
winnable_deals_only: false,
|
|
||||||
};
|
|
||||||
save_settings_to(&path, &s).expect("save");
|
|
||||||
let loaded = load_settings_from(&path);
|
|
||||||
assert_eq!(loaded, s);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn round_trip_preserves_non_default_cosmetic_selections() {
|
|
||||||
// selected_card_back and selected_background must survive save→load with
|
|
||||||
// non-zero values — zero is the default and not a meaningful regression check.
|
|
||||||
let path = tmp_path("cosmetic_selections");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
let s = Settings {
|
|
||||||
selected_card_back: 3,
|
|
||||||
selected_background: 2,
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
save_settings_to(&path, &s).expect("save");
|
|
||||||
let loaded = load_settings_from(&path);
|
|
||||||
assert_eq!(loaded.selected_card_back, 3);
|
|
||||||
assert_eq!(loaded.selected_background, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_from_missing_file_returns_default() {
|
fn load_from_missing_file_returns_default() {
|
||||||
let path = tmp_path("missing_xyz");
|
let path = tmp_path("missing_xyz");
|
||||||
@@ -495,250 +470,6 @@ mod tests {
|
|||||||
assert_eq!(s, Settings::default());
|
assert_eq!(s, Settings::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_from_old_format_uses_defaults_for_new_fields() {
|
|
||||||
// Simulate a settings.json written by an older version that only had
|
|
||||||
// sfx_volume and first_run_complete.
|
|
||||||
let path = tmp_path("old_format");
|
|
||||||
fs::write(
|
|
||||||
&path,
|
|
||||||
br#"{ "sfx_volume": 0.6, "first_run_complete": true }"#,
|
|
||||||
)
|
|
||||||
.expect("write");
|
|
||||||
let s = load_settings_from(&path);
|
|
||||||
assert!((s.sfx_volume - 0.6).abs() < 1e-6);
|
|
||||||
assert!(s.first_run_complete);
|
|
||||||
// New fields should fall back to their defaults.
|
|
||||||
assert!((s.music_volume - 0.5).abs() < 1e-6);
|
|
||||||
assert_eq!(s.animation_speed, AnimSpeed::Normal);
|
|
||||||
assert_eq!(s.theme, Theme::Green);
|
|
||||||
assert_eq!(s.sync_backend, SyncBackend::Local);
|
|
||||||
assert_eq!(s.draw_mode, DrawMode::DrawOne);
|
|
||||||
assert_eq!(s.selected_card_back, 0, "cosmetic card-back must default to 0 on old format");
|
|
||||||
assert_eq!(s.selected_background, 0, "cosmetic background must default to 0 on old format");
|
|
||||||
assert!(!s.color_blind_mode, "color_blind_mode must default to false on old format");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn color_blind_mode_defaults_to_false_when_field_absent() {
|
|
||||||
// Simulate a JSON file that has no color_blind_mode field.
|
|
||||||
let json = br#"{ "sfx_volume": 0.7 }"#;
|
|
||||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
|
||||||
assert!(!s.color_blind_mode, "color_blind_mode must be false when absent from JSON");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn color_blind_mode_round_trips() {
|
|
||||||
let path = tmp_path("color_blind");
|
|
||||||
let _ = std::fs::remove_file(&path);
|
|
||||||
let s = Settings {
|
|
||||||
color_blind_mode: true,
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
save_settings_to(&path, &s).expect("save");
|
|
||||||
let loaded = load_settings_from(&path);
|
|
||||||
assert!(loaded.color_blind_mode, "color_blind_mode must survive a save/load round-trip");
|
|
||||||
let _ = std::fs::remove_file(&path);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Task #62 — selected_card_back
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_card_back_default_is_zero() {
|
|
||||||
assert_eq!(Settings::default().selected_card_back, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_card_back_serializes_round_trip() {
|
|
||||||
let path = tmp_path("card_back_round_trip");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
let s = Settings {
|
|
||||||
selected_card_back: 2,
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
save_settings_to(&path, &s).expect("save");
|
|
||||||
let loaded = load_settings_from(&path);
|
|
||||||
assert_eq!(loaded.selected_card_back, 2, "selected_card_back must survive serde round-trip");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Task #63 — selected_background
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_background_default_is_zero() {
|
|
||||||
assert_eq!(Settings::default().selected_background, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_background_serializes_round_trip() {
|
|
||||||
let path = tmp_path("background_round_trip");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
let s = Settings {
|
|
||||||
selected_background: 3,
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
save_settings_to(&path, &s).expect("save");
|
|
||||||
let loaded = load_settings_from(&path);
|
|
||||||
assert_eq!(loaded.selected_background, 3, "selected_background must survive serde round-trip");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// window_geometry — persisted window size/position
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_window_geometry_default_is_none() {
|
|
||||||
assert!(
|
|
||||||
Settings::default().window_geometry.is_none(),
|
|
||||||
"default window_geometry must be None so first launch uses platform defaults"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_with_window_geometry_round_trip() {
|
|
||||||
let path = tmp_path("window_geometry_round_trip");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
let geom = WindowGeometry {
|
|
||||||
width: 1440,
|
|
||||||
height: 900,
|
|
||||||
x: 120,
|
|
||||||
y: 80,
|
|
||||||
};
|
|
||||||
let s = Settings {
|
|
||||||
window_geometry: Some(geom),
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
save_settings_to(&path, &s).expect("save");
|
|
||||||
let loaded = load_settings_from(&path);
|
|
||||||
assert_eq!(
|
|
||||||
loaded.window_geometry,
|
|
||||||
Some(geom),
|
|
||||||
"window_geometry must survive serde round-trip"
|
|
||||||
);
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn legacy_settings_without_window_geometry_deserializes_to_none() {
|
|
||||||
// A settings.json written by an older version of the game will be
|
|
||||||
// missing this field entirely. `#[serde(default)]` on the field
|
|
||||||
// must yield `None` rather than failing the whole deserialise.
|
|
||||||
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
|
||||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
|
||||||
assert!(
|
|
||||||
s.window_geometry.is_none(),
|
|
||||||
"legacy settings.json missing window_geometry must deserialize to None"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn window_geometry_explicit_null_deserializes_to_none() {
|
|
||||||
// An explicit `"window_geometry": null` is also valid input that
|
|
||||||
// must yield None — keeps tooling that hand-edits the file safe.
|
|
||||||
let json = br#"{ "window_geometry": null }"#;
|
|
||||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
|
||||||
assert!(s.window_geometry.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// shown_achievement_onboarding — first-win cue one-shot guard
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_shown_achievement_onboarding_default_is_false() {
|
|
||||||
assert!(
|
|
||||||
!Settings::default().shown_achievement_onboarding,
|
|
||||||
"default shown_achievement_onboarding must be false so the cue fires once"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_shown_achievement_onboarding_round_trip() {
|
|
||||||
let path = tmp_path("achievement_onboarding_round_trip");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
let s = Settings {
|
|
||||||
shown_achievement_onboarding: true,
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
save_settings_to(&path, &s).expect("save");
|
|
||||||
let loaded = load_settings_from(&path);
|
|
||||||
assert!(
|
|
||||||
loaded.shown_achievement_onboarding,
|
|
||||||
"shown_achievement_onboarding must survive serde round-trip"
|
|
||||||
);
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn legacy_settings_without_shown_achievement_onboarding_deserializes_to_false() {
|
|
||||||
// A settings.json written by an older version of the game will be
|
|
||||||
// missing this field entirely. `#[serde(default)]` on the field
|
|
||||||
// must yield `false` — the cue then fires on the next win, but
|
|
||||||
// only when stats.games_won == 1, so existing players who have
|
|
||||||
// already won past their first game won't see the toast either.
|
|
||||||
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
|
||||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
|
||||||
assert!(
|
|
||||||
!s.shown_achievement_onboarding,
|
|
||||||
"legacy settings.json missing shown_achievement_onboarding must deserialize to false"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// tooltip_delay_secs — player-tunable tooltip hover delay
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_tooltip_delay_default_is_existing_baseline() {
|
|
||||||
// The existing baseline pre-slider is 0.5 s, matching the
|
|
||||||
// `MOTION_TOOLTIP_DELAY_SECS` constant in
|
|
||||||
// `solitaire_engine::ui_theme`. The default must not regress.
|
|
||||||
let s = Settings::default();
|
|
||||||
assert!(
|
|
||||||
(s.tooltip_delay_secs - 0.5).abs() < 1e-6,
|
|
||||||
"tooltip_delay_secs default must be 0.5 (the pre-slider baseline), got {}",
|
|
||||||
s.tooltip_delay_secs
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_tooltip_delay_round_trip() {
|
|
||||||
let path = tmp_path("tooltip_delay_round_trip");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
let s = Settings {
|
|
||||||
tooltip_delay_secs: 1.2,
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
save_settings_to(&path, &s).expect("save");
|
|
||||||
let loaded = load_settings_from(&path);
|
|
||||||
assert!(
|
|
||||||
(loaded.tooltip_delay_secs - 1.2).abs() < 1e-6,
|
|
||||||
"tooltip_delay_secs must survive serde round-trip; got {}",
|
|
||||||
loaded.tooltip_delay_secs
|
|
||||||
);
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn legacy_settings_without_tooltip_delay_deserializes_to_default() {
|
|
||||||
// A settings.json written before this field existed must
|
|
||||||
// deserialize cleanly to the existing 0.5 s baseline rather
|
|
||||||
// than failing the whole load or yielding a zero value.
|
|
||||||
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
|
||||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
|
||||||
assert!(
|
|
||||||
(s.tooltip_delay_secs - default_tooltip_delay()).abs() < 1e-6,
|
|
||||||
"legacy settings.json missing tooltip_delay_secs must deserialize to default ({}), got {}",
|
|
||||||
default_tooltip_delay(),
|
|
||||||
s.tooltip_delay_secs
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn adjust_tooltip_delay_clamps_to_range() {
|
fn adjust_tooltip_delay_clamps_to_range() {
|
||||||
let mut s = Settings { tooltip_delay_secs: 0.5, ..Default::default() };
|
let mut s = Settings { tooltip_delay_secs: 0.5, ..Default::default() };
|
||||||
@@ -752,90 +483,6 @@ mod tests {
|
|||||||
assert_eq!(s.tooltip_delay_secs, 0.0);
|
assert_eq!(s.tooltip_delay_secs, 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sanitized_clamps_out_of_range_tooltip_delay() {
|
|
||||||
// Negative or oversized values from a hand-edited file must be
|
|
||||||
// clamped on load.
|
|
||||||
let s = Settings {
|
|
||||||
tooltip_delay_secs: -0.4,
|
|
||||||
..Settings::default()
|
|
||||||
}
|
|
||||||
.sanitized();
|
|
||||||
assert_eq!(s.tooltip_delay_secs, TOOLTIP_DELAY_MIN_SECS);
|
|
||||||
|
|
||||||
let s2 = Settings {
|
|
||||||
tooltip_delay_secs: 99.0,
|
|
||||||
..Settings::default()
|
|
||||||
}
|
|
||||||
.sanitized();
|
|
||||||
assert_eq!(s2.tooltip_delay_secs, TOOLTIP_DELAY_MAX_SECS);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// time_bonus_multiplier — cosmetic win-modal time-bonus weight
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_time_bonus_multiplier_default_is_one() {
|
|
||||||
let s = Settings::default();
|
|
||||||
assert!(
|
|
||||||
(s.time_bonus_multiplier - 1.0).abs() < 1e-6,
|
|
||||||
"default time_bonus_multiplier must be 1.0 (no change to displayed bonus), got {}",
|
|
||||||
s.time_bonus_multiplier
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_time_bonus_multiplier_round_trip() {
|
|
||||||
let path = tmp_path("time_bonus_multiplier_round_trip");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
let s = Settings {
|
|
||||||
time_bonus_multiplier: 1.5,
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
save_settings_to(&path, &s).expect("save");
|
|
||||||
let loaded = load_settings_from(&path);
|
|
||||||
assert!(
|
|
||||||
(loaded.time_bonus_multiplier - 1.5).abs() < 1e-6,
|
|
||||||
"time_bonus_multiplier must survive serde round-trip; got {}",
|
|
||||||
loaded.time_bonus_multiplier
|
|
||||||
);
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn legacy_settings_without_time_bonus_multiplier_deserializes_to_one() {
|
|
||||||
// A settings.json written before this field existed must
|
|
||||||
// deserialize cleanly to the existing 1.0 baseline so old
|
|
||||||
// players see no change to their win-modal bonuses.
|
|
||||||
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
|
||||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
|
||||||
assert!(
|
|
||||||
(s.time_bonus_multiplier - 1.0).abs() < 1e-6,
|
|
||||||
"legacy settings.json missing time_bonus_multiplier must deserialize to 1.0, got {}",
|
|
||||||
s.time_bonus_multiplier
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_time_bonus_multiplier_clamps_to_range() {
|
|
||||||
// Negative or oversized values from a hand-edited file must be
|
|
||||||
// clamped on load.
|
|
||||||
let s = Settings {
|
|
||||||
time_bonus_multiplier: -0.5,
|
|
||||||
..Settings::default()
|
|
||||||
}
|
|
||||||
.sanitized();
|
|
||||||
assert_eq!(s.time_bonus_multiplier, TIME_BONUS_MULTIPLIER_MIN);
|
|
||||||
|
|
||||||
let s2 = Settings {
|
|
||||||
time_bonus_multiplier: 99.0,
|
|
||||||
..Settings::default()
|
|
||||||
}
|
|
||||||
.sanitized();
|
|
||||||
assert_eq!(s2.time_bonus_multiplier, TIME_BONUS_MULTIPLIER_MAX);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn adjust_time_bonus_multiplier_clamps_and_rounds() {
|
fn adjust_time_bonus_multiplier_clamps_and_rounds() {
|
||||||
let mut s = Settings { time_bonus_multiplier: 1.0, ..Default::default() };
|
let mut s = Settings { time_bonus_multiplier: 1.0, ..Default::default() };
|
||||||
@@ -864,48 +511,30 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// winnable_deals_only — solver-backed deal filter toggle
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn settings_winnable_deals_only_default_is_false() {
|
fn adjust_replay_move_interval_clamps_and_rounds() {
|
||||||
// Off by default — the solver adds latency we shouldn't impose
|
let mut s = Settings { replay_move_interval_secs: 0.45, ..Default::default() };
|
||||||
// on every player without their consent.
|
// Step down to 0.40.
|
||||||
|
assert!((s.adjust_replay_move_interval(-0.05) - 0.40).abs() < 1e-6);
|
||||||
|
// Big positive jump clamps to MAX.
|
||||||
assert!(
|
assert!(
|
||||||
!Settings::default().winnable_deals_only,
|
(s.adjust_replay_move_interval(99.0) - REPLAY_MOVE_INTERVAL_MAX_SECS).abs() < 1e-6
|
||||||
"default winnable_deals_only must be false"
|
|
||||||
);
|
);
|
||||||
}
|
// Big negative jump clamps to MIN.
|
||||||
|
|
||||||
#[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!(
|
assert!(
|
||||||
loaded.winnable_deals_only,
|
(s.adjust_replay_move_interval(-99.0) - REPLAY_MOVE_INTERVAL_MIN_SECS).abs() < 1e-6
|
||||||
"winnable_deals_only must survive serde round-trip"
|
|
||||||
);
|
);
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
// Repeated 0.05 steps must not drift past the 0.05 grid.
|
||||||
fn legacy_settings_without_winnable_deals_only_deserializes_to_false() {
|
let mut s2 = Settings { replay_move_interval_secs: 0.10, ..Default::default() };
|
||||||
// A settings.json written before this field existed must
|
for _ in 0..6 {
|
||||||
// deserialize cleanly to `false` (the default-off behaviour)
|
s2.adjust_replay_move_interval(0.05);
|
||||||
// rather than failing the whole load or surprising the player
|
}
|
||||||
// by switching the toggle on.
|
// After six +0.05 steps from 0.10, value should be exactly 0.40 (2 decimals).
|
||||||
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
|
||||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
|
||||||
assert!(
|
assert!(
|
||||||
!s.winnable_deals_only,
|
(s2.replay_move_interval_secs - 0.40).abs() < 1e-6,
|
||||||
"legacy settings.json missing winnable_deals_only must deserialize to false"
|
"rounding should pin repeated 0.05 steps to the decimal grid, got {}",
|
||||||
|
s2.replay_move_interval_secs
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -358,13 +358,12 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
extract_leaderboard_body(resp).await
|
extract_leaderboard_body(resp).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Upload a winning replay to `POST /api/replays`. Mirrors the
|
/// Upload a winning replay to `POST /api/replays`. On success the
|
||||||
/// `push` auth flow: 401 triggers a token refresh and one retry.
|
/// server returns `{ "id": "<uuid>" }`; this method composes that
|
||||||
/// Non-success statuses are surfaced as the relevant `SyncError`
|
/// id with the configured base URL into the player-shareable
|
||||||
/// variant so the engine's push-on-win system can downgrade
|
/// `<base>/replays/<id>` link and returns it. Mirrors the `push`
|
||||||
/// network/auth failures into a quiet log without aborting the
|
/// auth flow: 401 triggers a token refresh and one retry.
|
||||||
/// game flow.
|
async fn push_replay(&self, replay: &Replay) -> Result<String, SyncError> {
|
||||||
async fn push_replay(&self, replay: &Replay) -> Result<(), SyncError> {
|
|
||||||
let token = self.access_token()?;
|
let token = self.access_token()?;
|
||||||
let url = format!("{}/api/replays", self.base_url);
|
let url = format!("{}/api/replays", self.base_url);
|
||||||
|
|
||||||
@@ -388,22 +387,38 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
return check_replay_status(resp.status());
|
return self.share_url_from_response(resp).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
check_replay_status(resp.status())
|
self.share_url_from_response(resp).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check_replay_status(status: reqwest::StatusCode) -> Result<(), SyncError> {
|
impl SolitaireServerClient {
|
||||||
if status.is_success() {
|
/// Pulled out of `push_replay` so both the first attempt and the
|
||||||
Ok(())
|
/// post-401-retry attempt go through the same parse path.
|
||||||
} else if status == reqwest::StatusCode::UNAUTHORIZED
|
async fn share_url_from_response(
|
||||||
|| status == reqwest::StatusCode::FORBIDDEN
|
&self,
|
||||||
{
|
resp: reqwest::Response,
|
||||||
Err(SyncError::Auth(format!("server returned {status}")))
|
) -> Result<String, SyncError> {
|
||||||
} else {
|
let status = resp.status();
|
||||||
Err(SyncError::Network(format!("server returned {status}")))
|
if !status.is_success() {
|
||||||
|
return Err(if status == reqwest::StatusCode::UNAUTHORIZED
|
||||||
|
|| status == reqwest::StatusCode::FORBIDDEN
|
||||||
|
{
|
||||||
|
SyncError::Auth(format!("server returned {status}"))
|
||||||
|
} else {
|
||||||
|
SyncError::Network(format!("server returned {status}"))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let body: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Serialization(e.to_string()))?;
|
||||||
|
let id = body["id"].as_str().ok_or_else(|| {
|
||||||
|
SyncError::Serialization("upload response missing `id`".into())
|
||||||
|
})?;
|
||||||
|
Ok(format!("{}/replays/{}", self.base_url, id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ tiny-skia = { workspace = true }
|
|||||||
ron = { workspace = true }
|
ron = { workspace = true }
|
||||||
dirs = { workspace = true }
|
dirs = { workspace = true }
|
||||||
zip = { workspace = true }
|
zip = { workspace = true }
|
||||||
|
arboard = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use chrono::{Local, Timelike, Utc};
|
use chrono::{Local, Timelike, Utc};
|
||||||
use solitaire_core::achievement::{
|
use solitaire_core::achievement::{
|
||||||
@@ -31,6 +32,7 @@ use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
|||||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||||
|
ScrimDismissible,
|
||||||
};
|
};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
|
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
|
||||||
@@ -48,6 +50,19 @@ pub struct AchievementsScreen;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct AchievementRow;
|
pub struct AchievementRow;
|
||||||
|
|
||||||
|
/// Marker on the scrollable body Node inside the Achievements modal.
|
||||||
|
///
|
||||||
|
/// The Achievements list can grow to ~19 rows which overflows the modal at
|
||||||
|
/// the 800x600 minimum window. This marker tags the inner container that
|
||||||
|
/// carries `Overflow::scroll_y()` plus a `max_height` constraint so the
|
||||||
|
/// content scrolls instead of clipping. Mirrors the
|
||||||
|
/// `SettingsPanelScrollable` pattern in `settings_plugin`.
|
||||||
|
///
|
||||||
|
/// `scroll_achievements_panel` reads this marker to route mouse-wheel
|
||||||
|
/// events into the body's `ScrollPosition`.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct AchievementsScrollable;
|
||||||
|
|
||||||
/// All per-player achievement records (one per known achievement).
|
/// All per-player achievement records (one per known achievement).
|
||||||
#[derive(Resource, Debug, Clone)]
|
#[derive(Resource, Debug, Clone)]
|
||||||
pub struct AchievementsResource(pub Vec<AchievementRecord>);
|
pub struct AchievementsResource(pub Vec<AchievementRecord>);
|
||||||
@@ -96,6 +111,11 @@ impl Plugin for AchievementPlugin {
|
|||||||
.add_message::<XpAwardedEvent>()
|
.add_message::<XpAwardedEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.add_message::<ToggleAchievementsRequestEvent>()
|
.add_message::<ToggleAchievementsRequestEvent>()
|
||||||
|
// `MouseWheel` is emitted by Bevy's input plugin under
|
||||||
|
// `DefaultPlugins`; register it explicitly so the
|
||||||
|
// achievements-scroll system also runs cleanly under
|
||||||
|
// `MinimalPlugins` in tests.
|
||||||
|
.add_message::<MouseWheel>()
|
||||||
// Run after GameMutation (so GameWonEvent is available), after
|
// Run after GameMutation (so GameWonEvent is available), after
|
||||||
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
|
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
|
||||||
// (so daily_challenge_streak is up to date for daily_devotee).
|
// (so daily_challenge_streak is up to date for daily_devotee).
|
||||||
@@ -118,6 +138,7 @@ impl Plugin for AchievementPlugin {
|
|||||||
)
|
)
|
||||||
.add_systems(Update, toggle_achievements_screen)
|
.add_systems(Update, toggle_achievements_screen)
|
||||||
.add_systems(Update, handle_achievements_close_button)
|
.add_systems(Update, handle_achievements_close_button)
|
||||||
|
.add_systems(Update, scroll_achievements_panel)
|
||||||
// Event-driven unlock: observe `ReplayPlaybackState` and unlock
|
// Event-driven unlock: observe `ReplayPlaybackState` and unlock
|
||||||
// `cinephile` the first time playback runs to natural completion.
|
// `cinephile` the first time playback runs to natural completion.
|
||||||
// Reads the resource via `Option<Res<_>>` so headless tests that
|
// Reads the resource via `Option<Res<_>>` so headless tests that
|
||||||
@@ -395,6 +416,38 @@ fn handle_achievements_close_button(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Routes mouse-wheel events into the Achievements modal's scrollable body
|
||||||
|
/// while the panel is open.
|
||||||
|
///
|
||||||
|
/// `offset_y` increases downward (0 = top). Scrolling down (`ev.y < 0`) adds
|
||||||
|
/// to the offset; scrolling up subtracts. Clamped to >= 0 so the viewport
|
||||||
|
/// never scrolls past the top. Mirrors `scroll_settings_panel` in
|
||||||
|
/// `settings_plugin`. The query is empty when no `AchievementsScrollable`
|
||||||
|
/// is in the world (modal closed) so this is a no-op outside the open
|
||||||
|
/// state without an explicit gate resource.
|
||||||
|
fn scroll_achievements_panel(
|
||||||
|
mut scroll_evr: MessageReader<MouseWheel>,
|
||||||
|
mut scrollables: Query<&mut ScrollPosition, With<AchievementsScrollable>>,
|
||||||
|
) {
|
||||||
|
if scrollables.is_empty() {
|
||||||
|
scroll_evr.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let delta_y: f32 = scroll_evr
|
||||||
|
.read()
|
||||||
|
.map(|ev| match ev.unit {
|
||||||
|
MouseScrollUnit::Line => ev.y * 50.0,
|
||||||
|
MouseScrollUnit::Pixel => ev.y,
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
if delta_y == 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut sp in scrollables.iter_mut() {
|
||||||
|
sp.0.y = (sp.0.y - delta_y).max(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn spawn_achievements_screen(
|
fn spawn_achievements_screen(
|
||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
records: &[AchievementRecord],
|
records: &[AchievementRecord],
|
||||||
@@ -421,79 +474,119 @@ fn spawn_achievements_screen(
|
|||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
|
|
||||||
spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| {
|
let any_unlocked = records.iter().any(|r| r.unlocked);
|
||||||
|
|
||||||
|
let scrim = spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| {
|
||||||
spawn_modal_header(card, header, font_res);
|
spawn_modal_header(card, header, font_res);
|
||||||
|
|
||||||
// Achievement rows — unlocked first, then locked alphabetical.
|
// First-time hint — shown until the player has unlocked anything.
|
||||||
let mut sorted: Vec<_> = records.iter().collect();
|
// The list itself describes individual rewards, but a top-level
|
||||||
sorted.sort_by_key(|r| (!r.unlocked, r.id.clone()));
|
// explanation gives newer players context for the otherwise dense
|
||||||
|
// greyed-out grid.
|
||||||
for record in &sorted {
|
if !any_unlocked {
|
||||||
let def = achievement_by_id(&record.id);
|
|
||||||
let (name, description) = def.map_or((record.id.as_str(), ""), |d| (d.name, d.description));
|
|
||||||
|
|
||||||
// Hide secret locked achievements so they remain a surprise.
|
|
||||||
let is_secret = def.is_some_and(|d| d.secret);
|
|
||||||
if is_secret && !record.unlocked {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let (name_color, desc_color, prefix) = if record.unlocked {
|
|
||||||
(ACCENT_PRIMARY, TEXT_PRIMARY, "\u{2713} ")
|
|
||||||
} else {
|
|
||||||
(TEXT_DISABLED, TEXT_DISABLED, "\u{25CB} ")
|
|
||||||
};
|
|
||||||
|
|
||||||
let tooltip_text = tooltip_for_row(record.unlocked, def);
|
|
||||||
|
|
||||||
card.spawn((
|
card.spawn((
|
||||||
Node {
|
Text::new(
|
||||||
flex_direction: FlexDirection::Column,
|
"Complete games and try new modes to unlock achievements and rewards.",
|
||||||
row_gap: VAL_SPACE_1,
|
),
|
||||||
|
TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
AchievementRow,
|
TextColor(TEXT_SECONDARY),
|
||||||
Tooltip::new(tooltip_text),
|
|
||||||
))
|
|
||||||
.with_children(|row| {
|
|
||||||
row.spawn((
|
|
||||||
Text::new(format!("{prefix}{name}")),
|
|
||||||
font_name.clone(),
|
|
||||||
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.
|
|
||||||
card.spawn((
|
|
||||||
Node {
|
|
||||||
height: Val::Px(1.0),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(BORDER_SUBTLE),
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scrollable body — the achievements list grows to ~19 rows which
|
||||||
|
// overflows the modal on the 800x600 minimum window. Wrapping the
|
||||||
|
// row list in an `Overflow::scroll_y()` Node with a constrained
|
||||||
|
// `max_height` keeps every row reachable. The Done button below
|
||||||
|
// sits outside the scroll so it's always one click away. Mirrors
|
||||||
|
// the `SettingsPanelScrollable` pattern.
|
||||||
|
card.spawn((
|
||||||
|
AchievementsScrollable,
|
||||||
|
ScrollPosition::default(),
|
||||||
|
Node {
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
row_gap: VAL_SPACE_1,
|
||||||
|
max_height: Val::Vh(70.0),
|
||||||
|
overflow: Overflow::scroll_y(),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.with_children(|body| {
|
||||||
|
// Achievement rows — unlocked first, then locked alphabetical.
|
||||||
|
let mut sorted: Vec<_> = records.iter().collect();
|
||||||
|
sorted.sort_by_key(|r| (!r.unlocked, r.id.clone()));
|
||||||
|
|
||||||
|
for record in &sorted {
|
||||||
|
let def = achievement_by_id(&record.id);
|
||||||
|
let (name, description) =
|
||||||
|
def.map_or((record.id.as_str(), ""), |d| (d.name, d.description));
|
||||||
|
|
||||||
|
// Hide secret locked achievements so they remain a surprise.
|
||||||
|
let is_secret = def.is_some_and(|d| d.secret);
|
||||||
|
if is_secret && !record.unlocked {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (name_color, desc_color, prefix) = if record.unlocked {
|
||||||
|
(ACCENT_PRIMARY, TEXT_PRIMARY, "\u{2713} ")
|
||||||
|
} else {
|
||||||
|
(TEXT_DISABLED, TEXT_DISABLED, "\u{25CB} ")
|
||||||
|
};
|
||||||
|
|
||||||
|
let tooltip_text = tooltip_for_row(record.unlocked, def);
|
||||||
|
|
||||||
|
body.spawn((
|
||||||
|
Node {
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
row_gap: VAL_SPACE_1,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
AchievementRow,
|
||||||
|
Tooltip::new(tooltip_text),
|
||||||
|
))
|
||||||
|
.with_children(|row| {
|
||||||
|
row.spawn((
|
||||||
|
Text::new(format!("{prefix}{name}")),
|
||||||
|
font_name.clone(),
|
||||||
|
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),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
spawn_modal_actions(card, |actions| {
|
spawn_modal_actions(card, |actions| {
|
||||||
spawn_modal_button(
|
spawn_modal_button(
|
||||||
actions,
|
actions,
|
||||||
@@ -505,6 +598,9 @@ fn spawn_achievements_screen(
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// Achievements is a read-only list — clicking the scrim outside
|
||||||
|
// the card dismisses alongside the existing A / Done paths.
|
||||||
|
commands.entity(scrim).insert(ScrimDismissible);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_reward(reward: Reward) -> String {
|
fn format_reward(reward: Reward) -> String {
|
||||||
@@ -895,6 +991,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
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use crate::card_animation::{sample_curve, CardAnimation, MotionCurve};
|
|||||||
use crate::card_plugin::CardEntity;
|
use crate::card_plugin::CardEntity;
|
||||||
use crate::challenge_plugin::ChallengeAdvancedEvent;
|
use crate::challenge_plugin::ChallengeAdvancedEvent;
|
||||||
use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent};
|
use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent};
|
||||||
use crate::events::{InfoToastEvent, NewGameConfirmEvent, XpAwardedEvent};
|
use crate::events::{InfoToastEvent, XpAwardedEvent};
|
||||||
use crate::events::{AchievementUnlockedEvent, GameWonEvent};
|
use crate::events::{AchievementUnlockedEvent, GameWonEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
@@ -161,7 +161,6 @@ impl Plugin for AnimationPlugin {
|
|||||||
.add_message::<TimeAttackEndedEvent>()
|
.add_message::<TimeAttackEndedEvent>()
|
||||||
.add_message::<ChallengeAdvancedEvent>()
|
.add_message::<ChallengeAdvancedEvent>()
|
||||||
.add_message::<SettingsChangedEvent>()
|
.add_message::<SettingsChangedEvent>()
|
||||||
.add_message::<NewGameConfirmEvent>()
|
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.add_message::<XpAwardedEvent>()
|
.add_message::<XpAwardedEvent>()
|
||||||
.init_resource::<EffectiveSlideDuration>()
|
.init_resource::<EffectiveSlideDuration>()
|
||||||
@@ -183,7 +182,6 @@ impl Plugin for AnimationPlugin {
|
|||||||
handle_challenge_toast,
|
handle_challenge_toast,
|
||||||
handle_settings_toast,
|
handle_settings_toast,
|
||||||
handle_auto_complete_toast,
|
handle_auto_complete_toast,
|
||||||
handle_new_game_confirm_toast,
|
|
||||||
handle_xp_awarded_toast,
|
handle_xp_awarded_toast,
|
||||||
tick_toasts,
|
tick_toasts,
|
||||||
(enqueue_toasts, drive_toast_display).chain(),
|
(enqueue_toasts, drive_toast_display).chain(),
|
||||||
@@ -459,15 +457,6 @@ fn handle_auto_complete_toast(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_new_game_confirm_toast(
|
|
||||||
mut commands: Commands,
|
|
||||||
mut events: MessageReader<NewGameConfirmEvent>,
|
|
||||||
) {
|
|
||||||
for _ in events.read() {
|
|
||||||
spawn_toast(&mut commands, "Press N again to start a new game".to_string(), 3.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads every incoming `InfoToastEvent` and appends its text to `ToastQueue`.
|
/// Reads every incoming `InfoToastEvent` and appends its text to `ToastQueue`.
|
||||||
///
|
///
|
||||||
/// This is the first half of the two-system toast queue (Task #67). The queue
|
/// This is the first half of the two-system toast queue (Task #67). The queue
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -207,13 +207,6 @@ pub struct ToggleLeaderboardRequestEvent;
|
|||||||
#[derive(Message, Debug, Clone)]
|
#[derive(Message, Debug, Clone)]
|
||||||
pub struct SyncCompleteEvent(pub Result<SyncResponse, String>);
|
pub struct SyncCompleteEvent(pub Result<SyncResponse, String>);
|
||||||
|
|
||||||
/// Fired by `InputPlugin` when N is pressed while a game is in progress
|
|
||||||
/// but confirmation has not yet been received. The animation plugin shows
|
|
||||||
/// a "Press N again to confirm" toast. A second N press within the
|
|
||||||
/// confirmation window sends `NewGameRequestEvent`.
|
|
||||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
|
||||||
pub struct NewGameConfirmEvent;
|
|
||||||
|
|
||||||
/// Generic informational toast message. Any system can fire this to display
|
/// Generic informational toast message. Any system can fire this to display
|
||||||
/// a short string to the player, e.g. "Locked — reach level 5".
|
/// a short string to the player, e.g. "Locked — reach level 5".
|
||||||
#[derive(Message, Debug, Clone)]
|
#[derive(Message, Debug, Clone)]
|
||||||
|
|||||||
@@ -21,8 +21,8 @@
|
|||||||
//!
|
//!
|
||||||
//! # Task #69 — Animated card deal on new game start
|
//! # Task #69 — Animated card deal on new game start
|
||||||
//!
|
//!
|
||||||
//! When `NewGameRequestEvent` fires (on a fresh game, `move_count == 0`) or
|
//! When `NewGameRequestEvent` fires (on a fresh game, `move_count == 0`),
|
||||||
//! `NewGameConfirmEvent` fires, `start_deal_anim` reads `LayoutResource` and
|
//! `start_deal_anim` reads `LayoutResource` and
|
||||||
//! inserts a `CardAnim` on every card entity, sliding each card from the stock
|
//! inserts a `CardAnim` on every card entity, sliding each card from the stock
|
||||||
//! pile's position to its current (final) position with a per-card stagger
|
//! pile's position to its current (final) position with a per-card stagger
|
||||||
//! derived from the current `AnimSpeed` setting plus a deterministic ±10 %
|
//! derived from the current `AnimSpeed` setting plus a deterministic ±10 %
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use std::path::PathBuf;
|
|||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
|
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
@@ -72,6 +73,32 @@ pub struct GameStatePath(pub Option<PathBuf>);
|
|||||||
#[derive(Resource, Debug, Clone)]
|
#[derive(Resource, Debug, Clone)]
|
||||||
pub struct ReplayPath(pub Option<PathBuf>);
|
pub struct ReplayPath(pub Option<PathBuf>);
|
||||||
|
|
||||||
|
/// Holds the saved-on-disk in-progress game between plugin build and
|
||||||
|
/// the player's answer to the "Continue or start a new game?" prompt.
|
||||||
|
///
|
||||||
|
/// Some(game) at startup means a previously-saved game existed and had
|
||||||
|
/// real moves on it. The restore-prompt modal swaps it into
|
||||||
|
/// `GameStateResource` if the player picks Continue, or drops it (and
|
||||||
|
/// lets `handle_new_game` clean up the disk file) on New Game. None for
|
||||||
|
/// first-launch installs and for save files that contain a fresh deal
|
||||||
|
/// with no moves yet — there's nothing meaningful to "continue" there.
|
||||||
|
#[derive(Resource, Debug, Default)]
|
||||||
|
pub struct PendingRestoredGame(pub Option<GameState>);
|
||||||
|
|
||||||
|
/// Marker on the "Welcome back — Continue or start a new game?" modal
|
||||||
|
/// scrim. Despawning the scrim cascades to the card and children, so a
|
||||||
|
/// single `commands.entity(scrim).despawn()` tears the modal down.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct RestorePromptScreen;
|
||||||
|
|
||||||
|
/// Marker on the modal's primary "Continue" button.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct RestoreContinueButton;
|
||||||
|
|
||||||
|
/// Marker on the modal's secondary "New game" button.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct RestoreNewGameButton;
|
||||||
|
|
||||||
/// In-memory accumulator for [`ReplayMove`] entries during the current
|
/// In-memory accumulator for [`ReplayMove`] entries during the current
|
||||||
/// game. Cleared on every new-game start; frozen into a [`Replay`] and
|
/// game. Cleared on every new-game start; frozen into a [`Replay`] and
|
||||||
/// flushed to disk by [`record_replay_on_win`] when the player wins.
|
/// flushed to disk by [`record_replay_on_win`] when the player wins.
|
||||||
@@ -109,11 +136,32 @@ impl GamePlugin {
|
|||||||
impl Plugin for GamePlugin {
|
impl Plugin for GamePlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
let path = game_state_file_path();
|
let path = game_state_file_path();
|
||||||
// Restore any saved in-progress game, falling back to a fresh deal.
|
// Try to load any saved in-progress game. We don't want to
|
||||||
let initial_state = path
|
// silently restore a half-played game on launch — the player
|
||||||
.as_deref()
|
// should get to decide between continuing and starting fresh.
|
||||||
.and_then(load_game_state_from)
|
// So: if there IS a saved game with progress and it isn't
|
||||||
.unwrap_or_else(|| GameState::new(seed_from_system_time(), DrawMode::DrawOne));
|
// already won, hold it in `PendingRestoredGame` and let the
|
||||||
|
// restore-prompt modal swap it into `GameStateResource` if
|
||||||
|
// the player picks Continue. Otherwise put it directly into
|
||||||
|
// `GameStateResource` (existing behaviour for un-played /
|
||||||
|
// won deals which there's nothing to ask about).
|
||||||
|
let saved = path.as_deref().and_then(load_game_state_from);
|
||||||
|
let prompt_worthy = saved
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|g| g.move_count > 0 && !g.is_won);
|
||||||
|
let (initial_state, pending_restore) = if prompt_worthy {
|
||||||
|
(
|
||||||
|
GameState::new(seed_from_system_time(), DrawMode::DrawOne),
|
||||||
|
saved,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
saved.unwrap_or_else(|| {
|
||||||
|
GameState::new(seed_from_system_time(), DrawMode::DrawOne)
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
// One-shot migration from the legacy single-slot
|
// One-shot migration from the legacy single-slot
|
||||||
// `latest_replay.json` to the rolling history at `replays.json`.
|
// `latest_replay.json` to the rolling history at `replays.json`.
|
||||||
@@ -136,7 +184,9 @@ impl Plugin for GamePlugin {
|
|||||||
app.insert_resource(GameStateResource(initial_state))
|
app.insert_resource(GameStateResource(initial_state))
|
||||||
.insert_resource(GameStatePath(path))
|
.insert_resource(GameStatePath(path))
|
||||||
.insert_resource(ReplayPath(history_path))
|
.insert_resource(ReplayPath(history_path))
|
||||||
|
.insert_resource(PendingRestoredGame(pending_restore))
|
||||||
.init_resource::<RecordingReplay>()
|
.init_resource::<RecordingReplay>()
|
||||||
|
.init_resource::<PendingNewGameSeed>()
|
||||||
.init_resource::<DragState>()
|
.init_resource::<DragState>()
|
||||||
.init_resource::<SyncStatusResource>()
|
.init_resource::<SyncStatusResource>()
|
||||||
.add_message::<MoveRequestEvent>()
|
.add_message::<MoveRequestEvent>()
|
||||||
@@ -150,6 +200,10 @@ impl Plugin for GamePlugin {
|
|||||||
.add_message::<crate::events::AchievementUnlockedEvent>()
|
.add_message::<crate::events::AchievementUnlockedEvent>()
|
||||||
.add_message::<FoundationCompletedEvent>()
|
.add_message::<FoundationCompletedEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
poll_pending_new_game_seed.before(GameMutation),
|
||||||
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
@@ -167,6 +221,11 @@ impl Plugin for GamePlugin {
|
|||||||
.add_systems(Update, handle_confirm_button_input.after(GameMutation))
|
.add_systems(Update, handle_confirm_button_input.after(GameMutation))
|
||||||
.add_systems(Update, handle_game_over_input.after(GameMutation))
|
.add_systems(Update, handle_game_over_input.after(GameMutation))
|
||||||
.add_systems(Update, handle_game_over_button_input.after(GameMutation))
|
.add_systems(Update, handle_game_over_button_input.after(GameMutation))
|
||||||
|
// Restore prompt: spawn the modal once the splash is gone,
|
||||||
|
// route Continue / New Game intents back into the existing
|
||||||
|
// GameMutation flow.
|
||||||
|
.add_systems(Update, spawn_restore_prompt_if_pending)
|
||||||
|
.add_systems(Update, handle_restore_prompt.before(GameMutation))
|
||||||
.init_resource::<AutoSaveTimer>()
|
.init_resource::<AutoSaveTimer>()
|
||||||
.add_systems(Update, tick_elapsed_time)
|
.add_systems(Update, tick_elapsed_time)
|
||||||
.add_systems(Update, auto_save_game_state)
|
.add_systems(Update, auto_save_game_state)
|
||||||
@@ -193,16 +252,20 @@ pub fn advance_elapsed(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Increment `GameState.elapsed_seconds` once per real-world second while
|
/// Increment `GameState.elapsed_seconds` once per real-world second while
|
||||||
/// the game is in progress (not won) and not paused. Stops counting on
|
/// the game is in progress (not won), not paused, and the launch /
|
||||||
/// win so the final time reflects how long the player took to solve the
|
/// mode-picker Home modal isn't covering the board. Stops counting on
|
||||||
/// deal; stops while the pause overlay is open.
|
/// win so the final time reflects how long the player took to solve
|
||||||
|
/// the deal; stops while the pause overlay is open; stops while Home
|
||||||
|
/// is up so the timer doesn't tick under the picker before the player
|
||||||
|
/// has actually committed to a deal.
|
||||||
fn tick_elapsed_time(
|
fn tick_elapsed_time(
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
mut game: ResMut<GameStateResource>,
|
mut game: ResMut<GameStateResource>,
|
||||||
mut accumulator: Local<f32>,
|
mut accumulator: Local<f32>,
|
||||||
paused: Option<Res<crate::pause_plugin::PausedResource>>,
|
paused: Option<Res<crate::pause_plugin::PausedResource>>,
|
||||||
|
home_screens: Query<(), With<crate::home_plugin::HomeScreen>>,
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) || !home_screens.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let is_won = game.0.is_won;
|
let is_won = game.0.is_won;
|
||||||
@@ -237,6 +300,60 @@ fn seed_from_system_time() -> u64 {
|
|||||||
/// seed so the player still gets a deal — better a possibly-unwinnable
|
/// seed so the player still gets a deal — better a possibly-unwinnable
|
||||||
/// hand than an infinite loop.
|
/// hand than an infinite loop.
|
||||||
///
|
///
|
||||||
|
/// In-flight async work for "Winnable deals only" seed selection.
|
||||||
|
///
|
||||||
|
/// `handle_new_game` writes here when it needs the solver to vet a deal;
|
||||||
|
/// `poll_pending_new_game_seed` reads from here, polls the task, and
|
||||||
|
/// re-emits a `NewGameRequestEvent` with the chosen seed once the task
|
||||||
|
/// completes. The desktop client's UI never blocks on the worst-case
|
||||||
|
/// 50 × ~120 ms solver runs that can pile up on pathological deals.
|
||||||
|
///
|
||||||
|
/// At most one task is ever in flight: a fresh new-game request while
|
||||||
|
/// a previous task is still running drops the previous task (Bevy's
|
||||||
|
/// `Task` `Drop` cancels it cooperatively at the next await point) and
|
||||||
|
/// queues the new one.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
pub struct PendingNewGameSeed {
|
||||||
|
/// `Some` while a solver-vetted seed is being computed.
|
||||||
|
inner: Option<PendingSeedTask>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One in-flight winnable-seed search plus the request fields that
|
||||||
|
/// would have flowed through `handle_new_game` synchronously. The
|
||||||
|
/// poll system replays them on a synthetic `NewGameRequestEvent` once
|
||||||
|
/// the task completes — `seed: Some(...)` skips the solver branch on
|
||||||
|
/// the second pass so we don't loop.
|
||||||
|
struct PendingSeedTask {
|
||||||
|
handle: Task<u64>,
|
||||||
|
mode: Option<GameMode>,
|
||||||
|
confirmed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update system: poll the in-flight winnable-seed search. When the
|
||||||
|
/// task resolves, emit a synthetic `NewGameRequestEvent` carrying the
|
||||||
|
/// chosen seed. Ordered `.before(GameMutation)` so `handle_new_game`
|
||||||
|
/// picks up the synthetic event on the same frame, completing the
|
||||||
|
/// new-game flow without a one-frame visual lag.
|
||||||
|
fn poll_pending_new_game_seed(
|
||||||
|
mut pending: ResMut<PendingNewGameSeed>,
|
||||||
|
mut new_game_writer: MessageWriter<NewGameRequestEvent>,
|
||||||
|
) {
|
||||||
|
let Some(p) = pending.inner.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(seed) = future::block_on(future::poll_once(&mut p.handle)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mode = p.mode;
|
||||||
|
let confirmed = p.confirmed;
|
||||||
|
pending.inner = None;
|
||||||
|
new_game_writer.write(NewGameRequestEvent {
|
||||||
|
seed: Some(seed),
|
||||||
|
mode,
|
||||||
|
confirmed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Pure helper extracted for testability — `new_game_with_solver_*`
|
/// Pure helper extracted for testability — `new_game_with_solver_*`
|
||||||
/// engine tests in the same file exercise this path.
|
/// engine tests in the same file exercise this path.
|
||||||
pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: &DrawMode) -> u64 {
|
pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: &DrawMode) -> u64 {
|
||||||
@@ -262,6 +379,7 @@ fn handle_new_game(
|
|||||||
mut game: ResMut<GameStateResource>,
|
mut game: ResMut<GameStateResource>,
|
||||||
mut changed: MessageWriter<StateChangedEvent>,
|
mut changed: MessageWriter<StateChangedEvent>,
|
||||||
mut recording: ResMut<RecordingReplay>,
|
mut recording: ResMut<RecordingReplay>,
|
||||||
|
mut pending_seed: ResMut<PendingNewGameSeed>,
|
||||||
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
|
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
|
||||||
path: Option<Res<GameStatePath>>,
|
path: Option<Res<GameStatePath>>,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
@@ -296,6 +414,13 @@ fn handle_new_game(
|
|||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drop any in-flight winnable-seed search now that we've
|
||||||
|
// committed to acting on a new request. Its result was for
|
||||||
|
// the previous user intent — the new request supersedes it
|
||||||
|
// regardless of which branch we take below (synchronous
|
||||||
|
// explicit-seed deal vs. another async solver search).
|
||||||
|
pending_seed.inner = None;
|
||||||
|
|
||||||
let initial_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
|
||||||
@@ -323,11 +448,22 @@ fn handle_new_game(
|
|||||||
let winnable_only = settings
|
let winnable_only = settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|s| s.0.winnable_deals_only);
|
.is_some_and(|s| s.0.winnable_deals_only);
|
||||||
let chosen_seed = if winnable_only && mode == GameMode::Classic && ev.seed.is_none() {
|
if winnable_only && mode == GameMode::Classic && ev.seed.is_none() {
|
||||||
choose_winnable_seed(initial_seed, &draw_mode)
|
let dm = draw_mode.clone();
|
||||||
} else {
|
let task = AsyncComputeTaskPool::get()
|
||||||
initial_seed
|
.spawn(async move { choose_winnable_seed(initial_seed, &dm) });
|
||||||
};
|
pending_seed.inner = Some(PendingSeedTask {
|
||||||
|
handle: task,
|
||||||
|
mode: ev.mode,
|
||||||
|
confirmed: ev.confirmed,
|
||||||
|
});
|
||||||
|
// Skip the rest of the new-game flow; the polling system
|
||||||
|
// will re-emit a synthetic event with a chosen seed once
|
||||||
|
// the task resolves.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chosen_seed = initial_seed;
|
||||||
|
|
||||||
game.0 = GameState::new_with_mode(chosen_seed, draw_mode, mode);
|
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
|
||||||
@@ -383,6 +519,132 @@ pub struct ConfirmNoButton;
|
|||||||
/// and "No (N)" — those were not real Button entities, so the player
|
/// and "No (N)" — those were not real Button entities, so the player
|
||||||
/// had no hover / press feedback and the modal felt like a debug panel
|
/// had no hover / press feedback and the modal felt like a debug panel
|
||||||
/// (the user's smoke-test "#2 complaint").
|
/// (the user's smoke-test "#2 complaint").
|
||||||
|
/// Update-schedule system: once the splash overlay is gone and there's
|
||||||
|
/// a pending restored game waiting for the player's answer, spawn the
|
||||||
|
/// "Welcome back — Continue or start a new game?" modal. Idempotent —
|
||||||
|
/// the existing `RestorePromptScreen` query gates against duplicate
|
||||||
|
/// spawns if Update fires before the player clicks.
|
||||||
|
fn spawn_restore_prompt_if_pending(
|
||||||
|
mut commands: Commands,
|
||||||
|
pending: Res<PendingRestoredGame>,
|
||||||
|
splash: Query<(), With<crate::splash_plugin::SplashRoot>>,
|
||||||
|
existing: Query<(), With<RestorePromptScreen>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
) {
|
||||||
|
if pending.0.is_none() || !splash.is_empty() || !existing.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
spawn_modal(
|
||||||
|
&mut commands,
|
||||||
|
RestorePromptScreen,
|
||||||
|
ui_theme::Z_MODAL_PANEL,
|
||||||
|
|card| {
|
||||||
|
spawn_modal_header(card, "Welcome back", font_res.as_deref());
|
||||||
|
spawn_modal_body_text(
|
||||||
|
card,
|
||||||
|
"You have an in-progress game. Continue where you left off, or start a new one?",
|
||||||
|
ui_theme::TEXT_SECONDARY,
|
||||||
|
font_res.as_deref(),
|
||||||
|
);
|
||||||
|
spawn_modal_actions(card, |actions| {
|
||||||
|
spawn_modal_button(
|
||||||
|
actions,
|
||||||
|
RestoreNewGameButton,
|
||||||
|
"New game",
|
||||||
|
Some("N"),
|
||||||
|
ButtonVariant::Secondary,
|
||||||
|
font_res.as_deref(),
|
||||||
|
);
|
||||||
|
spawn_modal_button(
|
||||||
|
actions,
|
||||||
|
RestoreContinueButton,
|
||||||
|
"Continue",
|
||||||
|
Some("Enter"),
|
||||||
|
ButtonVariant::Primary,
|
||||||
|
font_res.as_deref(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Click handlers + keyboard shortcuts for the restore prompt.
|
||||||
|
///
|
||||||
|
/// Continue (Enter / C) — swaps the saved game into `GameStateResource`
|
||||||
|
/// and writes a `StateChangedEvent` so card sprites resync to the
|
||||||
|
/// restored layout.
|
||||||
|
/// New game (N) — drops the saved game and writes
|
||||||
|
/// `NewGameRequestEvent { confirmed: true }`. The existing
|
||||||
|
/// `handle_new_game` flow takes over: deletes `game_state.json`, deals
|
||||||
|
/// a fresh game, fires `StateChangedEvent`. `confirmed: true` skips
|
||||||
|
/// the abandon-current-game confirm dialog (the player has already
|
||||||
|
/// confirmed by clicking New game here).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn handle_restore_prompt(
|
||||||
|
mut commands: Commands,
|
||||||
|
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||||
|
screens: Query<Entity, With<RestorePromptScreen>>,
|
||||||
|
continue_buttons: Query<&Interaction, (With<RestoreContinueButton>, Changed<Interaction>)>,
|
||||||
|
new_game_buttons: Query<&Interaction, (With<RestoreNewGameButton>, Changed<Interaction>)>,
|
||||||
|
mut pending: ResMut<PendingRestoredGame>,
|
||||||
|
mut game: ResMut<GameStateResource>,
|
||||||
|
mut changed: MessageWriter<StateChangedEvent>,
|
||||||
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||||
|
mut launch_home_shown: Option<ResMut<crate::home_plugin::LaunchHomeShown>>,
|
||||||
|
) {
|
||||||
|
if screens.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Esc maps to Continue rather than New Game so a stray dismiss
|
||||||
|
// press preserves the saved game — the data-preserving default is
|
||||||
|
// the safer fallback when a player hits Esc reflexively to "close
|
||||||
|
// this dialog" without reading it.
|
||||||
|
let key_continue = keys.as_ref().is_some_and(|k| {
|
||||||
|
k.just_pressed(KeyCode::Enter)
|
||||||
|
|| k.just_pressed(KeyCode::KeyC)
|
||||||
|
|| k.just_pressed(KeyCode::Escape)
|
||||||
|
});
|
||||||
|
let key_new = keys.as_ref().is_some_and(|k| k.just_pressed(KeyCode::KeyN));
|
||||||
|
let click_continue = continue_buttons
|
||||||
|
.iter()
|
||||||
|
.any(|i| *i == Interaction::Pressed);
|
||||||
|
let click_new = new_game_buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||||
|
|
||||||
|
let resolved = if key_continue || click_continue {
|
||||||
|
if let Some(restored) = pending.0.take() {
|
||||||
|
game.0 = restored;
|
||||||
|
changed.write(StateChangedEvent);
|
||||||
|
}
|
||||||
|
for entity in &screens {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else if key_new || click_new {
|
||||||
|
pending.0 = None;
|
||||||
|
for entity in &screens {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
new_game.write(NewGameRequestEvent {
|
||||||
|
seed: None,
|
||||||
|
mode: None,
|
||||||
|
confirmed: true,
|
||||||
|
});
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
// The player has just made an explicit launch-time choice (continue
|
||||||
|
// saved game, or start a fresh deal). Suppress the launch-time Home
|
||||||
|
// auto-show so it doesn't pop on top of the resolution they picked.
|
||||||
|
// `M` still re-opens the picker on demand.
|
||||||
|
if resolved
|
||||||
|
&& let Some(ref mut shown) = launch_home_shown
|
||||||
|
{
|
||||||
|
shown.0 = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn spawn_confirm_dialog(
|
fn spawn_confirm_dialog(
|
||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
original_request: NewGameRequestEvent,
|
original_request: NewGameRequestEvent,
|
||||||
@@ -936,9 +1198,17 @@ fn auto_save_game_state(
|
|||||||
path: Option<Res<GameStatePath>>,
|
path: Option<Res<GameStatePath>>,
|
||||||
mut timer: ResMut<AutoSaveTimer>,
|
mut timer: ResMut<AutoSaveTimer>,
|
||||||
paused: Option<Res<crate::pause_plugin::PausedResource>>,
|
paused: Option<Res<crate::pause_plugin::PausedResource>>,
|
||||||
|
pending: Res<PendingRestoredGame>,
|
||||||
) {
|
) {
|
||||||
// Don't save if paused, game is won, or no moves have been made yet.
|
// Don't save if paused, game is won, no moves have been made yet,
|
||||||
if paused.is_some_and(|p| p.0) || game.0.is_won || game.0.move_count == 0 {
|
// or there's a pending restore the player hasn't answered — saving
|
||||||
|
// the fresh-deal placeholder we seeded GameStateResource with at
|
||||||
|
// startup would clobber the real saved game on disk.
|
||||||
|
if paused.is_some_and(|p| p.0)
|
||||||
|
|| game.0.is_won
|
||||||
|
|| game.0.move_count == 0
|
||||||
|
|| pending.0.is_some()
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
timer.0 += time.delta_secs();
|
timer.0 += time.delta_secs();
|
||||||
@@ -955,17 +1225,25 @@ fn auto_save_game_state(
|
|||||||
/// player can resume where they left off. Won games are not saved (the
|
/// player can resume where they left off. Won games are not saved (the
|
||||||
/// `save_game_state_to` helper skips them). Blocking on exit is acceptable
|
/// `save_game_state_to` helper skips them). Blocking on exit is acceptable
|
||||||
/// because the game loop is already shutting down.
|
/// because the game loop is already shutting down.
|
||||||
|
///
|
||||||
|
/// Special case: when `PendingRestoredGame` still holds a saved game the
|
||||||
|
/// player never answered the restore prompt for, write THAT to disk
|
||||||
|
/// instead of the live `GameStateResource`. Otherwise we'd clobber a
|
||||||
|
/// real saved game with the fresh-deal placeholder we seeded
|
||||||
|
/// `GameStateResource` with at startup.
|
||||||
fn save_game_state_on_exit(
|
fn save_game_state_on_exit(
|
||||||
mut exit_events: MessageReader<AppExit>,
|
mut exit_events: MessageReader<AppExit>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
path: Res<GameStatePath>,
|
path: Res<GameStatePath>,
|
||||||
|
pending: Res<PendingRestoredGame>,
|
||||||
) {
|
) {
|
||||||
if exit_events.is_empty() {
|
if exit_events.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
exit_events.clear();
|
exit_events.clear();
|
||||||
let Some(p) = path.0.as_deref() else { return };
|
let Some(p) = path.0.as_deref() else { return };
|
||||||
if let Err(e) = save_game_state_to(p, &game.0) {
|
let to_save = pending.0.as_ref().unwrap_or(&game.0);
|
||||||
|
if let Err(e) = save_game_state_to(p, to_save) {
|
||||||
warn!("game_state: failed to save on exit: {e}");
|
warn!("game_state: failed to save on exit: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2320,4 +2598,111 @@ mod tests {
|
|||||||
0
|
0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Async-solver flow: a winnable-only request with no explicit
|
||||||
|
/// seed must populate `PendingNewGameSeed` on the same frame the
|
||||||
|
/// request fires (no main-thread stall waiting on the solver),
|
||||||
|
/// and subsequent updates must clear the pending state and
|
||||||
|
/// produce a new GameState.
|
||||||
|
///
|
||||||
|
/// Drives multiple `app.update()` calls because the polling
|
||||||
|
/// system needs at least one tick after spawn to observe the
|
||||||
|
/// task as ready and re-emit the synthetic event.
|
||||||
|
#[test]
|
||||||
|
fn winnable_seed_search_runs_async_and_completes_eventually() {
|
||||||
|
let mut app = test_app(394);
|
||||||
|
insert_settings(&mut app, true);
|
||||||
|
|
||||||
|
app.world_mut().write_message(NewGameRequestEvent {
|
||||||
|
seed: None,
|
||||||
|
mode: None,
|
||||||
|
confirmed: false,
|
||||||
|
});
|
||||||
|
// First update: handle_new_game spawns the solver task and
|
||||||
|
// returns. The GameStateResource is unchanged on this tick —
|
||||||
|
// the player's previous game is still on screen, so the UI
|
||||||
|
// doesn't visually stall.
|
||||||
|
app.update();
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<PendingNewGameSeed>().inner.is_some(),
|
||||||
|
"first frame should have an in-flight solver task",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pump frames until the polling system observes the task as
|
||||||
|
// ready and re-emits the synthetic event. AsyncComputeTaskPool
|
||||||
|
// is a shared pool across the whole `cargo test` run — when
|
||||||
|
// dozens of tests execute in parallel the pool can take a
|
||||||
|
// while to actually schedule our future. The yield_now() lets
|
||||||
|
// the pool's worker threads make progress between our polls
|
||||||
|
// without burning wall-clock time.
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
|
||||||
|
while app.world().resource::<PendingNewGameSeed>().inner.is_some() {
|
||||||
|
app.update();
|
||||||
|
std::thread::yield_now();
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<PendingNewGameSeed>().inner.is_none(),
|
||||||
|
"solver task should have completed within 15 s wall-clock",
|
||||||
|
);
|
||||||
|
// New game completed: a fresh deal carries 0 moves.
|
||||||
|
assert_eq!(
|
||||||
|
app.world().resource::<GameStateResource>().0.move_count,
|
||||||
|
0,
|
||||||
|
"completed new game must be in fresh-deal state",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel-on-replace: a winnable-only request that arrives while
|
||||||
|
/// a previous solver task is in flight must drop the previous
|
||||||
|
/// task and queue the new one. The most recently-fired request
|
||||||
|
/// is the one whose seed wins, regardless of which task started
|
||||||
|
/// first.
|
||||||
|
#[test]
|
||||||
|
fn winnable_seed_search_drops_in_flight_task_on_new_request() {
|
||||||
|
let mut app = test_app(394);
|
||||||
|
insert_settings(&mut app, true);
|
||||||
|
|
||||||
|
// Fire the first request; first update spawns the task.
|
||||||
|
app.world_mut().write_message(NewGameRequestEvent {
|
||||||
|
seed: None,
|
||||||
|
mode: None,
|
||||||
|
confirmed: false,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<PendingNewGameSeed>().inner.is_some(),
|
||||||
|
"first request should be in flight",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fire a SECOND request with an explicit seed before the
|
||||||
|
// first task can complete. handle_new_game's `pending.inner =
|
||||||
|
// None` line must drop the in-flight task; the explicit-seed
|
||||||
|
// branch then bypasses the solver entirely. After this tick
|
||||||
|
// the GameStateResource carries seed 12345, not whatever the
|
||||||
|
// solver would have picked for the first request.
|
||||||
|
app.world_mut().write_message(NewGameRequestEvent {
|
||||||
|
seed: Some(12345),
|
||||||
|
mode: None,
|
||||||
|
confirmed: true,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// Drive a few more ticks to drain any stragglers.
|
||||||
|
for _ in 0..5 {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<PendingNewGameSeed>().inner.is_none(),
|
||||||
|
"explicit-seed request must have cancelled the in-flight task",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
app.world().resource::<GameStateResource>().0.seed,
|
||||||
|
12345,
|
||||||
|
"explicit-seed request takes precedence over the dropped solver task",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -13,24 +13,34 @@
|
|||||||
//! [`InfoToastEvent`] explaining the gate but does not launch the mode
|
//! [`InfoToastEvent`] explaining the gate but does not launch the mode
|
||||||
//! or close the overlay.
|
//! or close the overlay.
|
||||||
|
|
||||||
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use solitaire_core::game_state::DrawMode;
|
||||||
|
use solitaire_data::save_settings_to;
|
||||||
|
|
||||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||||
|
use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
|
InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
|
||||||
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
|
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
|
||||||
|
ToggleProfileRequestEvent,
|
||||||
};
|
};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
|
use crate::settings_plugin::{
|
||||||
|
SettingsChangedEvent, SettingsResource, SettingsStoragePath,
|
||||||
|
};
|
||||||
|
use crate::stats_plugin::StatsResource;
|
||||||
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, BG_ELEVATED_HI, BORDER_STRONG, BORDER_SUBTLE, RADIUS_MD,
|
||||||
TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION,
|
STATE_INFO, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
|
||||||
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
|
TYPE_CAPTION, TYPE_DISPLAY, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -46,6 +56,31 @@ pub struct HomeScreen;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct HomeCancelButton;
|
pub struct HomeCancelButton;
|
||||||
|
|
||||||
|
/// Marker on the player-stats chip strip at the top of the Home modal.
|
||||||
|
/// Clicking the strip opens the Profile overlay so the player can drill
|
||||||
|
/// into level / XP / cosmetics without first dismissing Home.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct HomeProfileChip;
|
||||||
|
|
||||||
|
/// Marker on the "Draw 1" toggle button inside the Home modal's
|
||||||
|
/// draw-mode row. Clicking flips `Settings.draw_mode` to `DrawOne` and
|
||||||
|
/// fires `SettingsChangedEvent` so audio / UI dependents react.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct HomeDrawOneButton;
|
||||||
|
|
||||||
|
/// Marker on the "Draw 3" toggle button inside the Home modal's
|
||||||
|
/// draw-mode row. Mirror of [`HomeDrawOneButton`] for `DrawThree`.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct HomeDrawThreeButton;
|
||||||
|
|
||||||
|
/// Marker on the scrollable inner Node containing the player chips,
|
||||||
|
/// draw-mode row, and tile grid. Wrapping these in a scrollable
|
||||||
|
/// container keeps the modal usable on small viewports — without it,
|
||||||
|
/// the 3-row tile stack pushes the Cancel button off the bottom of
|
||||||
|
/// the screen on 800x600 hardware. Mirrors `SettingsPanelScrollable`.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct HomeScrollable;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Private mode-card data shape
|
// Private mode-card data shape
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -86,6 +121,38 @@ impl HomeMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Unicode glyph rendered as the picture-tile centrepiece. Stand-in
|
||||||
|
/// for real per-mode artwork — chosen for one-glyph-tells-the-mode
|
||||||
|
/// readability rather than visual fidelity. Swap to `Image` nodes
|
||||||
|
/// when art lands; the rest of the tile layout doesn't change.
|
||||||
|
///
|
||||||
|
/// Picks are constrained to **card suits** (U+2660-2666) and basic
|
||||||
|
/// **Geometric Shapes** (U+25xx) — the two ranges the bundled
|
||||||
|
/// FiraMono-Medium face actually covers. Earlier choices in
|
||||||
|
/// Dingbats (★ ❀ ✦) and Misc Symbols (⌚) rendered as
|
||||||
|
/// missing-glyph rectangles because FiraMono's coverage there is
|
||||||
|
/// minimal.
|
||||||
|
fn glyph(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
// Black club — card suit, the obvious solitaire mark.
|
||||||
|
HomeMode::Classic => "\u{2663}",
|
||||||
|
// Black diamond — Geometric Shapes; reads as the day's gem.
|
||||||
|
HomeMode::Daily => "\u{25C6}",
|
||||||
|
// White circle — Geometric Shapes; reads as the Zen enso.
|
||||||
|
HomeMode::Zen => "\u{25CB}",
|
||||||
|
// Black up-pointing triangle — Geometric Shapes; reads as
|
||||||
|
// a mountain / a step up in difficulty.
|
||||||
|
HomeMode::Challenge => "\u{25B2}",
|
||||||
|
// Rightwards arrow — Arrows block (U+2190-21FF), a core
|
||||||
|
// range every dev-oriented monospace font (FiraMono
|
||||||
|
// included) ships. Reads as "go / fast-forward" for the
|
||||||
|
// timed mode. Earlier ▶ (U+25B6) did not render; FiraMono
|
||||||
|
// ships ▲ (up triangle) but evidently not the sideways
|
||||||
|
// siblings.
|
||||||
|
HomeMode::TimeAttack => "\u{2192}",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The keyboard accelerator that dispatches the same launch event,
|
/// The keyboard accelerator that dispatches the same launch event,
|
||||||
/// shown in a small chip on the card.
|
/// shown in a small chip on the card.
|
||||||
fn hotkey(self) -> &'static str {
|
fn hotkey(self) -> &'static str {
|
||||||
@@ -114,27 +181,69 @@ impl HomeMode {
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct HomeModeCard(HomeMode);
|
struct HomeModeCard(HomeMode);
|
||||||
|
|
||||||
|
/// Tracks whether the launch-time Home modal has already been auto-shown
|
||||||
|
/// for this app session. Flipped to `true` by [`spawn_home_on_launch`]
|
||||||
|
/// the first time it spawns the modal, so the auto-show is one-shot per
|
||||||
|
/// process — subsequent dismissals (Cancel / mode pick) don't trigger
|
||||||
|
/// a respawn, but the player can still re-open the picker with `M`.
|
||||||
|
///
|
||||||
|
/// Other plugins (e.g. `game_plugin`'s restore-prompt handler) can flip
|
||||||
|
/// the flag manually to suppress the launch auto-show when the player
|
||||||
|
/// has already made a launch-time choice through a different surface.
|
||||||
|
#[derive(Resource, Debug, Default)]
|
||||||
|
pub struct LaunchHomeShown(pub bool);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Plugin
|
// Plugin
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Registers the M-key toggle, the mode-card click handler, and the
|
/// Registers the M-key toggle, the mode-card click handler, and the
|
||||||
/// Cancel-button handler.
|
/// Cancel-button handler.
|
||||||
pub struct HomePlugin;
|
///
|
||||||
|
/// `auto_show_on_launch` (default true) controls whether the picker
|
||||||
|
/// auto-spawns once the splash clears at app start. Headless tests use
|
||||||
|
/// [`HomePlugin::headless`] to opt out so each test starts with no
|
||||||
|
/// modal in the world.
|
||||||
|
pub struct HomePlugin {
|
||||||
|
auto_show_on_launch: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HomePlugin {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
auto_show_on_launch: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HomePlugin {
|
||||||
|
/// Test-only constructor that disables the launch-time auto-show.
|
||||||
|
/// `MinimalPlugins` test setups don't include a splash, so the
|
||||||
|
/// gating system would otherwise fire on the first tick and
|
||||||
|
/// pre-spawn the modal that every test asserts is absent.
|
||||||
|
pub fn headless() -> Self {
|
||||||
|
Self {
|
||||||
|
auto_show_on_launch: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Plugin for HomePlugin {
|
impl Plugin for HomePlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
// Be defensive about message registration so HomePlugin works
|
// Pre-mark the auto-show as already done in headless mode so the
|
||||||
// standalone in tests (the actual handlers live in
|
// gating system is a permanent no-op for tests.
|
||||||
// input_plugin / challenge_plugin / time_attack_plugin /
|
app.insert_resource(LaunchHomeShown(!self.auto_show_on_launch))
|
||||||
// daily_challenge_plugin, but those plugins might not be
|
.add_message::<NewGameRequestEvent>()
|
||||||
// installed in a tightly-scoped headless app).
|
|
||||||
app.add_message::<NewGameRequestEvent>()
|
|
||||||
.add_message::<StartZenRequestEvent>()
|
.add_message::<StartZenRequestEvent>()
|
||||||
.add_message::<StartChallengeRequestEvent>()
|
.add_message::<StartChallengeRequestEvent>()
|
||||||
.add_message::<StartTimeAttackRequestEvent>()
|
.add_message::<StartTimeAttackRequestEvent>()
|
||||||
.add_message::<StartDailyChallengeRequestEvent>()
|
.add_message::<StartDailyChallengeRequestEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
|
.add_message::<ToggleProfileRequestEvent>()
|
||||||
|
.add_message::<SettingsChangedEvent>()
|
||||||
|
// Defensively register MouseWheel so `scroll_home_panel`
|
||||||
|
// runs cleanly under MinimalPlugins headless tests too.
|
||||||
|
.add_message::<MouseWheel>()
|
||||||
// `.chain()` because several systems (M-toggle, card click,
|
// `.chain()` because several systems (M-toggle, card click,
|
||||||
// cancel button, digit-key shortcut) all read the
|
// cancel button, digit-key shortcut) all read the
|
||||||
// `HomeScreen` entity and may queue a despawn on it in the
|
// `HomeScreen` entity and may queue a despawn on it in the
|
||||||
@@ -146,25 +255,92 @@ impl Plugin for HomePlugin {
|
|||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
|
spawn_home_on_launch,
|
||||||
toggle_home_screen,
|
toggle_home_screen,
|
||||||
attach_focusable_to_home_mode_cards,
|
attach_focusable_to_home_mode_cards,
|
||||||
handle_home_card_click,
|
handle_home_card_click,
|
||||||
handle_home_cancel_button,
|
handle_home_cancel_button,
|
||||||
|
handle_home_profile_chip,
|
||||||
|
handle_home_draw_mode_buttons,
|
||||||
handle_home_digit_keys,
|
handle_home_digit_keys,
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain(),
|
||||||
);
|
)
|
||||||
|
.add_systems(Update, scroll_home_panel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auto-show on launch
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Auto-spawns the Home / mode-picker modal once per app session, so
|
||||||
|
/// the player lands on a deliberate "what mode do I want to play"
|
||||||
|
/// screen instead of the default Classic deal.
|
||||||
|
///
|
||||||
|
/// Gated on the launch-time UI being clear:
|
||||||
|
///
|
||||||
|
/// * `SplashRoot` must be gone — the splash owns the foreground during
|
||||||
|
/// the brand beat and the home modal appearing under it would feel
|
||||||
|
/// like a flash of half-rendered UI.
|
||||||
|
/// * `RestorePromptScreen` must not be open and `PendingRestoredGame`
|
||||||
|
/// must be empty — when the player has a saved in-progress game the
|
||||||
|
/// restore prompt takes precedence; the home picker would compete
|
||||||
|
/// with it for attention.
|
||||||
|
/// * `HomeScreen` must not already exist (defensive — e.g. the player
|
||||||
|
/// pressed `M` between ticks).
|
||||||
|
/// * `LaunchHomeShown` flips to `true` after the first spawn so this
|
||||||
|
/// system becomes a no-op for the rest of the session. Cancelling
|
||||||
|
/// the modal therefore goes to the underlying default deal rather
|
||||||
|
/// than respawning the picker.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn spawn_home_on_launch(
|
||||||
|
mut commands: Commands,
|
||||||
|
mut shown: ResMut<LaunchHomeShown>,
|
||||||
|
splash: Query<(), With<crate::splash_plugin::SplashRoot>>,
|
||||||
|
restore_prompts: Query<(), With<crate::game_plugin::RestorePromptScreen>>,
|
||||||
|
pending_restore: Option<Res<crate::game_plugin::PendingRestoredGame>>,
|
||||||
|
existing: Query<(), With<HomeScreen>>,
|
||||||
|
progress: Option<Res<ProgressResource>>,
|
||||||
|
stats: Option<Res<StatsResource>>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
daily: Option<Res<DailyChallengeResource>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
) {
|
||||||
|
if shown.0
|
||||||
|
|| !splash.is_empty()
|
||||||
|
|| !restore_prompts.is_empty()
|
||||||
|
|| pending_restore.as_ref().is_some_and(|p| p.0.is_some())
|
||||||
|
|| !existing.is_empty()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn_home_screen(
|
||||||
|
&mut commands,
|
||||||
|
build_home_context(
|
||||||
|
progress.as_deref(),
|
||||||
|
stats.as_deref(),
|
||||||
|
settings.as_deref(),
|
||||||
|
daily.as_deref(),
|
||||||
|
font_res.as_deref(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
shown.0 = true;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// M-key toggle
|
// M-key toggle
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn toggle_home_screen(
|
fn toggle_home_screen(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
progress: Option<Res<ProgressResource>>,
|
progress: Option<Res<ProgressResource>>,
|
||||||
|
stats: Option<Res<StatsResource>>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
daily: Option<Res<DailyChallengeResource>>,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
screens: Query<Entity, With<HomeScreen>>,
|
screens: Query<Entity, With<HomeScreen>>,
|
||||||
) {
|
) {
|
||||||
@@ -174,8 +350,54 @@ fn toggle_home_screen(
|
|||||||
if let Ok(entity) = screens.single() {
|
if let Ok(entity) = screens.single() {
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
} else {
|
} else {
|
||||||
let level = progress.as_ref().map_or(0, |p| p.0.level);
|
spawn_home_screen(
|
||||||
spawn_home_screen(&mut commands, level, font_res.as_deref());
|
&mut commands,
|
||||||
|
build_home_context(
|
||||||
|
progress.as_deref(),
|
||||||
|
stats.as_deref(),
|
||||||
|
settings.as_deref(),
|
||||||
|
daily.as_deref(),
|
||||||
|
font_res.as_deref(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a [`HomeContext`] from the live resources the Home modal
|
||||||
|
/// reads. Falls back to safe defaults when a resource is missing
|
||||||
|
/// (typical for `MinimalPlugins` headless tests that don't install
|
||||||
|
/// every contributor plugin).
|
||||||
|
fn build_home_context<'a>(
|
||||||
|
progress: Option<&ProgressResource>,
|
||||||
|
stats: Option<&StatsResource>,
|
||||||
|
settings: Option<&SettingsResource>,
|
||||||
|
daily: Option<&DailyChallengeResource>,
|
||||||
|
font_res: Option<&'a FontResource>,
|
||||||
|
) -> HomeContext<'a> {
|
||||||
|
let daily_today = daily.map(|d| {
|
||||||
|
let completed_today = progress
|
||||||
|
.and_then(|p| p.0.daily_challenge_last_completed)
|
||||||
|
.is_some_and(|d_last| d_last == d.date);
|
||||||
|
DailyToday {
|
||||||
|
date_label: d.date.format("%b %-d").to_string(),
|
||||||
|
goal: d.goal_description.clone(),
|
||||||
|
completed_today,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
HomeContext {
|
||||||
|
level: progress.map_or(0, |p| p.0.level),
|
||||||
|
total_xp: progress.map_or(0, |p| p.0.total_xp),
|
||||||
|
daily_streak: progress.map_or(0, |p| p.0.daily_challenge_streak),
|
||||||
|
lifetime_score: stats.map_or(0, |s| s.0.lifetime_score),
|
||||||
|
classic_best: stats.map_or(0, |s| s.0.classic_best_score),
|
||||||
|
zen_best: stats.map_or(0, |s| s.0.zen_best_score),
|
||||||
|
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
|
||||||
|
daily_today,
|
||||||
|
draw_mode: settings
|
||||||
|
.map(|s| s.0.draw_mode.clone())
|
||||||
|
.unwrap_or(DrawMode::DrawOne),
|
||||||
|
font_res,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,10 +472,22 @@ fn handle_home_card_click(
|
|||||||
|
|
||||||
fn handle_home_cancel_button(
|
fn handle_home_cancel_button(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
|
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||||
cancel_buttons: Query<&Interaction, (With<HomeCancelButton>, Changed<Interaction>)>,
|
cancel_buttons: Query<&Interaction, (With<HomeCancelButton>, Changed<Interaction>)>,
|
||||||
screens: Query<Entity, With<HomeScreen>>,
|
screens: Query<Entity, With<HomeScreen>>,
|
||||||
|
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
|
||||||
) {
|
) {
|
||||||
if !cancel_buttons.iter().any(|i| *i == Interaction::Pressed) {
|
if screens.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let click = cancel_buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||||
|
let esc = keys.is_some_and(|k| k.just_pressed(KeyCode::Escape));
|
||||||
|
// Esc only closes Home when it is the *topmost* modal. With Profile
|
||||||
|
// (or any other ModalScrim) layered on top, the topmost owns the
|
||||||
|
// dismissal — without this gate a single Esc closed the back
|
||||||
|
// modal (Home) and left the front modal orphaned.
|
||||||
|
let esc_targets_home = esc && other_modal_scrims.is_empty();
|
||||||
|
if !click && !esc_targets_home {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for entity in &screens {
|
for entity in &screens {
|
||||||
@@ -261,6 +495,115 @@ fn handle_home_cancel_button(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Header chip + draw-mode button handlers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Routes mouse-wheel events into the Home modal's scrollable body
|
||||||
|
/// while the modal is open. No-op when no `HomeScrollable` exists in
|
||||||
|
/// the world (modal closed). Mirrors `scroll_settings_panel` and
|
||||||
|
/// `scroll_leaderboard_panel`.
|
||||||
|
fn scroll_home_panel(
|
||||||
|
mut scroll_evr: MessageReader<MouseWheel>,
|
||||||
|
mut scrollables: Query<&mut ScrollPosition, With<HomeScrollable>>,
|
||||||
|
) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Click on the player-stats header chip → fire
|
||||||
|
/// [`ToggleProfileRequestEvent`] so the Profile overlay opens on top
|
||||||
|
/// of Home. Closing Profile (`P` / `Esc`) returns the player to the
|
||||||
|
/// Home picker without losing their context.
|
||||||
|
fn handle_home_profile_chip(
|
||||||
|
chips: Query<&Interaction, (With<HomeProfileChip>, Changed<Interaction>)>,
|
||||||
|
mut profile: MessageWriter<ToggleProfileRequestEvent>,
|
||||||
|
) {
|
||||||
|
if chips.iter().any(|i| *i == Interaction::Pressed) {
|
||||||
|
profile.write(ToggleProfileRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Click on a draw-mode chip — flip `Settings.draw_mode`, persist,
|
||||||
|
/// fire `SettingsChangedEvent`, and respawn the Home modal so the
|
||||||
|
/// active-chip styling reflects the new state. Repaint by full
|
||||||
|
/// rebuild keeps the helper code small (no per-entity colour
|
||||||
|
/// surgery) and the modal is light enough to respawn cleanly.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn handle_home_draw_mode_buttons(
|
||||||
|
mut commands: Commands,
|
||||||
|
one_buttons: Query<&Interaction, (With<HomeDrawOneButton>, Changed<Interaction>)>,
|
||||||
|
three_buttons: Query<&Interaction, (With<HomeDrawThreeButton>, Changed<Interaction>)>,
|
||||||
|
screens: Query<Entity, With<HomeScreen>>,
|
||||||
|
mut settings: Option<ResMut<SettingsResource>>,
|
||||||
|
storage_path: Option<Res<SettingsStoragePath>>,
|
||||||
|
mut changed: MessageWriter<SettingsChangedEvent>,
|
||||||
|
progress: Option<Res<ProgressResource>>,
|
||||||
|
stats: Option<Res<StatsResource>>,
|
||||||
|
daily: Option<Res<DailyChallengeResource>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
) {
|
||||||
|
if screens.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let want_one = one_buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||||
|
let want_three = three_buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||||
|
if !want_one && !want_three {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(settings) = settings.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let target = if want_one {
|
||||||
|
DrawMode::DrawOne
|
||||||
|
} else {
|
||||||
|
DrawMode::DrawThree
|
||||||
|
};
|
||||||
|
if settings.0.draw_mode == target {
|
||||||
|
return; // already in this mode — avoid a redundant respawn.
|
||||||
|
}
|
||||||
|
settings.0.draw_mode = target;
|
||||||
|
if let Some(p) = storage_path
|
||||||
|
&& let Some(path) = p.0.as_deref()
|
||||||
|
&& let Err(e) = save_settings_to(path, &settings.0)
|
||||||
|
{
|
||||||
|
warn!("home: failed to persist draw-mode change: {e}");
|
||||||
|
}
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
|
||||||
|
// Repaint by despawn + respawn so the chip styling and any
|
||||||
|
// dependent labels (none today, but Phase B may surface a
|
||||||
|
// "Standard (Draw 1)" caption like MSSC) reflect the new state.
|
||||||
|
for entity in &screens {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
spawn_home_screen(
|
||||||
|
&mut commands,
|
||||||
|
build_home_context(
|
||||||
|
progress.as_deref(),
|
||||||
|
stats.as_deref(),
|
||||||
|
Some(settings),
|
||||||
|
daily.as_deref(),
|
||||||
|
font_res.as_deref(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Digit-key shortcuts (1-5) — modal-scoped
|
// Digit-key shortcuts (1-5) — modal-scoped
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -357,20 +700,95 @@ fn handle_home_digit_keys(
|
|||||||
// Spawn helpers
|
// Spawn helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Spawns the Home modal with five mode cards plus a Cancel button.
|
/// Bundles the data the Home modal needs to render the new
|
||||||
fn spawn_home_screen(commands: &mut Commands, level: u32, font_res: Option<&FontResource>) {
|
/// MSSC-inspired header chips, per-mode score chips, and draw-mode
|
||||||
spawn_modal(commands, HomeScreen, Z_MODAL_PANEL, |card| {
|
/// row. Built fresh by the two call sites (`spawn_home_on_launch`
|
||||||
|
/// and `toggle_home_screen`) from the live progress / stats /
|
||||||
|
/// settings resources, with sensible defaults when a resource is
|
||||||
|
/// missing under `MinimalPlugins` headless tests.
|
||||||
|
struct HomeContext<'a> {
|
||||||
|
level: u32,
|
||||||
|
total_xp: u64,
|
||||||
|
lifetime_score: u64,
|
||||||
|
classic_best: u32,
|
||||||
|
zen_best: u32,
|
||||||
|
challenge_best: u32,
|
||||||
|
daily_streak: u32,
|
||||||
|
daily_today: Option<DailyToday>,
|
||||||
|
draw_mode: DrawMode,
|
||||||
|
font_res: Option<&'a FontResource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Today's daily-challenge metadata as the Home picker needs it. Only
|
||||||
|
/// populated when both [`DailyChallengeResource`] is present (the
|
||||||
|
/// plugin is wired) and we have something useful to show — otherwise
|
||||||
|
/// the Daily card falls back to its baseline description without a
|
||||||
|
/// dated callout.
|
||||||
|
struct DailyToday {
|
||||||
|
/// Short calendar label, e.g. `"May 6"`. Always populated.
|
||||||
|
date_label: String,
|
||||||
|
/// Server-supplied goal copy ("Win in under 5 minutes"). `None`
|
||||||
|
/// when no server backend is wired or the fetch hasn't returned.
|
||||||
|
goal: Option<String>,
|
||||||
|
/// `true` when the player has already recorded today's daily.
|
||||||
|
/// Surfaces a "Done" badge so the picker reads as reward-state
|
||||||
|
/// rather than "you still owe today's run".
|
||||||
|
completed_today: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the Home modal with the player-stats header strip, draw-mode
|
||||||
|
/// row, five mode cards, and a Cancel button.
|
||||||
|
fn spawn_home_screen(commands: &mut Commands, ctx: HomeContext<'_>) {
|
||||||
|
let HomeContext { font_res, .. } = ctx;
|
||||||
|
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 [
|
// Scrollable middle — chips + draw row + tile grid. Constrained
|
||||||
HomeMode::Classic,
|
// to 70vh so the modal fits on small viewports (the 5-tile
|
||||||
HomeMode::Daily,
|
// grid alone is ~540 px). Cancel button sits outside this
|
||||||
HomeMode::Zen,
|
// node so it's always one click away.
|
||||||
HomeMode::Challenge,
|
card.spawn((
|
||||||
HomeMode::TimeAttack,
|
HomeScrollable,
|
||||||
] {
|
ScrollPosition::default(),
|
||||||
spawn_mode_card(card, mode, level, font_res);
|
Node {
|
||||||
}
|
flex_direction: FlexDirection::Column,
|
||||||
|
row_gap: VAL_SPACE_3,
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
max_height: Val::Vh(70.0),
|
||||||
|
overflow: Overflow::scroll_y(),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.with_children(|body| {
|
||||||
|
spawn_home_header_chips(body, &ctx);
|
||||||
|
spawn_draw_mode_row(body, &ctx);
|
||||||
|
|
||||||
|
// Mode tiles in a wrapping 2-column grid. Each tile takes 48%
|
||||||
|
// of the row so column_gap fits comfortably; the 5 modes wrap
|
||||||
|
// to a third row of one tile, which we leave left-aligned —
|
||||||
|
// the asymmetry matches MSSC's "Daily Challenges / Today's
|
||||||
|
// Event" half-cell on the right of their grid and keeps the
|
||||||
|
// visual rhythm.
|
||||||
|
body.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
flex_wrap: FlexWrap::Wrap,
|
||||||
|
row_gap: VAL_SPACE_3,
|
||||||
|
column_gap: VAL_SPACE_3,
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|grid| {
|
||||||
|
for mode in [
|
||||||
|
HomeMode::Classic,
|
||||||
|
HomeMode::Daily,
|
||||||
|
HomeMode::Zen,
|
||||||
|
HomeMode::Challenge,
|
||||||
|
HomeMode::TimeAttack,
|
||||||
|
] {
|
||||||
|
spawn_mode_card(grid, mode, &ctx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
spawn_modal_actions(card, |actions| {
|
spawn_modal_actions(card, |actions| {
|
||||||
spawn_modal_button(
|
spawn_modal_button(
|
||||||
@@ -383,6 +801,190 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Player-stats chip strip — Level, XP, Lifetime Score. Clickable as a
|
||||||
|
/// whole to open the Profile overlay (mirrors the MSSC top-right
|
||||||
|
/// avatar+rewards corner that surfaces level + premium status). Falls
|
||||||
|
/// back to plain Text in headless contexts where `Button` interaction
|
||||||
|
/// isn't driven by the input pipeline anyway.
|
||||||
|
fn spawn_home_header_chips(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) {
|
||||||
|
let font_handle = ctx.font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||||
|
let font_label = TextFont {
|
||||||
|
font: font_handle.clone(),
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let font_value = TextFont {
|
||||||
|
font: font_handle,
|
||||||
|
font_size: TYPE_BODY,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
parent
|
||||||
|
.spawn((
|
||||||
|
HomeProfileChip,
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
justify_content: JustifyContent::SpaceBetween,
|
||||||
|
column_gap: VAL_SPACE_2,
|
||||||
|
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
|
||||||
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(BG_ELEVATED),
|
||||||
|
BorderColor::all(BORDER_SUBTLE),
|
||||||
|
))
|
||||||
|
.with_children(|row| {
|
||||||
|
for (label, value) in [
|
||||||
|
("Level".to_string(), format_compact(ctx.level as u64)),
|
||||||
|
("XP".to_string(), format_compact(ctx.total_xp)),
|
||||||
|
("Score".to_string(), format_compact(ctx.lifetime_score)),
|
||||||
|
] {
|
||||||
|
row.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
row_gap: VAL_SPACE_1,
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|col| {
|
||||||
|
col.spawn((
|
||||||
|
Text::new(label),
|
||||||
|
font_label.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
col.spawn((
|
||||||
|
Text::new(value),
|
||||||
|
font_value.clone(),
|
||||||
|
TextColor(ACCENT_PRIMARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw-mode row — "Draw 1" / "Draw 3" toggle. Affects the next Classic
|
||||||
|
/// deal (the Settings value the new-game flow reads). Surfacing it on
|
||||||
|
/// the Home modal keeps the per-game choice one tap away rather than
|
||||||
|
/// buried in Settings, mirroring the dropdown MSSC puts on its
|
||||||
|
/// difficulty picker.
|
||||||
|
fn spawn_draw_mode_row(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) {
|
||||||
|
let font_handle = ctx.font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||||
|
let font_label = TextFont {
|
||||||
|
font: font_handle.clone(),
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let font_btn = TextFont {
|
||||||
|
font: font_handle,
|
||||||
|
font_size: TYPE_BODY,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let active_one = matches!(ctx.draw_mode, DrawMode::DrawOne);
|
||||||
|
|
||||||
|
parent
|
||||||
|
.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: VAL_SPACE_3,
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
|
row.spawn((
|
||||||
|
Text::new("Draw mode"),
|
||||||
|
font_label.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
spawn_draw_mode_chip::<HomeDrawOneButton>(
|
||||||
|
row,
|
||||||
|
HomeDrawOneButton,
|
||||||
|
"Draw 1",
|
||||||
|
active_one,
|
||||||
|
&font_btn,
|
||||||
|
);
|
||||||
|
spawn_draw_mode_chip::<HomeDrawThreeButton>(
|
||||||
|
row,
|
||||||
|
HomeDrawThreeButton,
|
||||||
|
"Draw 3",
|
||||||
|
!active_one,
|
||||||
|
&font_btn,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_draw_mode_chip<M: Component>(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
marker: M,
|
||||||
|
label: &str,
|
||||||
|
active: bool,
|
||||||
|
font: &TextFont,
|
||||||
|
) {
|
||||||
|
let (bg, fg) = if active {
|
||||||
|
(ACCENT_PRIMARY, BG_ELEVATED)
|
||||||
|
} else {
|
||||||
|
(BG_ELEVATED_HI, TEXT_PRIMARY)
|
||||||
|
};
|
||||||
|
parent
|
||||||
|
.spawn((
|
||||||
|
marker,
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_1),
|
||||||
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(bg),
|
||||||
|
BorderColor::all(BORDER_SUBTLE),
|
||||||
|
))
|
||||||
|
.with_children(|c| {
|
||||||
|
c.spawn((Text::new(label.to_string()), font.clone(), TextColor(fg)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact decimal formatter: `1234567` → `"1.2M"`, `12345` → `"12.3K"`,
|
||||||
|
/// otherwise the raw number with thousands separators. Keeps chip text
|
||||||
|
/// short enough to fit a 3-up header strip without wrapping.
|
||||||
|
fn format_compact(n: u64) -> String {
|
||||||
|
if n >= 1_000_000 {
|
||||||
|
format!("{:.1}M", n as f64 / 1_000_000.0)
|
||||||
|
} else if n >= 10_000 {
|
||||||
|
format!("{:.1}K", n as f64 / 1_000.0)
|
||||||
|
} else if n >= 1_000 {
|
||||||
|
let (high, low) = (n / 1_000, n % 1_000);
|
||||||
|
format!("{high},{low:03}")
|
||||||
|
} else {
|
||||||
|
n.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-mode score / streak chip text. `None` for modes where no
|
||||||
|
/// per-mode best exists yet (Time Attack uses session scoring; modes
|
||||||
|
/// with `0` recorded mean "no win yet" and we hide the chip rather
|
||||||
|
/// than show a 0).
|
||||||
|
fn score_chip_text_for(mode: HomeMode, ctx: &HomeContext<'_>) -> Option<String> {
|
||||||
|
match mode {
|
||||||
|
HomeMode::Classic if ctx.classic_best > 0 => {
|
||||||
|
Some(format!("Best {}", format_compact(ctx.classic_best as u64)))
|
||||||
|
}
|
||||||
|
HomeMode::Zen if ctx.zen_best > 0 => {
|
||||||
|
Some(format!("Best {}", format_compact(ctx.zen_best as u64)))
|
||||||
|
}
|
||||||
|
HomeMode::Challenge if ctx.challenge_best > 0 => {
|
||||||
|
Some(format!("Best {}", format_compact(ctx.challenge_best as u64)))
|
||||||
|
}
|
||||||
|
HomeMode::Daily if ctx.daily_streak > 0 => {
|
||||||
|
Some(format!("Streak {}", ctx.daily_streak))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
||||||
@@ -456,9 +1058,11 @@ fn attach_focusable_to_home_mode_cards(
|
|||||||
fn spawn_mode_card(
|
fn spawn_mode_card(
|
||||||
parent: &mut ChildSpawnerCommands,
|
parent: &mut ChildSpawnerCommands,
|
||||||
mode: HomeMode,
|
mode: HomeMode,
|
||||||
level: u32,
|
ctx: &HomeContext<'_>,
|
||||||
font_res: Option<&FontResource>,
|
|
||||||
) {
|
) {
|
||||||
|
let level = ctx.level;
|
||||||
|
let font_res = ctx.font_res;
|
||||||
|
let score_chip = score_chip_text_for(mode, ctx);
|
||||||
let unlocked = mode.is_unlocked(level);
|
let unlocked = mode.is_unlocked(level);
|
||||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||||
let font_title = TextFont {
|
let font_title = TextFont {
|
||||||
@@ -472,10 +1076,17 @@ fn spawn_mode_card(
|
|||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
let font_chip = TextFont {
|
let font_chip = TextFont {
|
||||||
font: font_handle,
|
font: font_handle.clone(),
|
||||||
font_size: TYPE_CAPTION,
|
font_size: TYPE_CAPTION,
|
||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
|
// Glyph rendered at display size — Unicode emoji standing in for
|
||||||
|
// the per-mode artwork. Centred at the top of the tile.
|
||||||
|
let font_glyph = TextFont {
|
||||||
|
font: font_handle,
|
||||||
|
font_size: TYPE_DISPLAY,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
// Locked cards mute their text to communicate the disabled state at
|
// Locked cards mute their text to communicate the disabled state at
|
||||||
// a glance; the explicit "Unlocks at level N" caption underneath
|
// a glance; the explicit "Unlocks at level N" caption underneath
|
||||||
@@ -483,6 +1094,7 @@ fn spawn_mode_card(
|
|||||||
let title_color = if unlocked { TEXT_PRIMARY } else { TEXT_DISABLED };
|
let title_color = if unlocked { TEXT_PRIMARY } else { TEXT_DISABLED };
|
||||||
let desc_color = if unlocked { TEXT_SECONDARY } else { TEXT_DISABLED };
|
let desc_color = if unlocked { TEXT_SECONDARY } else { TEXT_DISABLED };
|
||||||
let border_color = if unlocked { BORDER_SUBTLE } else { BORDER_STRONG };
|
let border_color = if unlocked { BORDER_SUBTLE } else { BORDER_STRONG };
|
||||||
|
let glyph_color = if unlocked { ACCENT_PRIMARY } else { TEXT_DISABLED };
|
||||||
|
|
||||||
parent
|
parent
|
||||||
.spawn((
|
.spawn((
|
||||||
@@ -493,9 +1105,13 @@ fn spawn_mode_card(
|
|||||||
Button,
|
Button,
|
||||||
Node {
|
Node {
|
||||||
flex_direction: FlexDirection::Column,
|
flex_direction: FlexDirection::Column,
|
||||||
row_gap: VAL_SPACE_1,
|
row_gap: VAL_SPACE_2,
|
||||||
padding: UiRect::all(VAL_SPACE_3),
|
padding: UiRect::all(VAL_SPACE_3),
|
||||||
width: Val::Percent(100.0),
|
// 48% per tile + the row's column_gap = a clean 2-up
|
||||||
|
// grid that wraps to a single tile on the third row.
|
||||||
|
width: Val::Percent(48.0),
|
||||||
|
min_height: Val::Px(180.0),
|
||||||
|
align_items: AlignItems::Center,
|
||||||
border: UiRect::all(Val::Px(1.0)),
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
..default()
|
..default()
|
||||||
@@ -504,12 +1120,20 @@ fn spawn_mode_card(
|
|||||||
BorderColor::all(border_color),
|
BorderColor::all(border_color),
|
||||||
))
|
))
|
||||||
.with_children(|c| {
|
.with_children(|c| {
|
||||||
|
// Centerpiece glyph — placeholder for real per-mode art.
|
||||||
|
c.spawn((
|
||||||
|
Text::new(mode.glyph().to_string()),
|
||||||
|
font_glyph.clone(),
|
||||||
|
TextColor(glyph_color),
|
||||||
|
));
|
||||||
|
|
||||||
// Title row — title text on the left, hotkey chip on the right.
|
// Title row — title text on the left, hotkey chip on the right.
|
||||||
c.spawn(Node {
|
c.spawn(Node {
|
||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Row,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
justify_content: JustifyContent::SpaceBetween,
|
justify_content: JustifyContent::SpaceBetween,
|
||||||
column_gap: VAL_SPACE_3,
|
column_gap: VAL_SPACE_3,
|
||||||
|
width: Val::Percent(100.0),
|
||||||
..default()
|
..default()
|
||||||
})
|
})
|
||||||
.with_children(|row| {
|
.with_children(|row| {
|
||||||
@@ -559,6 +1183,59 @@ fn spawn_mode_card(
|
|||||||
TextColor(desc_color),
|
TextColor(desc_color),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// Per-mode score / streak chip — populated only when the
|
||||||
|
// player has data for this mode. Hidden on a 0 best so a
|
||||||
|
// fresh profile doesn't show "Best 0" everywhere.
|
||||||
|
if let Some(text) = score_chip.clone()
|
||||||
|
&& unlocked
|
||||||
|
{
|
||||||
|
c.spawn((
|
||||||
|
Text::new(text),
|
||||||
|
font_chip.clone(),
|
||||||
|
TextColor(ACCENT_PRIMARY),
|
||||||
|
Node {
|
||||||
|
margin: UiRect::top(VAL_SPACE_1),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daily-only "Today's Event" caption — date, optional
|
||||||
|
// server goal, and a "Done" badge once the player has
|
||||||
|
// already recorded today's completion. Only renders for
|
||||||
|
// the Daily card when DailyChallengeResource is present.
|
||||||
|
if matches!(mode, HomeMode::Daily)
|
||||||
|
&& unlocked
|
||||||
|
&& let Some(today) = ctx.daily_today.as_ref()
|
||||||
|
{
|
||||||
|
let date_text = if today.completed_today {
|
||||||
|
format!("Today, {} \u{2022} Done", today.date_label)
|
||||||
|
} else {
|
||||||
|
format!("Today, {}", today.date_label)
|
||||||
|
};
|
||||||
|
let date_color = if today.completed_today {
|
||||||
|
ACCENT_PRIMARY
|
||||||
|
} else {
|
||||||
|
STATE_INFO
|
||||||
|
};
|
||||||
|
c.spawn((
|
||||||
|
Text::new(date_text),
|
||||||
|
font_chip.clone(),
|
||||||
|
TextColor(date_color),
|
||||||
|
Node {
|
||||||
|
margin: UiRect::top(VAL_SPACE_1),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
if let Some(goal) = today.goal.as_ref() {
|
||||||
|
c.spawn((
|
||||||
|
Text::new(format!("Goal: {goal}")),
|
||||||
|
font_chip.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Locked footnote — explicit copy so the gate is unambiguous.
|
// Locked footnote — explicit copy so the gate is unambiguous.
|
||||||
if !unlocked {
|
if !unlocked {
|
||||||
c.spawn((
|
c.spawn((
|
||||||
@@ -599,7 +1276,7 @@ mod tests {
|
|||||||
.add_plugins(GamePlugin)
|
.add_plugins(GamePlugin)
|
||||||
.add_plugins(TablePlugin)
|
.add_plugins(TablePlugin)
|
||||||
.add_plugins(ProgressPlugin::headless())
|
.add_plugins(ProgressPlugin::headless())
|
||||||
.add_plugins(HomePlugin);
|
.add_plugins(HomePlugin::headless());
|
||||||
app.init_resource::<ButtonInput<KeyCode>>();
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
app.update();
|
app.update();
|
||||||
app
|
app
|
||||||
@@ -889,7 +1566,7 @@ mod tests {
|
|||||||
.add_plugins(GamePlugin)
|
.add_plugins(GamePlugin)
|
||||||
.add_plugins(TablePlugin)
|
.add_plugins(TablePlugin)
|
||||||
.add_plugins(ProgressPlugin::headless())
|
.add_plugins(ProgressPlugin::headless())
|
||||||
.add_plugins(HomePlugin);
|
.add_plugins(HomePlugin::headless());
|
||||||
app.init_resource::<ButtonInput<KeyCode>>();
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
app.update();
|
app.update();
|
||||||
app
|
app
|
||||||
|
|||||||
@@ -62,6 +62,18 @@ pub struct HudMode;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct HudChallenge;
|
pub struct HudChallenge;
|
||||||
|
|
||||||
|
/// Marker on the "won this deal before" indicator text node.
|
||||||
|
///
|
||||||
|
/// Displays `"✓ Won before"` when the current deal's seed + draw_mode +
|
||||||
|
/// mode triple matches one of the entries in `ReplayHistoryResource`.
|
||||||
|
/// Empty string otherwise (including won games — the score readout
|
||||||
|
/// already conveys the win on the active deal). Only meaningful for
|
||||||
|
/// Classic / Zen / Challenge — daily-challenge and time-attack seeds
|
||||||
|
/// are filtered out implicitly because their replay entries always
|
||||||
|
/// carry a different mode tag.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct HudWonPreviously;
|
||||||
|
|
||||||
/// Marker on the undo-count text node.
|
/// Marker on the undo-count text node.
|
||||||
///
|
///
|
||||||
/// Shows how many undos have been used this game. Displayed in amber when
|
/// Shows how many undos have been used this game. Displayed in amber when
|
||||||
@@ -194,6 +206,16 @@ pub const SCORE_FLOATER_THRESHOLD: i32 = 50;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct ActionButton;
|
pub struct ActionButton;
|
||||||
|
|
||||||
|
/// Marker on rows inside a popover panel ([`ModesPopover`] or
|
||||||
|
/// [`MenuPopover`]). Popover rows already carry `ActionButton` so the
|
||||||
|
/// hover/press paint path applies to them, but the auto-fade applied
|
||||||
|
/// to the top-level action bar must NOT also fade these rows — the
|
||||||
|
/// popover only renders when the player has explicitly opened it, so
|
||||||
|
/// its content should always be at full opacity. `apply_action_fade`
|
||||||
|
/// excludes entities with this marker via `Without<PopoverRow>`.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct PopoverRow;
|
||||||
|
|
||||||
/// Marker on the "New Game" action button anchored top-right of the play
|
/// Marker on the "New Game" action button anchored top-right of the play
|
||||||
/// area. Click fires [`NewGameRequestEvent`]; the existing
|
/// area. Click fires [`NewGameRequestEvent`]; the existing
|
||||||
/// `ConfirmNewGameScreen` modal handles confirmation when a game is in
|
/// `ConfirmNewGameScreen` modal handles confirmation when a game is in
|
||||||
@@ -302,6 +324,7 @@ impl Plugin for HudPlugin {
|
|||||||
.init_resource::<HudActionFade>()
|
.init_resource::<HudActionFade>()
|
||||||
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons))
|
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons))
|
||||||
.add_systems(Update, update_hud.after(GameMutation))
|
.add_systems(Update, update_hud.after(GameMutation))
|
||||||
|
.add_systems(Update, update_won_previously.after(GameMutation))
|
||||||
.add_systems(Update, announce_auto_complete.after(GameMutation))
|
.add_systems(Update, announce_auto_complete.after(GameMutation))
|
||||||
.add_systems(Update, update_selection_hud)
|
.add_systems(Update, update_selection_hud)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
@@ -481,6 +504,15 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
|||||||
font_body.clone(),
|
font_body.clone(),
|
||||||
TextColor(STATE_INFO),
|
TextColor(STATE_INFO),
|
||||||
));
|
));
|
||||||
|
t2.spawn((
|
||||||
|
HudWonPreviously,
|
||||||
|
Tooltip::new(
|
||||||
|
"You've won this deal before. Same seed in your replay history.",
|
||||||
|
),
|
||||||
|
Text::new(""),
|
||||||
|
font_body.clone(),
|
||||||
|
TextColor(STATE_SUCCESS),
|
||||||
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Tier 3 — penalty / bonus. Undos and Recycles share the
|
// Tier 3 — penalty / bonus. Undos and Recycles share the
|
||||||
@@ -834,6 +866,7 @@ fn spawn_modes_popover(
|
|||||||
.spawn((
|
.spawn((
|
||||||
option,
|
option,
|
||||||
ActionButton,
|
ActionButton,
|
||||||
|
PopoverRow,
|
||||||
Button,
|
Button,
|
||||||
Tooltip::new(tooltip),
|
Tooltip::new(tooltip),
|
||||||
Node {
|
Node {
|
||||||
@@ -987,6 +1020,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
|
|||||||
.spawn((
|
.spawn((
|
||||||
option,
|
option,
|
||||||
ActionButton,
|
ActionButton,
|
||||||
|
PopoverRow,
|
||||||
Button,
|
Button,
|
||||||
Tooltip::new(tooltip),
|
Tooltip::new(tooltip),
|
||||||
Node {
|
Node {
|
||||||
@@ -1117,9 +1151,20 @@ fn update_action_fade(
|
|||||||
/// `Last` (after `paint_action_buttons`) so a hover-state change in the
|
/// `Last` (after `paint_action_buttons`) so a hover-state change in the
|
||||||
/// same frame doesn't override the fade with an opaque idle / hover
|
/// same frame doesn't override the fade with an opaque idle / hover
|
||||||
/// colour.
|
/// colour.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
fn apply_action_fade(
|
fn apply_action_fade(
|
||||||
fade: Res<HudActionFade>,
|
fade: Res<HudActionFade>,
|
||||||
mut buttons: Query<(&Children, &mut BackgroundColor), With<ActionButton>>,
|
// Excludes `PopoverRow` so the auto-fade only applies to the
|
||||||
|
// top-level action bar buttons. Popover rows live inside an
|
||||||
|
// explicitly-opened dropdown panel and need to stay visible
|
||||||
|
// regardless of the bar's fade state — without the exclusion
|
||||||
|
// the rows fade to invisible while the popover container stays
|
||||||
|
// visible, leaving a solid background block with no readable
|
||||||
|
// content.
|
||||||
|
mut buttons: Query<
|
||||||
|
(&Children, &mut BackgroundColor),
|
||||||
|
(With<ActionButton>, Without<PopoverRow>),
|
||||||
|
>,
|
||||||
mut text_q: Query<&mut TextColor>,
|
mut text_q: Query<&mut TextColor>,
|
||||||
) {
|
) {
|
||||||
for (children, mut bg) in &mut buttons {
|
for (children, mut bg) in &mut buttons {
|
||||||
@@ -1480,6 +1525,42 @@ fn lerp_text_color(from: Color, to: Color, t: f32) -> Color {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets the [`HudWonPreviously`] text to "✓ Won before" whenever the
|
||||||
|
/// current deal's seed + draw_mode + mode triple matches an entry in
|
||||||
|
/// the rolling [`ReplayHistory`]. Cleared while the active game is won
|
||||||
|
/// (the on-screen "Game won!" cue already conveys victory) and on
|
||||||
|
/// fresh deals the player hasn't won before.
|
||||||
|
///
|
||||||
|
/// Lives in its own system rather than `update_hud` to keep this
|
||||||
|
/// orthogonal: `update_hud`'s query disambiguation is already busy
|
||||||
|
/// enough; threading another marker through every Without filter
|
||||||
|
/// would touch ~10 unrelated queries for no benefit.
|
||||||
|
fn update_won_previously(
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
// Optional because the HUD plugin's headless tests run without
|
||||||
|
// `StatsPlugin` and therefore without this resource. With the
|
||||||
|
// resource absent there's no history to compare against; the
|
||||||
|
// indicator just stays empty.
|
||||||
|
history: Option<Res<crate::stats_plugin::ReplayHistoryResource>>,
|
||||||
|
mut q: Query<&mut Text, With<HudWonPreviously>>,
|
||||||
|
) {
|
||||||
|
let Ok(mut text) = q.single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let won_before = !game.0.is_won
|
||||||
|
&& history.as_ref().is_some_and(|h| {
|
||||||
|
h.0.replays.iter().any(|r| {
|
||||||
|
r.seed == game.0.seed
|
||||||
|
&& r.draw_mode == game.0.draw_mode
|
||||||
|
&& r.mode == game.0.mode
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let next = if won_before { "\u{2713} Won before" } else { "" };
|
||||||
|
if text.0 != next {
|
||||||
|
text.0 = next.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||||
fn update_hud(
|
fn update_hud(
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
|
|||||||
@@ -40,10 +40,10 @@ use solitaire_core::game_state::DrawMode;
|
|||||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
DrawRequestEvent, ForfeitRequestEvent, HintVisualEvent, InfoToastEvent, MoveRejectedEvent,
|
DrawRequestEvent, ForfeitRequestEvent, HintVisualEvent, InfoToastEvent, MoveRejectedEvent,
|
||||||
MoveRequestEvent, NewGameConfirmEvent, NewGameRequestEvent, StartZenRequestEvent,
|
MoveRequestEvent, NewGameRequestEvent, StartZenRequestEvent, StateChangedEvent,
|
||||||
StateChangedEvent, UndoRequestEvent,
|
UndoRequestEvent,
|
||||||
};
|
};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::{ConfirmNewGameScreen, GameMutation, RestorePromptScreen};
|
||||||
use crate::pause_plugin::PausedResource;
|
use crate::pause_plugin::PausedResource;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::layout::{Layout, LayoutResource};
|
use crate::layout::{Layout, LayoutResource};
|
||||||
@@ -54,21 +54,15 @@ use crate::time_attack_plugin::TimeAttackResource;
|
|||||||
/// Z-depth used for cards while being dragged — above all resting cards.
|
/// Z-depth used for cards while being dragged — above all resting cards.
|
||||||
const DRAG_Z: f32 = 500.0;
|
const DRAG_Z: f32 = 500.0;
|
||||||
|
|
||||||
/// Shared countdown state for the new-game double-press confirmation
|
/// Solver budgets used by the H-key hint system.
|
||||||
/// flow.
|
|
||||||
///
|
///
|
||||||
/// Using a resource (instead of `Local`) lets the keyboard sub-systems
|
/// Wraps `solitaire_core::solver::SolverConfig` as a Bevy resource so
|
||||||
/// share the same countdown state without needing to pass values
|
/// tests can inject tighter budgets to exercise the heuristic-fallback
|
||||||
/// between them. Forfeit no longer has a keyboard countdown — `G` now
|
/// path. Production initialises this to `SolverConfig::default()` (100k
|
||||||
/// fires `ForfeitRequestEvent` and `PausePlugin` shows a real
|
/// move / 200k state budgets, the same numbers the new-game retry loop
|
||||||
/// `ForfeitConfirmScreen` modal.
|
/// uses).
|
||||||
#[derive(Resource, Debug, Default)]
|
#[derive(Resource, Debug, Clone, Default)]
|
||||||
struct KeyboardConfirmState {
|
pub struct HintSolverConfig(pub solitaire_core::solver::SolverConfig);
|
||||||
/// Seconds remaining in the new-game confirmation window (> 0 while open).
|
|
||||||
new_game_countdown: f32,
|
|
||||||
/// True while we are waiting for the second N press to confirm a new game.
|
|
||||||
new_game_pending: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Registers keyboard, mouse, and touch input systems.
|
/// Registers keyboard, mouse, and touch input systems.
|
||||||
///
|
///
|
||||||
@@ -89,8 +83,7 @@ pub struct InputPlugin;
|
|||||||
impl Plugin for InputPlugin {
|
impl Plugin for InputPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<HintCycleIndex>()
|
app.init_resource::<HintCycleIndex>()
|
||||||
.init_resource::<KeyboardConfirmState>()
|
.init_resource::<HintSolverConfig>()
|
||||||
.add_message::<NewGameConfirmEvent>()
|
|
||||||
.add_message::<StartZenRequestEvent>()
|
.add_message::<StartZenRequestEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.add_message::<ForfeitRequestEvent>()
|
.add_message::<ForfeitRequestEvent>()
|
||||||
@@ -120,9 +113,6 @@ impl Plugin for InputPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Seconds after the first N press during which a second N confirms new game.
|
|
||||||
const NEW_GAME_CONFIRM_WINDOW: f32 = 3.0;
|
|
||||||
|
|
||||||
/// Bundles the event writers needed by the core keyboard handler.
|
/// Bundles the event writers needed by the core keyboard handler.
|
||||||
///
|
///
|
||||||
/// Keeping these in a [`SystemParam`] avoids hitting Bevy's 16-parameter limit.
|
/// Keeping these in a [`SystemParam`] avoids hitting Bevy's 16-parameter limit.
|
||||||
@@ -130,43 +120,39 @@ const NEW_GAME_CONFIRM_WINDOW: f32 = 3.0;
|
|||||||
struct CoreKeyboardMessages<'w> {
|
struct CoreKeyboardMessages<'w> {
|
||||||
undo: MessageWriter<'w, UndoRequestEvent>,
|
undo: MessageWriter<'w, UndoRequestEvent>,
|
||||||
new_game: MessageWriter<'w, NewGameRequestEvent>,
|
new_game: MessageWriter<'w, NewGameRequestEvent>,
|
||||||
confirm_event: MessageWriter<'w, NewGameConfirmEvent>,
|
|
||||||
info_toast: MessageWriter<'w, InfoToastEvent>,
|
info_toast: MessageWriter<'w, InfoToastEvent>,
|
||||||
draw: MessageWriter<'w, DrawRequestEvent>,
|
draw: MessageWriter<'w, DrawRequestEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles the core keyboard shortcuts: U (undo), N (new game + confirmation
|
/// Handles the core keyboard shortcuts: U (undo), N (new game), Z (zen mode),
|
||||||
/// window), Z (zen mode), D / Space (draw), and ticks down the new-game
|
/// D / Space (draw).
|
||||||
/// confirmation countdown each frame.
|
///
|
||||||
|
/// `N` fires `NewGameRequestEvent` straight through; the existing
|
||||||
|
/// `handle_new_game` flow shows the `ConfirmNewGameScreen` modal when
|
||||||
|
/// the current game is in progress, so a single press surfaces a real
|
||||||
|
/// Confirm / Cancel UI instead of a "press N again" toast. `Shift+N`
|
||||||
|
/// keeps the keyboard power-user bypass by setting `confirmed: true`.
|
||||||
|
///
|
||||||
|
/// While the confirm modal or the restore prompt is already open, the
|
||||||
|
/// system skips the N branch so those modals' own input handlers can
|
||||||
|
/// process N (cancel / start-new-game) without us re-firing a request
|
||||||
|
/// the same frame.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn handle_keyboard_core(
|
fn handle_keyboard_core(
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
progress: Option<Res<ProgressResource>>,
|
progress: Option<Res<ProgressResource>>,
|
||||||
game: Option<Res<GameStateResource>>,
|
|
||||||
time: Res<Time>,
|
|
||||||
mut confirm: ResMut<KeyboardConfirmState>,
|
|
||||||
mut ev: CoreKeyboardMessages<'_>,
|
mut ev: CoreKeyboardMessages<'_>,
|
||||||
mut time_attack: Option<ResMut<TimeAttackResource>>,
|
mut time_attack: Option<ResMut<TimeAttackResource>>,
|
||||||
selection: Option<Res<SelectionState>>,
|
selection: Option<Res<SelectionState>>,
|
||||||
mut zen_requests: MessageReader<StartZenRequestEvent>,
|
mut zen_requests: MessageReader<StartZenRequestEvent>,
|
||||||
|
confirm_screens: Query<(), With<ConfirmNewGameScreen>>,
|
||||||
|
restore_prompts: Query<(), With<RestorePromptScreen>>,
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tick down the new-game confirmation window each frame.
|
|
||||||
if confirm.new_game_countdown > 0.0 {
|
|
||||||
confirm.new_game_countdown -= time.delta_secs();
|
|
||||||
if confirm.new_game_countdown <= 0.0 {
|
|
||||||
confirm.new_game_countdown = 0.0;
|
|
||||||
if confirm.new_game_pending {
|
|
||||||
confirm.new_game_pending = false;
|
|
||||||
ev.info_toast.write(InfoToastEvent("New game cancelled".to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if keys.just_pressed(KeyCode::KeyU) {
|
if keys.just_pressed(KeyCode::KeyU) {
|
||||||
ev.undo.write(UndoRequestEvent);
|
ev.undo.write(UndoRequestEvent);
|
||||||
}
|
}
|
||||||
@@ -183,27 +169,24 @@ fn handle_keyboard_core(
|
|||||||
mode: Some(solitaire_core::game_state::GameMode::Classic),
|
mode: Some(solitaire_core::game_state::GameMode::Classic),
|
||||||
confirmed: false,
|
confirmed: false,
|
||||||
});
|
});
|
||||||
confirm.new_game_countdown = 0.0;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let active_game = game.as_ref().is_some_and(|g| g.0.move_count > 0 && !g.0.is_won);
|
// The confirm modal and restore prompt own N while they're up —
|
||||||
let shift_held = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
|
// they cancel / accept respectively. Skipping here prevents us
|
||||||
if shift_held || !active_game {
|
// from firing a fresh request the same frame those modals close.
|
||||||
// Shift+N or no active game — start immediately, no confirmation.
|
if !confirm_screens.is_empty() || !restore_prompts.is_empty() {
|
||||||
ev.new_game.write(NewGameRequestEvent::default());
|
// intentional: defer to those modals' input handlers.
|
||||||
confirm.new_game_countdown = 0.0;
|
|
||||||
confirm.new_game_pending = false;
|
|
||||||
} else if confirm.new_game_countdown > 0.0 {
|
|
||||||
// Second press within the window — confirmed.
|
|
||||||
ev.new_game.write(NewGameRequestEvent::default());
|
|
||||||
confirm.new_game_countdown = 0.0;
|
|
||||||
confirm.new_game_pending = false;
|
|
||||||
} else {
|
} else {
|
||||||
// First press on an active game — require confirmation.
|
let shift_held = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
|
||||||
confirm.new_game_countdown = NEW_GAME_CONFIRM_WINDOW;
|
ev.new_game.write(NewGameRequestEvent {
|
||||||
confirm.new_game_pending = true;
|
seed: None,
|
||||||
ev.confirm_event.write(NewGameConfirmEvent);
|
mode: None,
|
||||||
|
// Shift+N skips the confirm modal for keyboard power-users;
|
||||||
|
// bare N falls through `handle_new_game`'s active-game check
|
||||||
|
// and shows the modal when a game is in progress.
|
||||||
|
confirmed: shift_held,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,20 +219,34 @@ fn handle_keyboard_core(
|
|||||||
// Esc is handled by `PausePlugin` (overlay toggle + paused flag).
|
// Esc is handled by `PausePlugin` (overlay toggle + paused flag).
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles the H key: cycles through all available hints, highlighting the
|
/// Handles the H key: surface the solver's provably-best first move when
|
||||||
/// source card yellow for 2 s and showing a descriptive toast.
|
/// the position is winnable; otherwise fall back to cycling through the
|
||||||
|
/// heuristic hints.
|
||||||
///
|
///
|
||||||
/// The hint index wraps around once all hints have been cycled through. When no
|
/// The solver (`solitaire_core::solver::try_solve_from_state`) is run
|
||||||
/// moves are available a "No hints available" toast is shown instead.
|
/// synchronously on each H press — median ~2 ms on real positions, with a
|
||||||
|
/// hard cap from `SolverConfig::default()`'s budgets. When the verdict is
|
||||||
|
/// `Winnable`, the returned `first_move` is shown as a single, stable hint
|
||||||
|
/// (no cycling — the optimal move doesn't change between identical
|
||||||
|
/// presses). When the verdict is `Unwinnable` or `Inconclusive`, the
|
||||||
|
/// handler falls back to the legacy heuristic in `all_hints`, which still
|
||||||
|
/// cycles through every legal move.
|
||||||
|
///
|
||||||
|
/// When no moves are available a "No hints available" toast is shown
|
||||||
|
/// instead. The H key always produces a hint when any legal move exists.
|
||||||
|
///
|
||||||
|
/// TODO: if profiling ever shows >100 ms solver calls in practice, move
|
||||||
|
/// the solver call to `AsyncComputeTaskPool` to keep input latency low.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn handle_keyboard_hint(
|
fn handle_keyboard_hint(
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
game: Option<Res<GameStateResource>>,
|
game: Option<Res<GameStateResource>>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
solver_config: Res<HintSolverConfig>,
|
||||||
mut hint_cycle: ResMut<HintCycleIndex>,
|
mut hint_cycle: ResMut<HintCycleIndex>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
|
card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
|
||||||
mut info_toast: MessageWriter<InfoToastEvent>,
|
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||||
mut hint_visual: MessageWriter<HintVisualEvent>,
|
mut hint_visual: MessageWriter<HintVisualEvent>,
|
||||||
) {
|
) {
|
||||||
@@ -269,6 +266,25 @@ fn handle_keyboard_hint(
|
|||||||
|
|
||||||
let Some(_layout_res) = layout else { return };
|
let Some(_layout_res) = layout else { return };
|
||||||
|
|
||||||
|
// First pass: ask the solver for the provably-best move. The
|
||||||
|
// solver is deterministic, so repeated H presses on the same
|
||||||
|
// position keep showing the same hint (cycling is reserved for
|
||||||
|
// the heuristic fallback path).
|
||||||
|
use solitaire_core::solver::{try_solve_from_state, SolverResult};
|
||||||
|
let outcome = try_solve_from_state(&g.0, &solver_config.0);
|
||||||
|
if outcome.result == SolverResult::Winnable
|
||||||
|
&& let Some(mv) = outcome.first_move
|
||||||
|
{
|
||||||
|
let from = mv.source.clone();
|
||||||
|
let to = mv.dest.clone();
|
||||||
|
emit_hint_visuals(&g.0, &from, &to, &mut commands, card_entities, &mut info_toast, &mut hint_visual);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: heuristic cycling hint. Used when the solver verdict
|
||||||
|
// is `Unwinnable` (no legal winning path — but a legal *move* may
|
||||||
|
// still exist, e.g. drawing from stock) or `Inconclusive` (budget
|
||||||
|
// exhausted on a complex mid-game position).
|
||||||
let hints = all_hints(&g.0);
|
let hints = all_hints(&g.0);
|
||||||
if hints.is_empty() {
|
if hints.is_empty() {
|
||||||
info_toast.write(InfoToastEvent("No hints available".to_string()));
|
info_toast.write(InfoToastEvent("No hints available".to_string()));
|
||||||
@@ -278,14 +294,29 @@ fn handle_keyboard_hint(
|
|||||||
// Pick the hint at the current cycle index (wrapping) and advance.
|
// Pick the hint at the current cycle index (wrapping) and advance.
|
||||||
let idx = hint_cycle.0 % hints.len();
|
let idx = hint_cycle.0 % hints.len();
|
||||||
hint_cycle.0 = hint_cycle.0.wrapping_add(1);
|
hint_cycle.0 = hint_cycle.0.wrapping_add(1);
|
||||||
let (from, to, _count) = &hints[idx];
|
let (from, to, _count) = hints[idx].clone();
|
||||||
|
emit_hint_visuals(&g.0, &from, &to, &mut commands, card_entities, &mut info_toast, &mut hint_visual);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the visual + toast effects for a single chosen hint move.
|
||||||
|
///
|
||||||
|
/// Shared between the solver-driven and heuristic-driven hint paths so
|
||||||
|
/// both produce identical player-facing feedback.
|
||||||
|
fn emit_hint_visuals(
|
||||||
|
game: &GameState,
|
||||||
|
from: &PileType,
|
||||||
|
to: &PileType,
|
||||||
|
commands: &mut Commands,
|
||||||
|
mut card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
|
||||||
|
info_toast: &mut MessageWriter<InfoToastEvent>,
|
||||||
|
hint_visual: &mut MessageWriter<HintVisualEvent>,
|
||||||
|
) {
|
||||||
// When the hint points at the stock (draw suggestion) there is no
|
// When the hint points at the stock (draw suggestion) there is no
|
||||||
// face-up card to highlight — show a toast instead.
|
// face-up card to highlight — show a toast instead.
|
||||||
// If the stock is empty, pressing D will recycle the waste rather
|
// If the stock is empty, pressing D will recycle the waste rather
|
||||||
// than draw a card, so the toast text must reflect that.
|
// than draw a card, so the toast text must reflect that.
|
||||||
if *from == PileType::Stock {
|
if *from == PileType::Stock {
|
||||||
let stock_empty = g.0.piles
|
let stock_empty = game.piles
|
||||||
.get(&PileType::Stock)
|
.get(&PileType::Stock)
|
||||||
.is_some_and(|p| p.cards.is_empty());
|
.is_some_and(|p| p.cards.is_empty());
|
||||||
let msg = if stock_empty {
|
let msg = if stock_empty {
|
||||||
@@ -298,7 +329,7 @@ fn handle_keyboard_hint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find the top face-up card in the source pile and highlight it.
|
// Find the top face-up card in the source pile and highlight it.
|
||||||
let top_card_id = g.0.piles.get(from)
|
let top_card_id = game.piles.get(from)
|
||||||
.and_then(|p| p.cards.last().filter(|c| c.face_up))
|
.and_then(|p| p.cards.last().filter(|c| c.face_up))
|
||||||
.map(|c| c.id);
|
.map(|c| c.id);
|
||||||
if let Some(card_id) = top_card_id {
|
if let Some(card_id) = top_card_id {
|
||||||
@@ -327,7 +358,7 @@ fn handle_keyboard_hint(
|
|||||||
// player keeps thinking in suit terms; otherwise fall back to "foundation".
|
// player keeps thinking in suit terms; otherwise fall back to "foundation".
|
||||||
let msg = match to {
|
let msg = match to {
|
||||||
PileType::Foundation(_) => {
|
PileType::Foundation(_) => {
|
||||||
let claimed = g.0.piles.get(to).and_then(|p| p.claimed_suit());
|
let claimed = game.piles.get(to).and_then(|p| p.claimed_suit());
|
||||||
if let Some(suit) = claimed {
|
if let Some(suit) = claimed {
|
||||||
let suit_name = match suit {
|
let suit_name = match suit {
|
||||||
Suit::Clubs => "Clubs",
|
Suit::Clubs => "Clubs",
|
||||||
@@ -1960,15 +1991,6 @@ mod tests {
|
|||||||
assert!(hints.is_empty(), "no hint should exist when the game is truly stuck");
|
assert!(hints.is_empty(), "no hint should exist when the game is truly stuck");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Const-assert that `NEW_GAME_CONFIRM_WINDOW` is positive so the
|
|
||||||
/// confirmation countdown actually opens on the first N press.
|
|
||||||
///
|
|
||||||
/// Mirrors the existing `forfeit_confirm_window_is_positive` test.
|
|
||||||
#[test]
|
|
||||||
fn new_game_confirm_window_is_positive() {
|
|
||||||
const { assert!(NEW_GAME_CONFIRM_WINDOW > 0.0, "NEW_GAME_CONFIRM_WINDOW must be > 0"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Drag-rejection return tween — `CardAnimation` replaces the legacy
|
// Drag-rejection return tween — `CardAnimation` replaces the legacy
|
||||||
// `ShakeAnim` on the dragged cards. The audio cue
|
// `ShakeAnim` on the dragged cards. The audio cue
|
||||||
@@ -2125,5 +2147,194 @@ mod tests {
|
|||||||
anim.end_z
|
anim.end_z
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Hint system — solver promotion (v0.16.0+)
|
||||||
|
//
|
||||||
|
// The H-key hint is now backed by `solitaire_core::solver::try_solve_from_state`.
|
||||||
|
// When the solver proves the position winnable, the hint is the
|
||||||
|
// first move on the solver's solution path. When the solver returns
|
||||||
|
// Inconclusive (budget exhausted) or Unwinnable, the legacy
|
||||||
|
// heuristic in `all_hints` supplies the hint instead so the H key
|
||||||
|
// always produces feedback while any legal move exists.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Build a minimal Bevy app that registers only the resources and
|
||||||
|
/// messages needed to drive `handle_keyboard_hint` end-to-end.
|
||||||
|
/// Skips every other input system — the test only exercises the hint
|
||||||
|
/// path and we want the assertions to be unaffected by other handlers.
|
||||||
|
fn hint_test_app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins);
|
||||||
|
app.add_message::<InfoToastEvent>();
|
||||||
|
app.add_message::<HintVisualEvent>();
|
||||||
|
app.init_resource::<HintCycleIndex>();
|
||||||
|
app.init_resource::<HintSolverConfig>();
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
// Layout: a fixed 1280x800 layout — `handle_keyboard_hint` only
|
||||||
|
// checks the resource is present, never reads coordinates.
|
||||||
|
app.insert_resource(crate::layout::LayoutResource(
|
||||||
|
crate::layout::compute_layout(Vec2::new(1280.0, 800.0)),
|
||||||
|
));
|
||||||
|
app.add_systems(Update, handle_keyboard_hint);
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: simulate "the player just pressed H this frame".
|
||||||
|
fn press_h(app: &mut App) {
|
||||||
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
|
input.release(KeyCode::KeyH);
|
||||||
|
input.clear();
|
||||||
|
input.press(KeyCode::KeyH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a near-finished `GameState`: foundations hold A..Q for each
|
||||||
|
/// suit, four Kings sit on tableau columns 0..3, stock and waste
|
||||||
|
/// empty. Solver-side equivalent of the `near_finished_game_state`
|
||||||
|
/// helper in `solitaire_core::solver::tests`.
|
||||||
|
fn near_finished_game_state() -> GameState {
|
||||||
|
use solitaire_core::card::{Card, Rank, Suit};
|
||||||
|
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||||
|
for slot in 0..4_u8 {
|
||||||
|
game.piles
|
||||||
|
.get_mut(&PileType::Foundation(slot))
|
||||||
|
.unwrap()
|
||||||
|
.cards
|
||||||
|
.clear();
|
||||||
|
}
|
||||||
|
for i in 0..7_usize {
|
||||||
|
game.piles
|
||||||
|
.get_mut(&PileType::Tableau(i))
|
||||||
|
.unwrap()
|
||||||
|
.cards
|
||||||
|
.clear();
|
||||||
|
}
|
||||||
|
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||||
|
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||||
|
let suit_for_slot = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||||
|
let ranks_below_king = [
|
||||||
|
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
|
||||||
|
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
|
||||||
|
Rank::Jack, Rank::Queen,
|
||||||
|
];
|
||||||
|
for (slot, suit) in suit_for_slot.iter().enumerate() {
|
||||||
|
let pile = game
|
||||||
|
.piles
|
||||||
|
.get_mut(&PileType::Foundation(slot as u8))
|
||||||
|
.unwrap();
|
||||||
|
for (i, rank) in ranks_below_king.iter().enumerate() {
|
||||||
|
pile.cards.push(Card {
|
||||||
|
id: (slot as u32) * 13 + i as u32,
|
||||||
|
suit: *suit,
|
||||||
|
rank: *rank,
|
||||||
|
face_up: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (col, suit) in suit_for_slot.iter().enumerate() {
|
||||||
|
game.piles
|
||||||
|
.get_mut(&PileType::Tableau(col))
|
||||||
|
.unwrap()
|
||||||
|
.cards
|
||||||
|
.push(Card {
|
||||||
|
id: 100 + col as u32,
|
||||||
|
suit: *suit,
|
||||||
|
rank: Rank::King,
|
||||||
|
face_up: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
game
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When the solver verdict is Winnable, the hint must come from the
|
||||||
|
/// solver: in our near-finished fixture, four Tableau→Foundation
|
||||||
|
/// moves are legal and the solver returns one of them. The
|
||||||
|
/// `HintVisualEvent` source card must be one of the four Kings and
|
||||||
|
/// the destination must be a foundation slot.
|
||||||
|
#[test]
|
||||||
|
fn hint_uses_solver_when_winnable() {
|
||||||
|
use solitaire_core::card::Rank;
|
||||||
|
let mut app = hint_test_app();
|
||||||
|
let game = near_finished_game_state();
|
||||||
|
// Track the 4 King ids so we can assert the hint source matches.
|
||||||
|
let king_ids: Vec<u32> = (0..4_u8)
|
||||||
|
.map(|c| {
|
||||||
|
game.piles
|
||||||
|
.get(&PileType::Tableau(c as usize))
|
||||||
|
.unwrap()
|
||||||
|
.cards
|
||||||
|
.last()
|
||||||
|
.filter(|c| c.rank == Rank::King)
|
||||||
|
.map(|c| c.id)
|
||||||
|
.expect("each tableau col 0..3 has a King on top")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
app.insert_resource(GameStateResource(game));
|
||||||
|
press_h(&mut app);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// Read out the messages via the standard cursor API.
|
||||||
|
let messages = app.world().resource::<Messages<HintVisualEvent>>();
|
||||||
|
let mut cursor = messages.get_cursor();
|
||||||
|
let collected: Vec<HintVisualEvent> = cursor.read(messages).cloned().collect();
|
||||||
|
assert_eq!(
|
||||||
|
collected.len(), 1,
|
||||||
|
"exactly one HintVisualEvent must fire on a winnable solver verdict"
|
||||||
|
);
|
||||||
|
let event = &collected[0];
|
||||||
|
assert!(
|
||||||
|
king_ids.contains(&event.source_card_id),
|
||||||
|
"solver hint must point at one of the four Kings; got id {}",
|
||||||
|
event.source_card_id
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(event.dest_pile, PileType::Foundation(_)),
|
||||||
|
"solver hint destination must be a foundation slot; got {:?}",
|
||||||
|
event.dest_pile
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When the solver returns Inconclusive (e.g. tight budgets force an
|
||||||
|
/// early bail), the heuristic fallback must still produce a hint
|
||||||
|
/// event so the H key never feels broken.
|
||||||
|
///
|
||||||
|
/// We force the solver inconclusive by setting both budgets to 0 —
|
||||||
|
/// the search bails on the very first iteration, returning
|
||||||
|
/// `SolverResult::Inconclusive`. The heuristic fallback then runs on
|
||||||
|
/// the fresh deal and finds at least one legal move.
|
||||||
|
#[test]
|
||||||
|
fn hint_falls_back_to_heuristic_when_solver_inconclusive() {
|
||||||
|
use solitaire_core::solver::SolverConfig;
|
||||||
|
let mut app = hint_test_app();
|
||||||
|
// Force solver to bail before exploring anything.
|
||||||
|
app.insert_resource(HintSolverConfig(SolverConfig {
|
||||||
|
move_budget: 0,
|
||||||
|
state_budget: 0,
|
||||||
|
}));
|
||||||
|
// A fresh seeded deal — guaranteed to have at least one legal
|
||||||
|
// move (the standard Klondike opening always has draws available
|
||||||
|
// even if no immediate tableau move exists).
|
||||||
|
let game = GameState::new(42, DrawMode::DrawOne);
|
||||||
|
app.insert_resource(GameStateResource(game));
|
||||||
|
press_h(&mut app);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let world = app.world();
|
||||||
|
let visuals = world.resource::<Messages<HintVisualEvent>>();
|
||||||
|
let mut visual_cursor = visuals.get_cursor();
|
||||||
|
let collected: Vec<HintVisualEvent> = visual_cursor.read(visuals).cloned().collect();
|
||||||
|
// Either a card-move hint (most fresh deals) or a draw suggestion.
|
||||||
|
// A draw suggestion fires no `HintVisualEvent` (only an
|
||||||
|
// `InfoToastEvent`), so we accept zero-or-one HintVisualEvent so
|
||||||
|
// long as at least one feedback signal was emitted overall.
|
||||||
|
let toasts = world.resource::<Messages<InfoToastEvent>>();
|
||||||
|
let mut toast_cursor = toasts.get_cursor();
|
||||||
|
let toast_count = toast_cursor.read(toasts).count();
|
||||||
|
assert!(
|
||||||
|
!collected.is_empty() || toast_count > 0,
|
||||||
|
"heuristic fallback must produce a hint signal (visual or toast)"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,99 @@ 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("Be the first on the leaderboard."),
|
||||||
|
font_status.clone(),
|
||||||
let mut sorted = rows.to_vec();
|
TextColor(TEXT_PRIMARY),
|
||||||
sorted.sort_by_key(|e| std::cmp::Reverse(e.best_score.unwrap_or(0)));
|
));
|
||||||
|
body.spawn((
|
||||||
for (i, entry) in sorted.iter().take(10).enumerate() {
|
Text::new("Win a game and opt in to appear here."),
|
||||||
// Top three get accent treatments to highlight the
|
font_row.clone(),
|
||||||
// podium without leaning on hand-picked metallic
|
TextColor(TEXT_SECONDARY),
|
||||||
// colours that sit outside the token system.
|
));
|
||||||
let rank_color = match i {
|
}
|
||||||
0 => ACCENT_PRIMARY, // Balatro yellow for #1
|
LeaderboardResource::Loaded(rows) => {
|
||||||
1 | 2 => TEXT_PRIMARY,
|
// Column headers
|
||||||
_ => TEXT_SECONDARY,
|
body.spawn(Node {
|
||||||
};
|
|
||||||
|
|
||||||
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 +572,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 +718,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();
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ pub use events::{
|
|||||||
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
|
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
|
||||||
ForfeitEvent, ForfeitRequestEvent, FoundationCompletedEvent, GameWonEvent, HelpRequestEvent,
|
ForfeitEvent, ForfeitRequestEvent, FoundationCompletedEvent, GameWonEvent, HelpRequestEvent,
|
||||||
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||||
NewGameConfirmEvent, NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
||||||
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
|
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
|
||||||
StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent,
|
StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent,
|
||||||
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
|
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsSto
|
|||||||
use crate::stats_plugin::StatsResource;
|
use crate::stats_plugin::StatsResource;
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
||||||
spawn_modal_header, ButtonVariant,
|
spawn_modal_header, ButtonVariant, ModalScrim,
|
||||||
};
|
};
|
||||||
|
use bevy::ecs::system::SystemParam;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
self, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_3,
|
self, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_3,
|
||||||
};
|
};
|
||||||
@@ -126,15 +127,24 @@ impl Plugin for PausePlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bundles the modal-related queries `toggle_pause` reads each tick.
|
||||||
|
/// Pulled into a [`SystemParam`] so the system stays under Bevy's 16-
|
||||||
|
/// parameter cap after the cross-modal Esc guard query was added.
|
||||||
|
#[derive(SystemParam)]
|
||||||
|
struct PauseModalQueries<'w, 's> {
|
||||||
|
pause_screens: Query<'w, 's, Entity, With<PauseScreen>>,
|
||||||
|
forfeit_screens: Query<'w, 's, Entity, With<ForfeitConfirmScreen>>,
|
||||||
|
game_over_screens: Query<'w, 's, Entity, With<GameOverScreen>>,
|
||||||
|
other_modal_scrims: Query<'w, 's, Entity, (With<ModalScrim>, Without<PauseScreen>)>,
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn toggle_pause(
|
fn toggle_pause(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
mut requests: MessageReader<PauseRequestEvent>,
|
mut requests: MessageReader<PauseRequestEvent>,
|
||||||
mut paused: ResMut<PausedResource>,
|
mut paused: ResMut<PausedResource>,
|
||||||
screens: Query<Entity, With<PauseScreen>>,
|
modal_queries: PauseModalQueries<'_, '_>,
|
||||||
forfeit_screens: Query<Entity, With<ForfeitConfirmScreen>>,
|
|
||||||
game_over_screens: Query<Entity, With<GameOverScreen>>,
|
|
||||||
game: Option<Res<GameStateResource>>,
|
game: Option<Res<GameStateResource>>,
|
||||||
path: Option<Res<GameStatePath>>,
|
path: Option<Res<GameStatePath>>,
|
||||||
progress: Option<Res<ProgressResource>>,
|
progress: Option<Res<ProgressResource>>,
|
||||||
@@ -145,6 +155,13 @@ fn toggle_pause(
|
|||||||
mut changed: MessageWriter<StateChangedEvent>,
|
mut changed: MessageWriter<StateChangedEvent>,
|
||||||
selection: Option<Res<SelectionState>>,
|
selection: Option<Res<SelectionState>>,
|
||||||
) {
|
) {
|
||||||
|
let PauseModalQueries {
|
||||||
|
pause_screens: screens,
|
||||||
|
forfeit_screens,
|
||||||
|
game_over_screens,
|
||||||
|
other_modal_scrims,
|
||||||
|
} = modal_queries;
|
||||||
|
|
||||||
// Either Esc or a click on the HUD "Pause" button (which fires
|
// Either Esc or a click on the HUD "Pause" button (which fires
|
||||||
// PauseRequestEvent) opens or closes the overlay. Drain the queue so a
|
// PauseRequestEvent) opens or closes the overlay. Drain the queue so a
|
||||||
// burst of clicks doesn't queue future toggles.
|
// burst of clicks doesn't queue future toggles.
|
||||||
@@ -157,6 +174,16 @@ fn toggle_pause(
|
|||||||
if !forfeit_screens.is_empty() {
|
if !forfeit_screens.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Any other modal (Confirm New Game, Restore, Home, Onboarding,
|
||||||
|
// Settings, etc.) owns its own dismissal — pause must not stack
|
||||||
|
// on top of it. Without this guard a single Esc both closes the
|
||||||
|
// open modal AND spawns the pause overlay underneath, leaving the
|
||||||
|
// player on a screen they didn't ask for. The HUD-button path
|
||||||
|
// (`button_clicked`) is gated too; clicking Pause while another
|
||||||
|
// modal is up is almost always an accident.
|
||||||
|
if !other_modal_scrims.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// If a card is currently selected, let SelectionPlugin handle this Escape
|
// If a card is currently selected, let SelectionPlugin handle this Escape
|
||||||
// (it will clear the selection). Pause must not also open in the same frame.
|
// (it will clear the selection). Pause must not also open in the same frame.
|
||||||
if selection.is_some_and(|s| s.selected_pile.is_some()) {
|
if selection.is_some_and(|s| s.selected_pile.is_some()) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +146,17 @@ fn toggle_profile_screen(
|
|||||||
screens: Query<Entity, With<ProfileScreen>>,
|
screens: Query<Entity, With<ProfileScreen>>,
|
||||||
) {
|
) {
|
||||||
let button_clicked = requests.read().count() > 0;
|
let button_clicked = requests.read().count() > 0;
|
||||||
if !keys.just_pressed(KeyCode::KeyP) && !button_clicked {
|
let p_pressed = keys.just_pressed(KeyCode::KeyP);
|
||||||
|
let esc_pressed = keys.just_pressed(KeyCode::Escape);
|
||||||
|
let already_open = !screens.is_empty();
|
||||||
|
// P / button toggles open-or-close. Esc only ever closes — when
|
||||||
|
// Profile is layered over Home (clicking the new Home stats chip
|
||||||
|
// opens this on top), Esc must dismiss the *topmost* modal.
|
||||||
|
// Without this branch, Esc fell through to Home's cancel handler
|
||||||
|
// and closed the wrong modal.
|
||||||
|
let want_open = !already_open && (p_pressed || button_clicked);
|
||||||
|
let want_close = already_open && (p_pressed || button_clicked || esc_pressed);
|
||||||
|
if !want_open && !want_close {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Ok(entity) = screens.single() {
|
if let Ok(entity) = screens.single() {
|
||||||
@@ -133,186 +195,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 +406,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 +586,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();
|
||||||
|
|||||||
@@ -45,11 +45,34 @@ use solitaire_data::{Replay, ReplayMove};
|
|||||||
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent};
|
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent};
|
||||||
use crate::game_plugin::{GameMutation, RecordingReplay};
|
use crate::game_plugin::{GameMutation, RecordingReplay};
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
|
use crate::settings_plugin::SettingsResource;
|
||||||
|
|
||||||
/// Per-move duration during playback. Tunable in Settings later;
|
/// Default per-move duration during playback, in seconds. Acts as the
|
||||||
/// hardcoded for v1.
|
/// fallback when `SettingsResource` is absent — i.e. in headless test
|
||||||
|
/// fixtures that don't install [`crate::settings_plugin::SettingsPlugin`].
|
||||||
|
/// In production the live value is read from
|
||||||
|
/// [`solitaire_data::Settings::replay_move_interval_secs`] every frame
|
||||||
|
/// so Settings adjustments take effect on the next playback tick.
|
||||||
|
///
|
||||||
|
/// Kept in sync with `solitaire_data::settings::default_replay_move_interval_secs`
|
||||||
|
/// (the data crate cannot depend on this engine crate, so the constant
|
||||||
|
/// is duplicated). The
|
||||||
|
/// `settings_replay_move_interval_default_matches_engine_constant`
|
||||||
|
/// test in `solitaire_engine::settings_plugin` enforces equality.
|
||||||
pub const REPLAY_MOVE_INTERVAL_SECS: f32 = 0.45;
|
pub const REPLAY_MOVE_INTERVAL_SECS: f32 = 0.45;
|
||||||
|
|
||||||
|
/// Helper: returns the live per-move replay interval. Reads
|
||||||
|
/// [`SettingsResource::replay_move_interval_secs`] when the resource is
|
||||||
|
/// installed, falling back to [`REPLAY_MOVE_INTERVAL_SECS`] otherwise.
|
||||||
|
/// Also clamps below by `f32::EPSILON` so a hand-edited 0.0 cannot
|
||||||
|
/// busy-loop the playback tick.
|
||||||
|
fn current_move_interval_secs(settings: Option<&SettingsResource>) -> f32 {
|
||||||
|
let raw = settings
|
||||||
|
.map(|s| s.0.replay_move_interval_secs)
|
||||||
|
.unwrap_or(REPLAY_MOVE_INTERVAL_SECS);
|
||||||
|
raw.max(f32::EPSILON)
|
||||||
|
}
|
||||||
|
|
||||||
/// How long the [`ReplayPlaybackState::Completed`] state lingers before
|
/// How long the [`ReplayPlaybackState::Completed`] state lingers before
|
||||||
/// the auto-clear system transitions it back to
|
/// the auto-clear system transitions it back to
|
||||||
/// [`ReplayPlaybackState::Inactive`]. Gives the overlay UI time to
|
/// [`ReplayPlaybackState::Inactive`]. Gives the overlay UI time to
|
||||||
@@ -161,6 +184,12 @@ pub fn start_replay_playback(
|
|||||||
let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode.clone(), replay.mode);
|
let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode.clone(), replay.mode);
|
||||||
commands.insert_resource(GameStateResource(fresh));
|
commands.insert_resource(GameStateResource(fresh));
|
||||||
|
|
||||||
|
// Initial `secs_to_next` uses the constant rather than reading
|
||||||
|
// `SettingsResource` because this entry point takes `Commands` /
|
||||||
|
// `ResMut<ReplayPlaybackState>` only. The first-tick latency may
|
||||||
|
// therefore lag the configured interval by up to ~0.45 s on an
|
||||||
|
// unusually short setting; subsequent ticks read the live setting
|
||||||
|
// every frame via [`tick_replay_playback`].
|
||||||
**state = ReplayPlaybackState::Playing {
|
**state = ReplayPlaybackState::Playing {
|
||||||
replay,
|
replay,
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
@@ -207,11 +236,13 @@ pub fn stop_replay_playback(
|
|||||||
/// so the loop runs at most once per frame.
|
/// so the loop runs at most once per frame.
|
||||||
fn tick_replay_playback(
|
fn tick_replay_playback(
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
mut state: ResMut<ReplayPlaybackState>,
|
mut state: ResMut<ReplayPlaybackState>,
|
||||||
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
||||||
mut draws_writer: MessageWriter<DrawRequestEvent>,
|
mut draws_writer: MessageWriter<DrawRequestEvent>,
|
||||||
) {
|
) {
|
||||||
let dt = time.delta_secs();
|
let dt = time.delta_secs();
|
||||||
|
let interval = current_move_interval_secs(settings.as_deref());
|
||||||
let mut transition_to_completed = false;
|
let mut transition_to_completed = false;
|
||||||
|
|
||||||
if let ReplayPlaybackState::Playing {
|
if let ReplayPlaybackState::Playing {
|
||||||
@@ -235,7 +266,7 @@ fn tick_replay_playback(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
*cursor += 1;
|
*cursor += 1;
|
||||||
*secs_to_next += REPLAY_MOVE_INTERVAL_SECS;
|
*secs_to_next += interval;
|
||||||
}
|
}
|
||||||
|
|
||||||
if *cursor >= replay.moves.len() {
|
if *cursor >= replay.moves.len() {
|
||||||
@@ -679,4 +710,124 @@ mod tests {
|
|||||||
"recording must not grow while playback is active",
|
"recording must not grow while playback is active",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// With `SettingsResource::replay_move_interval_secs` set to 0.10 s
|
||||||
|
/// (well below the 0.45 s default), playback over a fixed
|
||||||
|
/// wall-clock window must dispatch strictly more moves than the
|
||||||
|
/// same fixture would at the 0.45 s default. This is the
|
||||||
|
/// regression check that the tick reads from the live Settings
|
||||||
|
/// value rather than the hardcoded
|
||||||
|
/// [`REPLAY_MOVE_INTERVAL_SECS`] constant.
|
||||||
|
///
|
||||||
|
/// The follow-up assertion exercises the boundary condition: at
|
||||||
|
/// the 0.10 s/move setting, exactly six 0.10 s ticks must yield
|
||||||
|
/// fewer moves than six 0.20 s ticks (because the latter doubles
|
||||||
|
/// the per-update advance and pays off two intervals each tick).
|
||||||
|
#[test]
|
||||||
|
fn replay_playback_tick_uses_settings_interval() {
|
||||||
|
use solitaire_data::Settings;
|
||||||
|
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct CapturedDraws(usize);
|
||||||
|
|
||||||
|
fn collect_draws(
|
||||||
|
mut events: MessageReader<DrawRequestEvent>,
|
||||||
|
mut sink: ResMut<CapturedDraws>,
|
||||||
|
) {
|
||||||
|
for _ in events.read() {
|
||||||
|
sink.0 += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Long replay so the fast cadence has plenty of moves to
|
||||||
|
// chew through and the 0.45 s vs 0.10 s difference is easy
|
||||||
|
// to observe.
|
||||||
|
fn ten_draws_replay() -> Replay {
|
||||||
|
Replay::new(
|
||||||
|
7,
|
||||||
|
DrawMode::DrawOne,
|
||||||
|
GameMode::Classic,
|
||||||
|
10,
|
||||||
|
100,
|
||||||
|
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||||
|
vec![ReplayMove::StockClick; 10],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Run 1: 0.10 s/move (Settings override) ----
|
||||||
|
let mut fast_app = headless_app();
|
||||||
|
fast_app.insert_resource(SettingsResource(Settings {
|
||||||
|
replay_move_interval_secs: 0.10,
|
||||||
|
..Settings::default()
|
||||||
|
}));
|
||||||
|
fast_app
|
||||||
|
.init_resource::<CapturedDraws>()
|
||||||
|
.add_systems(Update, collect_draws);
|
||||||
|
|
||||||
|
start_playback(&mut fast_app, ten_draws_replay());
|
||||||
|
fast_app.update();
|
||||||
|
// 1.0 s of virtual time at 0.10 s/move dispatches ~5 moves
|
||||||
|
// after the default 0.45 s startup interval is consumed.
|
||||||
|
advance_by(&mut fast_app, 1.0);
|
||||||
|
let fast_count = fast_app.world().resource::<CapturedDraws>().0;
|
||||||
|
|
||||||
|
// ---- Run 2: 0.45 s/move (default — no SettingsResource) ----
|
||||||
|
let mut slow_app = headless_app();
|
||||||
|
// `tick_replay_playback` falls back to `REPLAY_MOVE_INTERVAL_SECS`
|
||||||
|
// (0.45 s) when `SettingsResource` is absent.
|
||||||
|
slow_app
|
||||||
|
.init_resource::<CapturedDraws>()
|
||||||
|
.add_systems(Update, collect_draws);
|
||||||
|
|
||||||
|
start_playback(&mut slow_app, ten_draws_replay());
|
||||||
|
slow_app.update();
|
||||||
|
advance_by(&mut slow_app, 1.0);
|
||||||
|
let slow_count = slow_app.world().resource::<CapturedDraws>().0;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
fast_count > slow_count,
|
||||||
|
"at 0.10 s/move the tick must dispatch strictly more moves \
|
||||||
|
than at the 0.45 s default over the same wall-clock window: \
|
||||||
|
fast={fast_count}, slow={slow_count}",
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Boundary: a 0.05 s/tick cadence over the same window
|
||||||
|
// dispatches NO MORE moves than a 0.10 s/tick cadence, because
|
||||||
|
// 0.05 s < 0.10 s configured interval — the secs_to_next clock
|
||||||
|
// never crosses the threshold inside a single tick. ----
|
||||||
|
//
|
||||||
|
// We don't assert "exactly zero" because the leading update()
|
||||||
|
// after `start_playback` may run before the strategy is
|
||||||
|
// applied (cf. comments on `tick_advances_cursor_after_interval`),
|
||||||
|
// but the count must not exceed what we'd get with one-tick
|
||||||
|
// advances at the same total wall-clock window.
|
||||||
|
fn count_after_window(interval_secs: f32, tick_secs: f32, total_secs: f32) -> usize {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.insert_resource(SettingsResource(Settings {
|
||||||
|
replay_move_interval_secs: interval_secs,
|
||||||
|
..Settings::default()
|
||||||
|
}));
|
||||||
|
app.init_resource::<CapturedDraws>()
|
||||||
|
.add_systems(Update, collect_draws);
|
||||||
|
start_playback(&mut app, ten_draws_replay());
|
||||||
|
app.update();
|
||||||
|
app.insert_resource(TimeUpdateStrategy::ManualDuration(
|
||||||
|
Duration::from_secs_f32(tick_secs),
|
||||||
|
));
|
||||||
|
let ticks = (total_secs / tick_secs).ceil() as usize + 1;
|
||||||
|
for _ in 0..ticks {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
app.world().resource::<CapturedDraws>().0
|
||||||
|
}
|
||||||
|
|
||||||
|
let count_at_05 = count_after_window(0.10, 0.05, 1.0);
|
||||||
|
let count_at_20 = count_after_window(0.10, 0.20, 1.0);
|
||||||
|
assert!(
|
||||||
|
count_at_05 <= count_at_20,
|
||||||
|
"0.05 s ticks (strictly less than the 0.10 s interval) must \
|
||||||
|
dispatch no more moves than 0.20 s ticks over the same \
|
||||||
|
wall-clock window: count_at_05={count_at_05}, count_at_20={count_at_20}",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ use bevy::window::{WindowMoved, WindowResized};
|
|||||||
use solitaire_core::game_state::DrawMode;
|
use solitaire_core::game_state::DrawMode;
|
||||||
use solitaire_data::{
|
use solitaire_data::{
|
||||||
load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings,
|
load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings,
|
||||||
WindowGeometry, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_STEP_SECS,
|
WindowGeometry, REPLAY_MOVE_INTERVAL_STEP_SECS, TIME_BONUS_MULTIPLIER_STEP,
|
||||||
|
TOOLTIP_DELAY_STEP_SECS,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent};
|
use crate::events::{InfoToastEvent, ManualSyncRequestEvent, ToggleSettingsRequestEvent};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||||
@@ -132,6 +133,12 @@ struct TooltipDelayText;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct TimeBonusMultiplierText;
|
struct TimeBonusMultiplierText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the live replay-playback per-move
|
||||||
|
/// interval value. The Gameplay-section row beside this label lets the
|
||||||
|
/// player tune `Settings::replay_move_interval_secs`.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct ReplayMoveIntervalText;
|
||||||
|
|
||||||
/// Marks the `Text` node showing the current "Winnable deals only"
|
/// Marks the `Text` node showing the current "Winnable deals only"
|
||||||
/// state ("ON" / "OFF") in the Gameplay section.
|
/// state ("ON" / "OFF") in the Gameplay section.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
@@ -179,6 +186,12 @@ enum SettingsButton {
|
|||||||
TimeBonusDown,
|
TimeBonusDown,
|
||||||
/// Increment the cosmetic time-bonus multiplier by one step.
|
/// Increment the cosmetic time-bonus multiplier by one step.
|
||||||
TimeBonusUp,
|
TimeBonusUp,
|
||||||
|
/// Decrement the replay-playback per-move interval by one step
|
||||||
|
/// (i.e. speed playback up).
|
||||||
|
ReplayMoveIntervalDown,
|
||||||
|
/// Increment the replay-playback per-move interval by one step
|
||||||
|
/// (i.e. slow playback down).
|
||||||
|
ReplayMoveIntervalUp,
|
||||||
ToggleTheme,
|
ToggleTheme,
|
||||||
ToggleColorBlind,
|
ToggleColorBlind,
|
||||||
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
|
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
|
||||||
@@ -219,8 +232,12 @@ impl SettingsButton {
|
|||||||
SettingsButton::TooltipDelayUp => 46,
|
SettingsButton::TooltipDelayUp => 46,
|
||||||
SettingsButton::TimeBonusDown => 47,
|
SettingsButton::TimeBonusDown => 47,
|
||||||
SettingsButton::TimeBonusUp => 48,
|
SettingsButton::TimeBonusUp => 48,
|
||||||
|
// Replay-speed slider — last Gameplay-section row, so it
|
||||||
|
// sits between TimeBonusUp (48) and the Cosmetic section.
|
||||||
|
SettingsButton::ReplayMoveIntervalDown => 49,
|
||||||
|
SettingsButton::ReplayMoveIntervalUp => 49,
|
||||||
// Cosmetic section
|
// Cosmetic section
|
||||||
SettingsButton::ToggleTheme => 50,
|
SettingsButton::ToggleTheme => 55,
|
||||||
SettingsButton::ToggleColorBlind => 60,
|
SettingsButton::ToggleColorBlind => 60,
|
||||||
// Picker rows — every swatch in a row shares the row's
|
// Picker rows — every swatch in a row shares the row's
|
||||||
// priority so entity-index tiebreaking yields left → right.
|
// priority so entity-index tiebreaking yields left → right.
|
||||||
@@ -279,6 +296,7 @@ impl Plugin for SettingsPlugin {
|
|||||||
.add_message::<SettingsChangedEvent>()
|
.add_message::<SettingsChangedEvent>()
|
||||||
.add_message::<ManualSyncRequestEvent>()
|
.add_message::<ManualSyncRequestEvent>()
|
||||||
.add_message::<ToggleSettingsRequestEvent>()
|
.add_message::<ToggleSettingsRequestEvent>()
|
||||||
|
.add_message::<InfoToastEvent>()
|
||||||
.add_message::<bevy::input::mouse::MouseWheel>()
|
.add_message::<bevy::input::mouse::MouseWheel>()
|
||||||
// `WindowResized` / `WindowMoved` are real Bevy window events
|
// `WindowResized` / `WindowMoved` are real Bevy window events
|
||||||
// and emitted by the windowing backend under `DefaultPlugins`,
|
// and emitted by the windowing backend under `DefaultPlugins`,
|
||||||
@@ -310,6 +328,7 @@ impl Plugin for SettingsPlugin {
|
|||||||
update_color_blind_text,
|
update_color_blind_text,
|
||||||
update_tooltip_delay_text,
|
update_tooltip_delay_text,
|
||||||
update_time_bonus_multiplier_text,
|
update_time_bonus_multiplier_text,
|
||||||
|
update_replay_move_interval_text,
|
||||||
update_winnable_deals_only_text,
|
update_winnable_deals_only_text,
|
||||||
attach_focusable_to_settings_buttons,
|
attach_focusable_to_settings_buttons,
|
||||||
scroll_focus_into_view,
|
scroll_focus_into_view,
|
||||||
@@ -365,6 +384,7 @@ fn handle_volume_keys(
|
|||||||
mut settings: ResMut<SettingsResource>,
|
mut settings: ResMut<SettingsResource>,
|
||||||
path: Res<SettingsStoragePath>,
|
path: Res<SettingsStoragePath>,
|
||||||
mut changed: MessageWriter<SettingsChangedEvent>,
|
mut changed: MessageWriter<SettingsChangedEvent>,
|
||||||
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
) {
|
) {
|
||||||
let mut delta = 0.0_f32;
|
let mut delta = 0.0_f32;
|
||||||
if keys.just_pressed(KeyCode::BracketLeft) {
|
if keys.just_pressed(KeyCode::BracketLeft) {
|
||||||
@@ -383,6 +403,10 @@ fn handle_volume_keys(
|
|||||||
}
|
}
|
||||||
persist(&path, &settings.0);
|
persist(&path, &settings.0);
|
||||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
toast.write(InfoToastEvent(format!(
|
||||||
|
"SFX volume: {}%",
|
||||||
|
(after * 100.0).round() as i32
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens or closes the Settings panel — `O` keyboard accelerator or
|
/// Opens or closes the Settings panel — `O` keyboard accelerator or
|
||||||
@@ -605,6 +629,21 @@ fn update_time_bonus_multiplier_text(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Refreshes the live replay-playback per-move-interval value in the
|
||||||
|
/// Gameplay section whenever `SettingsResource` changes (slider buttons,
|
||||||
|
/// hand-edited settings.json reload, etc.).
|
||||||
|
fn update_replay_move_interval_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<ReplayMoveIntervalText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = replay_move_interval_label(settings.0.replay_move_interval_secs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn card_back_label(idx: usize) -> String {
|
fn card_back_label(idx: usize) -> String {
|
||||||
if idx == 0 {
|
if idx == 0 {
|
||||||
"Default".to_string()
|
"Default".to_string()
|
||||||
@@ -765,6 +804,29 @@ fn handle_settings_buttons(
|
|||||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SettingsButton::ReplayMoveIntervalDown => {
|
||||||
|
let before = settings.0.replay_move_interval_secs;
|
||||||
|
let after = settings
|
||||||
|
.0
|
||||||
|
.adjust_replay_move_interval(-REPLAY_MOVE_INTERVAL_STEP_SECS);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// The Text node is refreshed by
|
||||||
|
// `update_replay_move_interval_text` on the next
|
||||||
|
// frame via `settings.is_changed()`.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ReplayMoveIntervalUp => {
|
||||||
|
let before = settings.0.replay_move_interval_secs;
|
||||||
|
let after = settings
|
||||||
|
.0
|
||||||
|
.adjust_replay_move_interval(REPLAY_MOVE_INTERVAL_STEP_SECS);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
SettingsButton::ToggleTheme => {
|
SettingsButton::ToggleTheme => {
|
||||||
settings.0.theme = match settings.0.theme {
|
settings.0.theme = match settings.0.theme {
|
||||||
Theme::Green => Theme::Blue,
|
Theme::Green => Theme::Blue,
|
||||||
@@ -876,6 +938,14 @@ fn time_bonus_label(value: f32) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Formats the replay-playback per-move interval for display in the
|
||||||
|
/// Settings panel. Mirrors [`tooltip_delay_label`] for parity — the
|
||||||
|
/// readout is `"{n:.2} s/move"` (e.g. `"0.45 s/move"`, `"0.10 s/move"`),
|
||||||
|
/// using two decimal places because the step is 0.05 s.
|
||||||
|
fn replay_move_interval_label(secs: f32) -> String {
|
||||||
|
format!("{secs:.2} s/move")
|
||||||
|
}
|
||||||
|
|
||||||
/// Auto-attaches [`Focusable`] to every bespoke Settings button — icon
|
/// Auto-attaches [`Focusable`] to every bespoke Settings button — icon
|
||||||
/// buttons (volume +/−, toggle, cycle), swatch buttons (card-back,
|
/// buttons (volume +/−, toggle, cycle), swatch buttons (card-back,
|
||||||
/// background pickers), and the "Sync Now" button. The "Done" button is
|
/// background pickers), and the "Sync Now" button. The "Done" button is
|
||||||
@@ -1228,6 +1298,11 @@ fn spawn_settings_panel(
|
|||||||
settings.time_bonus_multiplier,
|
settings.time_bonus_multiplier,
|
||||||
font_res,
|
font_res,
|
||||||
);
|
);
|
||||||
|
replay_move_interval_row(
|
||||||
|
body,
|
||||||
|
settings.replay_move_interval_secs,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
|
||||||
// --- Cosmetic ---
|
// --- Cosmetic ---
|
||||||
section_label(body, "Cosmetic", font_res);
|
section_label(body, "Cosmetic", font_res);
|
||||||
@@ -1341,11 +1416,18 @@ fn volume_row<Marker: Component>(
|
|||||||
) {
|
) {
|
||||||
let label_font = label_text_font(font_res);
|
let label_font = label_text_font(font_res);
|
||||||
let value_font = value_text_font(font_res);
|
let value_font = value_text_font(font_res);
|
||||||
|
// Row spans the full body width with a flex-grow spacer between
|
||||||
|
// the left-aligned label and the right-aligned controls cluster.
|
||||||
|
// Without `width: 100%` + the spacer, the label / value / buttons
|
||||||
|
// bunch against the left edge and a varying-length value (e.g.
|
||||||
|
// "0.80" → "1.00") shifts the +/− buttons sideways frame to
|
||||||
|
// frame, visually overlapping with adjacent UI on small windows.
|
||||||
parent
|
parent
|
||||||
.spawn(Node {
|
.spawn(Node {
|
||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Row,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
column_gap: VAL_SPACE_2,
|
column_gap: VAL_SPACE_2,
|
||||||
|
width: Val::Percent(100.0),
|
||||||
..default()
|
..default()
|
||||||
})
|
})
|
||||||
.with_children(|row| {
|
.with_children(|row| {
|
||||||
@@ -1354,14 +1436,31 @@ fn volume_row<Marker: Component>(
|
|||||||
label_font,
|
label_font,
|
||||||
TextColor(TEXT_SECONDARY),
|
TextColor(TEXT_SECONDARY),
|
||||||
));
|
));
|
||||||
row.spawn((
|
// Spacer: takes up all remaining horizontal space so the
|
||||||
marker,
|
// controls cluster sits flush against the right edge.
|
||||||
Text::new(format!("{value:.2}")),
|
row.spawn(Node {
|
||||||
value_font,
|
flex_grow: 1.0,
|
||||||
TextColor(TEXT_PRIMARY),
|
..default()
|
||||||
));
|
});
|
||||||
icon_button(row, "−", btn_down, tooltip_down, font_res);
|
// Controls cluster — value + decrement + increment held
|
||||||
icon_button(row, "+", btn_up, tooltip_up, font_res);
|
// together so the buttons stay in fixed positions even
|
||||||
|
// as the value text width varies.
|
||||||
|
row.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: VAL_SPACE_2,
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|cluster| {
|
||||||
|
cluster.spawn((
|
||||||
|
marker,
|
||||||
|
Text::new(format!("{value:.2}")),
|
||||||
|
value_font,
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
icon_button(cluster, "−", btn_down, tooltip_down, font_res);
|
||||||
|
icon_button(cluster, "+", btn_up, tooltip_up, font_res);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1381,6 +1480,7 @@ fn tooltip_delay_row(
|
|||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Row,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
column_gap: VAL_SPACE_2,
|
column_gap: VAL_SPACE_2,
|
||||||
|
width: Val::Percent(100.0),
|
||||||
..default()
|
..default()
|
||||||
})
|
})
|
||||||
.with_children(|row| {
|
.with_children(|row| {
|
||||||
@@ -1389,26 +1489,38 @@ fn tooltip_delay_row(
|
|||||||
label_font,
|
label_font,
|
||||||
TextColor(TEXT_SECONDARY),
|
TextColor(TEXT_SECONDARY),
|
||||||
));
|
));
|
||||||
row.spawn((
|
row.spawn(Node {
|
||||||
TooltipDelayText,
|
flex_grow: 1.0,
|
||||||
Text::new(tooltip_delay_label(value_secs)),
|
..default()
|
||||||
value_font,
|
});
|
||||||
TextColor(TEXT_PRIMARY),
|
row.spawn(Node {
|
||||||
));
|
flex_direction: FlexDirection::Row,
|
||||||
icon_button(
|
align_items: AlignItems::Center,
|
||||||
row,
|
column_gap: VAL_SPACE_2,
|
||||||
"−",
|
..default()
|
||||||
SettingsButton::TooltipDelayDown,
|
})
|
||||||
"Shorten the hover delay before tooltips appear.",
|
.with_children(|cluster| {
|
||||||
font_res,
|
cluster.spawn((
|
||||||
);
|
TooltipDelayText,
|
||||||
icon_button(
|
Text::new(tooltip_delay_label(value_secs)),
|
||||||
row,
|
value_font,
|
||||||
"+",
|
TextColor(TEXT_PRIMARY),
|
||||||
SettingsButton::TooltipDelayUp,
|
));
|
||||||
"Lengthen the hover delay before tooltips appear.",
|
icon_button(
|
||||||
font_res,
|
cluster,
|
||||||
);
|
"−",
|
||||||
|
SettingsButton::TooltipDelayDown,
|
||||||
|
"Shorten the hover delay before tooltips appear.",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
icon_button(
|
||||||
|
cluster,
|
||||||
|
"+",
|
||||||
|
SettingsButton::TooltipDelayUp,
|
||||||
|
"Lengthen the hover delay before tooltips appear.",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1431,6 +1543,7 @@ fn time_bonus_multiplier_row(
|
|||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Row,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
column_gap: VAL_SPACE_2,
|
column_gap: VAL_SPACE_2,
|
||||||
|
width: Val::Percent(100.0),
|
||||||
..default()
|
..default()
|
||||||
})
|
})
|
||||||
.with_children(|row| {
|
.with_children(|row| {
|
||||||
@@ -1439,26 +1552,101 @@ fn time_bonus_multiplier_row(
|
|||||||
label_font,
|
label_font,
|
||||||
TextColor(TEXT_SECONDARY),
|
TextColor(TEXT_SECONDARY),
|
||||||
));
|
));
|
||||||
|
row.spawn(Node {
|
||||||
|
flex_grow: 1.0,
|
||||||
|
..default()
|
||||||
|
});
|
||||||
|
row.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: VAL_SPACE_2,
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|cluster| {
|
||||||
|
cluster.spawn((
|
||||||
|
TimeBonusMultiplierText,
|
||||||
|
Text::new(time_bonus_label(value)),
|
||||||
|
value_font,
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
icon_button(
|
||||||
|
cluster,
|
||||||
|
"−",
|
||||||
|
SettingsButton::TimeBonusDown,
|
||||||
|
"Shrink the time-bonus shown in the win modal. Cosmetic only.",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
icon_button(
|
||||||
|
cluster,
|
||||||
|
"+",
|
||||||
|
SettingsButton::TimeBonusUp,
|
||||||
|
"Boost the time-bonus shown in the win modal. Cosmetic only.",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Replay speed 0.45 s/move [−] [+]` — slider row for the
|
||||||
|
/// player-tunable replay-playback per-move interval. Mirrors
|
||||||
|
/// [`tooltip_delay_row`] (label, current value, decrement, increment)
|
||||||
|
/// but formats the value via [`replay_move_interval_label`] as
|
||||||
|
/// `"{n:.2} s/move"`. The decrement button speeds playback up
|
||||||
|
/// (smaller interval); the increment slows it down — same direction
|
||||||
|
/// convention as the tooltip-delay slider.
|
||||||
|
fn replay_move_interval_row(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
value_secs: f32,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
let label_font = label_text_font(font_res);
|
||||||
|
let value_font = value_text_font(font_res);
|
||||||
|
parent
|
||||||
|
.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: VAL_SPACE_2,
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
row.spawn((
|
row.spawn((
|
||||||
TimeBonusMultiplierText,
|
Text::new("Replay speed".to_string()),
|
||||||
Text::new(time_bonus_label(value)),
|
label_font,
|
||||||
value_font,
|
TextColor(TEXT_SECONDARY),
|
||||||
TextColor(TEXT_PRIMARY),
|
|
||||||
));
|
));
|
||||||
icon_button(
|
row.spawn(Node {
|
||||||
row,
|
flex_grow: 1.0,
|
||||||
"−",
|
..default()
|
||||||
SettingsButton::TimeBonusDown,
|
});
|
||||||
"Shrink the time-bonus shown in the win modal. Cosmetic only.",
|
row.spawn(Node {
|
||||||
font_res,
|
flex_direction: FlexDirection::Row,
|
||||||
);
|
align_items: AlignItems::Center,
|
||||||
icon_button(
|
column_gap: VAL_SPACE_2,
|
||||||
row,
|
..default()
|
||||||
"+",
|
})
|
||||||
SettingsButton::TimeBonusUp,
|
.with_children(|cluster| {
|
||||||
"Boost the time-bonus shown in the win modal. Cosmetic only.",
|
cluster.spawn((
|
||||||
font_res,
|
ReplayMoveIntervalText,
|
||||||
);
|
Text::new(replay_move_interval_label(value_secs)),
|
||||||
|
value_font,
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
icon_button(
|
||||||
|
cluster,
|
||||||
|
"−",
|
||||||
|
SettingsButton::ReplayMoveIntervalDown,
|
||||||
|
"Speed up replay playback (shorter per-move interval).",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
icon_button(
|
||||||
|
cluster,
|
||||||
|
"+",
|
||||||
|
SettingsButton::ReplayMoveIntervalUp,
|
||||||
|
"Slow down replay playback (longer per-move interval).",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1484,6 +1672,7 @@ fn toggle_row<Marker: Component>(
|
|||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Row,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
column_gap: VAL_SPACE_2,
|
column_gap: VAL_SPACE_2,
|
||||||
|
width: Val::Percent(100.0),
|
||||||
..default()
|
..default()
|
||||||
})
|
})
|
||||||
.with_children(|row| {
|
.with_children(|row| {
|
||||||
@@ -1492,8 +1681,20 @@ fn toggle_row<Marker: Component>(
|
|||||||
label_font,
|
label_font,
|
||||||
TextColor(TEXT_SECONDARY),
|
TextColor(TEXT_SECONDARY),
|
||||||
));
|
));
|
||||||
row.spawn((marker, Text::new(value), value_font, TextColor(TEXT_PRIMARY)));
|
row.spawn(Node {
|
||||||
icon_button(row, "⇄", action, tooltip, font_res);
|
flex_grow: 1.0,
|
||||||
|
..default()
|
||||||
|
});
|
||||||
|
row.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: VAL_SPACE_2,
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|cluster| {
|
||||||
|
cluster.spawn((marker, Text::new(value), value_font, TextColor(TEXT_PRIMARY)));
|
||||||
|
icon_button(cluster, "⇄", action, tooltip, font_res);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_data::{
|
use solitaire_data::{
|
||||||
@@ -28,6 +29,7 @@ use crate::resources::GameStateResource;
|
|||||||
use crate::time_attack_plugin::TimeAttackResource;
|
use crate::time_attack_plugin::TimeAttackResource;
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||||
|
ScrimDismissible,
|
||||||
};
|
};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, STATE_WARNING, STREAK_MILESTONES,
|
ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, STATE_WARNING, STREAK_MILESTONES,
|
||||||
@@ -72,6 +74,26 @@ pub struct StatsCell;
|
|||||||
#[derive(Resource, Debug, Default, Clone)]
|
#[derive(Resource, Debug, Default, Clone)]
|
||||||
pub struct ReplayHistoryResource(pub ReplayHistory);
|
pub struct ReplayHistoryResource(pub ReplayHistory);
|
||||||
|
|
||||||
|
/// Most recent shareable replay URL written by `sync_plugin` after the
|
||||||
|
/// `SyncProvider::push_replay` task completes successfully. `None`
|
||||||
|
/// until the player wins a game on a server-backed sync backend;
|
||||||
|
/// repopulated on each subsequent win.
|
||||||
|
///
|
||||||
|
/// The Stats overlay's "Copy share link" button reads from here and
|
||||||
|
/// writes the URL to the OS clipboard via `arboard`. Not persisted to
|
||||||
|
/// disk — the URL is recoverable by re-uploading the same replay
|
||||||
|
/// (still in `replays.json`), so the session-bound lifetime is fine
|
||||||
|
/// for a v1 share affordance.
|
||||||
|
#[derive(Resource, Debug, Default, Clone)]
|
||||||
|
pub struct LastSharedReplayUrl(pub Option<String>);
|
||||||
|
|
||||||
|
/// Marker on the "Copy share link" button inside the Stats modal.
|
||||||
|
/// Click writes [`LastSharedReplayUrl`] to the OS clipboard via
|
||||||
|
/// `arboard` and surfaces a confirmation toast. Hidden / disabled
|
||||||
|
/// when no shareable URL is available.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct CopyShareLinkButton;
|
||||||
|
|
||||||
/// Currently-selected index into [`ReplayHistoryResource::0`].`replays`.
|
/// Currently-selected index into [`ReplayHistoryResource::0`].`replays`.
|
||||||
///
|
///
|
||||||
/// `0` is the most recent win and is the default on every modal open.
|
/// `0` is the most recent win and is the default on every modal open.
|
||||||
@@ -118,6 +140,18 @@ pub struct ReplaySelectorCaption;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct PerModeBestsRow;
|
pub struct PerModeBestsRow;
|
||||||
|
|
||||||
|
/// Marker on the scrollable body Node inside the Stats modal.
|
||||||
|
///
|
||||||
|
/// The Stats panel renders an 8-cell primary grid, three per-mode bests
|
||||||
|
/// rows, a five-cell progression grid, weekly goals, an unlocks line,
|
||||||
|
/// optional Time Attack readout, and the latest replay caption — enough
|
||||||
|
/// content to overflow the modal on the 800x600 minimum window. This
|
||||||
|
/// marker tags the inner container that carries `Overflow::scroll_y()`
|
||||||
|
/// plus a `max_height` constraint. Mirrors the `SettingsPanelScrollable`
|
||||||
|
/// pattern.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct StatsScrollable;
|
||||||
|
|
||||||
/// Registers stats resources, update systems, and the UI toggle.
|
/// Registers stats resources, update systems, and the UI toggle.
|
||||||
pub struct StatsPlugin {
|
pub struct StatsPlugin {
|
||||||
/// Where to persist stats. `None` disables all file I/O (for tests).
|
/// Where to persist stats. `None` disables all file I/O (for tests).
|
||||||
@@ -161,12 +195,17 @@ impl Plugin for StatsPlugin {
|
|||||||
.insert_resource(ReplayHistoryResource(initial_history))
|
.insert_resource(ReplayHistoryResource(initial_history))
|
||||||
.init_resource::<SelectedReplayIndex>()
|
.init_resource::<SelectedReplayIndex>()
|
||||||
.insert_resource(LatestReplayPath(replay_path))
|
.insert_resource(LatestReplayPath(replay_path))
|
||||||
|
.init_resource::<LastSharedReplayUrl>()
|
||||||
.add_message::<GameWonEvent>()
|
.add_message::<GameWonEvent>()
|
||||||
.add_message::<NewGameRequestEvent>()
|
.add_message::<NewGameRequestEvent>()
|
||||||
.add_message::<ForfeitEvent>()
|
.add_message::<ForfeitEvent>()
|
||||||
.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
|
||||||
@@ -192,10 +231,38 @@ impl Plugin for StatsPlugin {
|
|||||||
refresh_replay_history_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_copy_share_link_button)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(handle_replay_selector_buttons, repaint_replay_selector_caption).chain(),
|
(handle_replay_selector_buttons, repaint_replay_selector_caption).chain(),
|
||||||
);
|
)
|
||||||
|
.add_systems(Update, scroll_stats_panel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Routes mouse-wheel events into the Stats modal's scrollable body
|
||||||
|
/// while the panel is open. No-op when no `StatsScrollable` exists in
|
||||||
|
/// the world (modal closed). Mirrors `scroll_settings_panel`.
|
||||||
|
fn scroll_stats_panel(
|
||||||
|
mut scroll_evr: MessageReader<MouseWheel>,
|
||||||
|
mut scrollables: Query<&mut ScrollPosition, With<StatsScrollable>>,
|
||||||
|
) {
|
||||||
|
if scrollables.is_empty() {
|
||||||
|
scroll_evr.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let delta_y: f32 = scroll_evr
|
||||||
|
.read()
|
||||||
|
.map(|ev| match ev.unit {
|
||||||
|
MouseScrollUnit::Line => ev.y * 50.0,
|
||||||
|
MouseScrollUnit::Pixel => ev.y,
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
if delta_y == 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut sp in scrollables.iter_mut() {
|
||||||
|
sp.0.y = (sp.0.y - delta_y).max(0.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,6 +299,48 @@ fn refresh_replay_history_on_win(
|
|||||||
/// resets the live game to the recorded deal and ticks through the
|
/// resets the live game to the recorded deal and ticks through the
|
||||||
/// move list via [`crate::replay_playback`]; the
|
/// move list via [`crate::replay_playback`]; the
|
||||||
/// [`crate::replay_overlay`] banner surfaces while playback runs.
|
/// [`crate::replay_overlay`] banner surfaces while playback runs.
|
||||||
|
/// Copies [`LastSharedReplayUrl`] to the OS clipboard via `arboard`
|
||||||
|
/// and surfaces a confirmation toast. When no URL is in hand (no win
|
||||||
|
/// yet on a server-backed sync backend, local-only mode, or upload
|
||||||
|
/// failed) the button still acknowledges the click but explains why
|
||||||
|
/// the clipboard wasn't written. `arboard::Clipboard::new()` failures
|
||||||
|
/// are logged + surfaced as a generic "couldn't reach the clipboard"
|
||||||
|
/// toast rather than swallowed — they're rare but worth diagnosing.
|
||||||
|
fn handle_copy_share_link_button(
|
||||||
|
buttons: Query<&Interaction, (With<CopyShareLinkButton>, Changed<Interaction>)>,
|
||||||
|
last_url: Res<LastSharedReplayUrl>,
|
||||||
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
|
) {
|
||||||
|
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(url) = last_url.0.as_ref() else {
|
||||||
|
toast.write(InfoToastEvent(
|
||||||
|
"No share link yet \u{2014} win a game on a server-backed sync to upload one.".to_string(),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match arboard::Clipboard::new() {
|
||||||
|
Ok(mut cb) => match cb.set_text(url.clone()) {
|
||||||
|
Ok(()) => {
|
||||||
|
toast.write(InfoToastEvent(format!("Copied: {url}")));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("clipboard write failed: {e}");
|
||||||
|
toast.write(InfoToastEvent(
|
||||||
|
"Couldn't write to clipboard \u{2014} share link wasn't copied.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
warn!("clipboard init failed: {e}");
|
||||||
|
toast.write(InfoToastEvent(
|
||||||
|
"Couldn't reach the clipboard \u{2014} share link wasn't copied.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_watch_replay_button(
|
fn handle_watch_replay_button(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
buttons: Query<&Interaction, (With<WatchReplayButton>, Changed<Interaction>)>,
|
buttons: Query<&Interaction, (With<WatchReplayButton>, Changed<Interaction>)>,
|
||||||
@@ -558,107 +667,51 @@ fn spawn_stats_screen(
|
|||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
|
|
||||||
spawn_modal(commands, StatsScreen, Z_MODAL_PANEL, |card| {
|
let scrim = spawn_modal(commands, StatsScreen, Z_MODAL_PANEL, |card| {
|
||||||
spawn_modal_header(card, "Statistics", font_res);
|
spawn_modal_header(card, "Statistics", font_res);
|
||||||
|
|
||||||
// First-launch caption — sits above the grid as gentle nudge so
|
// Scrollable body — the Stats panel renders an 8-cell grid plus
|
||||||
// the wall of em-dashes reads as "nothing to track yet" rather
|
// multiple sections (per-mode bests, progression, weekly goals,
|
||||||
// than as broken state.
|
// unlocks, optional Time Attack, latest replay caption) and
|
||||||
if is_first_launch {
|
// overflows the modal on the 800x600 minimum window. Wrapping
|
||||||
card.spawn((
|
// in an `Overflow::scroll_y()` Node with a constrained
|
||||||
Text::new("Play a game to start tracking stats."),
|
// `max_height` keeps every cell reachable; the Watch Replay /
|
||||||
TextFont {
|
// Done action row stays fixed outside the scroll.
|
||||||
font_size: TYPE_CAPTION,
|
card.spawn((
|
||||||
..default()
|
StatsScrollable,
|
||||||
},
|
ScrollPosition::default(),
|
||||||
TextColor(TEXT_SECONDARY),
|
Node {
|
||||||
Node {
|
flex_direction: FlexDirection::Column,
|
||||||
margin: UiRect {
|
row_gap: VAL_SPACE_3,
|
||||||
bottom: VAL_SPACE_2,
|
max_height: Val::Vh(70.0),
|
||||||
|
overflow: Overflow::scroll_y(),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.with_children(|body| {
|
||||||
|
// First-launch caption — sits above the grid as gentle nudge so
|
||||||
|
// the wall of em-dashes reads as "nothing to track yet" rather
|
||||||
|
// than as broken state.
|
||||||
|
if is_first_launch {
|
||||||
|
body.spawn((
|
||||||
|
Text::new("Play a game to start tracking stats."),
|
||||||
|
TextFont {
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
..default()
|
TextColor(TEXT_SECONDARY),
|
||||||
},
|
Node {
|
||||||
));
|
margin: UiRect {
|
||||||
}
|
bottom: VAL_SPACE_2,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// --- primary stat cells grid ---
|
// --- primary stat cells grid ---
|
||||||
card.spawn(Node {
|
body.spawn(Node {
|
||||||
flex_direction: FlexDirection::Row,
|
|
||||||
flex_wrap: FlexWrap::Wrap,
|
|
||||||
justify_content: JustifyContent::Center,
|
|
||||||
align_items: AlignItems::FlexStart,
|
|
||||||
column_gap: VAL_SPACE_4,
|
|
||||||
row_gap: VAL_SPACE_3,
|
|
||||||
width: Val::Percent(100.0),
|
|
||||||
..default()
|
|
||||||
})
|
|
||||||
.with_children(|grid| {
|
|
||||||
spawn_stat_cell(grid, &win_rate_str, "Win Rate");
|
|
||||||
spawn_stat_cell(grid, &played_str, "Games Played");
|
|
||||||
spawn_stat_cell(grid, &won_str, "Games Won");
|
|
||||||
spawn_stat_cell(grid, &lost_str, "Games Lost");
|
|
||||||
spawn_stat_cell(grid, &fastest_str, "Fastest Win");
|
|
||||||
spawn_stat_cell(grid, &avg_time_str, "Avg Time");
|
|
||||||
spawn_stat_cell(grid, &best_score_str, "Best Score");
|
|
||||||
spawn_stat_cell(grid, &best_streak_str, "Best Streak");
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- per-mode bests section ---
|
|
||||||
// Three rows, one per supported mode. Time Attack uses session-level
|
|
||||||
// scoring (count of wins inside a 10-minute window) so a per-game
|
|
||||||
// best wouldn't compose; Daily uses Classic scoring and so already
|
|
||||||
// contributes to the Classic row.
|
|
||||||
card.spawn((
|
|
||||||
Text::new("Per-mode bests"),
|
|
||||||
font_section.clone(),
|
|
||||||
TextColor(STATE_INFO),
|
|
||||||
));
|
|
||||||
card.spawn(Node {
|
|
||||||
flex_direction: FlexDirection::Column,
|
|
||||||
width: Val::Percent(100.0),
|
|
||||||
row_gap: VAL_SPACE_2,
|
|
||||||
..default()
|
|
||||||
})
|
|
||||||
.with_children(|column| {
|
|
||||||
spawn_per_mode_bests_row(
|
|
||||||
column,
|
|
||||||
"Classic",
|
|
||||||
stats.classic_best_score,
|
|
||||||
stats.classic_fastest_win_seconds,
|
|
||||||
&font_row,
|
|
||||||
);
|
|
||||||
spawn_per_mode_bests_row(
|
|
||||||
column,
|
|
||||||
"Zen",
|
|
||||||
stats.zen_best_score,
|
|
||||||
stats.zen_fastest_win_seconds,
|
|
||||||
&font_row,
|
|
||||||
);
|
|
||||||
spawn_per_mode_bests_row(
|
|
||||||
column,
|
|
||||||
"Challenge",
|
|
||||||
stats.challenge_best_score,
|
|
||||||
stats.challenge_fastest_win_seconds,
|
|
||||||
&font_row,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- progression section ---
|
|
||||||
if let Some(p) = progress {
|
|
||||||
card.spawn((
|
|
||||||
Text::new("Progression"),
|
|
||||||
font_section.clone(),
|
|
||||||
TextColor(STATE_INFO),
|
|
||||||
));
|
|
||||||
|
|
||||||
let level_str = format_stat_value(p.level);
|
|
||||||
let xp_str = format_stat_value(p.total_xp as u32);
|
|
||||||
let next_label = xp_to_next_level_label(p.total_xp, p.level);
|
|
||||||
let daily_str = format_stat_value(p.daily_challenge_streak);
|
|
||||||
let challenge_str = challenge_progress_label(p.challenge_index);
|
|
||||||
|
|
||||||
card.spawn(Node {
|
|
||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Row,
|
||||||
flex_wrap: FlexWrap::Wrap,
|
flex_wrap: FlexWrap::Wrap,
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
@@ -669,68 +722,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
|
||||||
@@ -746,6 +875,19 @@ fn spawn_stats_screen(
|
|||||||
ButtonVariant::Secondary,
|
ButtonVariant::Secondary,
|
||||||
font_res,
|
font_res,
|
||||||
);
|
);
|
||||||
|
// Copy share link only renders when a sharable URL is in
|
||||||
|
// hand. The button is intentionally absent (rather than
|
||||||
|
// disabled) when no upload has happened yet — keeps the
|
||||||
|
// action bar free of dead controls in the local-only and
|
||||||
|
// first-launch cases.
|
||||||
|
spawn_modal_button(
|
||||||
|
actions,
|
||||||
|
CopyShareLinkButton,
|
||||||
|
"Copy share link",
|
||||||
|
None,
|
||||||
|
ButtonVariant::Secondary,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
spawn_modal_button(
|
spawn_modal_button(
|
||||||
actions,
|
actions,
|
||||||
StatsCloseButton,
|
StatsCloseButton,
|
||||||
@@ -756,6 +898,8 @@ fn spawn_stats_screen(
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// Stats is read-only — opt into click-outside-to-dismiss.
|
||||||
|
commands.entity(scrim).insert(ScrimDismissible);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn one row of the "Per-mode bests" section: the mode label on the
|
/// Spawn one row of the "Per-mode bests" section: the mode label on the
|
||||||
@@ -1088,6 +1232,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`]
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ use crate::events::{GameWonEvent, ManualSyncRequestEvent, SyncCompleteEvent};
|
|||||||
use crate::game_plugin::RecordingReplay;
|
use crate::game_plugin::RecordingReplay;
|
||||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
||||||
use crate::resources::{GameStateResource, SyncStatus, SyncStatusResource};
|
use crate::resources::{GameStateResource, SyncStatus, SyncStatusResource};
|
||||||
use crate::stats_plugin::{StatsResource, StatsStoragePath};
|
use crate::stats_plugin::{LastSharedReplayUrl, StatsResource, StatsStoragePath};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Public resources
|
// Public resources
|
||||||
@@ -57,6 +57,13 @@ pub struct PullTaskResult(pub Option<Result<SyncPayload, SyncError>>);
|
|||||||
#[derive(Resource, Default)]
|
#[derive(Resource, Default)]
|
||||||
struct PullTask(Option<Task<Result<SyncPayload, SyncError>>>);
|
struct PullTask(Option<Task<Result<SyncPayload, SyncError>>>);
|
||||||
|
|
||||||
|
/// Holds the in-flight winning-replay upload task so the polling
|
||||||
|
/// system can harvest the resulting share URL on the main thread
|
||||||
|
/// without blocking. `None` outside an active upload; `Some(task)`
|
||||||
|
/// from `GameWonEvent` until the response lands.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct PendingReplayUpload(Option<Task<Result<String, SyncError>>>);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Plugin struct
|
// Plugin struct
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -94,12 +101,18 @@ impl Plugin for SyncPlugin {
|
|||||||
.init_resource::<SyncStatusResource>()
|
.init_resource::<SyncStatusResource>()
|
||||||
.init_resource::<PullTaskResult>()
|
.init_resource::<PullTaskResult>()
|
||||||
.init_resource::<PullTask>()
|
.init_resource::<PullTask>()
|
||||||
|
.init_resource::<PendingReplayUpload>()
|
||||||
.add_message::<ManualSyncRequestEvent>()
|
.add_message::<ManualSyncRequestEvent>()
|
||||||
.add_message::<SyncCompleteEvent>()
|
.add_message::<SyncCompleteEvent>()
|
||||||
.add_systems(Startup, start_pull)
|
.add_systems(Startup, start_pull)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(poll_pull_result, handle_manual_sync_request, push_replay_on_win),
|
(
|
||||||
|
poll_pull_result,
|
||||||
|
handle_manual_sync_request,
|
||||||
|
push_replay_on_win,
|
||||||
|
poll_replay_upload_result,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.add_systems(Last, push_on_exit);
|
.add_systems(Last, push_on_exit);
|
||||||
}
|
}
|
||||||
@@ -282,6 +295,7 @@ fn push_replay_on_win(
|
|||||||
provider: Res<SyncProviderResource>,
|
provider: Res<SyncProviderResource>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
recording: Res<RecordingReplay>,
|
recording: Res<RecordingReplay>,
|
||||||
|
mut pending: ResMut<PendingReplayUpload>,
|
||||||
) {
|
) {
|
||||||
for ev in wins.read() {
|
for ev in wins.read() {
|
||||||
// Empty-recording guard mirrors `record_replay_on_win` —
|
// Empty-recording guard mirrors `record_replay_on_win` —
|
||||||
@@ -300,15 +314,39 @@ fn push_replay_on_win(
|
|||||||
recording.moves.clone(),
|
recording.moves.clone(),
|
||||||
);
|
);
|
||||||
let provider = provider.0.clone();
|
let provider = provider.0.clone();
|
||||||
AsyncComputeTaskPool::get()
|
let task = AsyncComputeTaskPool::get()
|
||||||
.spawn(async move {
|
.spawn(async move { provider.push_replay(&replay).await });
|
||||||
match provider.push_replay(&replay).await {
|
// If a previous upload is still in flight, drop it — the most
|
||||||
Ok(()) => {}
|
// recent win is the one whose share link the player will care
|
||||||
Err(SyncError::UnsupportedPlatform) => {}
|
// about. Bevy's `Task` Drop cancels cooperatively.
|
||||||
Err(e) => warn!("replay upload failed: {e}"),
|
pending.0 = Some(task);
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
.detach();
|
|
||||||
|
/// Update-schedule system: harvests the upload task's result on the
|
||||||
|
/// main thread once it resolves. On success writes the share URL to
|
||||||
|
/// [`LastSharedReplayUrl`] so the Stats overlay's Copy button has
|
||||||
|
/// something to send to the clipboard. On `UnsupportedPlatform` (the
|
||||||
|
/// `LocalOnlyProvider` no-op path) clears the URL silently. Real
|
||||||
|
/// network / auth errors log a warn and clear the URL.
|
||||||
|
fn poll_replay_upload_result(
|
||||||
|
mut pending: ResMut<PendingReplayUpload>,
|
||||||
|
mut last_url: ResMut<LastSharedReplayUrl>,
|
||||||
|
) {
|
||||||
|
let Some(task) = pending.0.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(result) = future::block_on(future::poll_once(task)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
pending.0 = None;
|
||||||
|
match result {
|
||||||
|
Ok(url) => last_url.0 = Some(url),
|
||||||
|
Err(SyncError::UnsupportedPlatform) => last_url.0 = None,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("replay upload failed: {e}");
|
||||||
|
last_url.0 = None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -171,11 +171,15 @@ fn advance_time_attack(
|
|||||||
mut ended: MessageWriter<TimeAttackEndedEvent>,
|
mut ended: MessageWriter<TimeAttackEndedEvent>,
|
||||||
paused: Option<Res<crate::pause_plugin::PausedResource>>,
|
paused: Option<Res<crate::pause_plugin::PausedResource>>,
|
||||||
path: Option<Res<TimeAttackSessionPath>>,
|
path: Option<Res<TimeAttackSessionPath>>,
|
||||||
|
home_screens: Query<(), With<crate::home_plugin::HomeScreen>>,
|
||||||
) {
|
) {
|
||||||
if !session.active {
|
if !session.active {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if paused.is_some_and(|p| p.0) {
|
// Mirrors `tick_elapsed_time`: pause while the launch / mode-picker
|
||||||
|
// Home modal is up so the countdown doesn't burn while the player
|
||||||
|
// is choosing what to play next.
|
||||||
|
if paused.is_some_and(|p| p.0) || !home_screens.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
session.remaining_secs -= time.delta_secs();
|
session.remaining_secs -= time.delta_secs();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ impl Plugin for WinSummaryPlugin {
|
|||||||
collect_session_achievements,
|
collect_session_achievements,
|
||||||
spawn_win_summary_after_delay,
|
spawn_win_summary_after_delay,
|
||||||
handle_win_summary_buttons,
|
handle_win_summary_buttons,
|
||||||
|
handle_win_summary_keyboard,
|
||||||
apply_screen_shake,
|
apply_screen_shake,
|
||||||
reveal_score_breakdown,
|
reveal_score_breakdown,
|
||||||
)
|
)
|
||||||
@@ -624,6 +625,31 @@ fn handle_win_summary_buttons(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keyboard accelerator for the win summary's "Play Again" button.
|
||||||
|
/// Enter / Return collapses the win modal and starts a fresh deal —
|
||||||
|
/// the same path the click handler takes — so a keyboard-only player
|
||||||
|
/// can dismiss the post-win celebration without reaching for the mouse.
|
||||||
|
fn handle_win_summary_keyboard(
|
||||||
|
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||||
|
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||||
|
) {
|
||||||
|
if overlays.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(keys) = keys else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !keys.just_pressed(KeyCode::Enter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for entity in &overlays {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
new_game.write(NewGameRequestEvent::default());
|
||||||
|
}
|
||||||
|
|
||||||
/// Applies a decaying sinusoidal offset to the main `Camera2d` each frame
|
/// Applies a decaying sinusoidal offset to the main `Camera2d` each frame
|
||||||
/// while `ScreenShakeResource::remaining > 0`.
|
/// while `ScreenShakeResource::remaining > 0`.
|
||||||
///
|
///
|
||||||
@@ -798,8 +824,11 @@ fn spawn_overlay(
|
|||||||
BackgroundColor(ACCENT_PRIMARY),
|
BackgroundColor(ACCENT_PRIMARY),
|
||||||
))
|
))
|
||||||
.with_children(|b| {
|
.with_children(|b| {
|
||||||
|
// Append the Enter / Return glyph so keyboard players see
|
||||||
|
// the accelerator on the button itself — mirrors the
|
||||||
|
// chip-style hints on every modal button helper.
|
||||||
b.spawn((
|
b.spawn((
|
||||||
Text::new("Play Again"),
|
Text::new("Play Again \u{21B5}"),
|
||||||
TextFont { font_size: TYPE_BODY_LG, ..default() },
|
TextFont { font_size: TYPE_BODY_LG, ..default() },
|
||||||
TextColor(BG_BASE),
|
TextColor(BG_BASE),
|
||||||
));
|
));
|
||||||
|
|||||||
Reference in New Issue
Block a user