Compare commits

..

37 Commits

Author SHA1 Message Date
funman300 bfcd05fbb5 docs: cut v0.18.0 — launch-experience round + async winnable seeds
CHANGELOG.md gains a [0.18.0] section synthesising the 24 commits
since v0.17.0: the Restore prompt + auto-show Home picker launch
flow, MSSC-style picker (header chips, draw-mode chips, picture
tiles with FiraMono-covered glyphs, Today's Event callout), the
last solver hot path moving onto AsyncComputeTaskPool with
cancel-on-replace, "Won before" HUD chip, "Copy share link" Stats
button via arboard, the N-key flow finally routing through the
real Confirm/Cancel modal, Esc-on-modal layering fixes, and the
unified-3.0 Claude rule set adoption.

SESSION_HANDOFF.md (root) refreshed to reflect HEAD at
v0.17.0-24-gc497c31, the carryover punch list trimmed (items B
and C shipped, A partially shipped, D unchanged), and a new
Process notes section describing the test-discipline prune and
the smaller-port template the async hint work should follow.

Build: cargo clippy --workspace --all-targets -- -D warnings clean.
Tests: 1166 passing / 0 failing (one flake on
auto_save_writes_after_30_seconds reproduced clean on re-run;
passes in isolation).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 17:20:10 -07:00
funman300 c497c3193c fix(engine): freeze game timers while the Home picker is up
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 — so the player saw "0:11" before they had
chosen a mode. Time Attack had the same issue when M was pressed
mid-session: the 10-minute countdown burned while the player browsed
modes.

`tick_elapsed_time` and `advance_time_attack` now also gate on the
absence of `HomeScreen`, mirroring their existing `PausedResource`
check. The Home modal already covers input via its scrim, so this
purely freezes the timer without coupling to the pause-overlay
ownership of `PausedResource`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:29:42 +00:00
funman300 9aa0dd23b1 fix(engine): Esc dismisses the topmost modal when Profile stacks on Home
Clicking the new Home header chip opens Profile on top of Home.
Pressing Esc then closed Home (because handle_home_cancel_button
fired on Esc with no awareness of layered modals) and left Profile
orphaned over the game — the player had to press P afterwards just
to dismiss what they meant to dismiss in the first place.

Two changes restore the standard "Esc closes the topmost modal"
contract:

- profile_plugin: split P/button (toggle) from Esc (close-only).
  Esc only fires when Profile is currently open.
- home_plugin: handle_home_cancel_button now skips its Esc branch
  when any other ModalScrim exists, deferring to whichever modal
  is on top. Click on the explicit Cancel button is unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:15:18 +00:00
funman300 d065d49fe7 fix(engine): TimeAttack tile glyph swaps to → (FiraMono ships sideways
triangles inconsistently)

Quat: ▶ (U+25B6) rendered as tofu even though ▲ (U+25B2) from the
same Geometric Shapes block works. FiraMono evidently ships the
up/down triangles but not the left/right siblings.

Swapped to U+2192 (RIGHTWARDS ARROW) from the Arrows block, which
is part of every dev-oriented monospace font's core coverage. Reads
as "go / fast-forward" for the timed mode and is visually distinct
from the other 4 tile glyphs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:06:40 +00:00
funman300 c30b04ec72 fix(engine): Home tile glyphs picked from FiraMono's actual coverage
The bundled face is FiraMono-Medium (assets/fonts/main.ttf), and its
glyph table covers card suits (U+2660-2666) plus basic Geometric
Shapes (U+25xx) but not Dingbats / Misc Symbols. The previous round
of "BMP fallbacks" still picked from blocks FiraMono doesn't cover,
so 4 of 5 tiles continued to render as tofu.

Re-picked from ranges FiraMono actually has:
- Daily: U+25C6 (BLACK DIAMOND)
- Zen:   U+25CB (WHITE CIRCLE) — Zen enso
- Challenge: U+25B2 (BLACK UP-POINTING TRIANGLE) — climbing
- TimeAttack: U+25B6 (BLACK RIGHT-POINTING TRIANGLE) — play / FF
- Classic keeps U+2663 (BLACK CLUB SUIT)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:00:26 +00:00
funman300 40d6e0ab17 fix(engine): Home tile glyphs render + modal fits any viewport
Two regressions Quat caught in screenshot review of the picture-tile
rework:

1. Tofu boxes for 4 of 5 tiles. The earlier emoji picks (calendar,
   cherry-blossom, lightning, stopwatch) live in Unicode planes that
   most Linux desktop fonts don't cover, so they rendered as
   missing-glyph rectangles. Swapped to BMP / Dingbats codepoints
   that the system-default font fallback always has:
   - Daily: \u{2605} (BLACK STAR)
   - Zen:   \u{2740} (WHITE FLORETTE)
   - Challenge: \u{2726} (BLACK FOUR-POINTED STAR)
   - TimeAttack: \u{231A} (WATCH, Misc Symbols / Unicode 1.1)
   Classic keeps its club (\u{2663}) — already rendered correctly.

2. Cancel button pushed off the bottom of the viewport. The 3-row
   tile grid alone is ~540 px; on the 800x600 minimum window the
   modal exceeded the screen. Wrapped chips + draw row + grid in a
   `HomeScrollable` Node with `max_height: 70vh` and `Overflow::scroll_y()`,
   adding a `scroll_home_panel` system to drive `ScrollPosition` from
   `MouseWheel`. Mirrors the existing Settings / Leaderboard /
   Achievements scrollable pattern. Cancel sits outside the scroll
   so it's always reachable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:52:44 +00:00
funman300 9fe650fa20 feat(engine): Home picker — 2-column picture tiles with Unicode glyphs
Phase B step 2 of the MSSC-inspired Home rework. Mode cards become a
wrapping 2-up grid with a centred Unicode-glyph centrepiece per tile,
standing in for real per-mode artwork until that lands.

- HomeMode::glyph() returns the placeholder codepoint for each mode:
  ♣ Classic, calendar Daily, cherry-blossom Zen, lightning Challenge,
  stopwatch TimeAttack. Cherry-blossom is used over lotus-position
  because the latter renders inconsistently across desktop fonts.
- The mode-card loop is wrapped in a FlexWrap::Wrap row container.
  Tiles set `width: 48%` + `min_height: 180px`; the 5-mode grid
  wraps to a third row of one tile, mirroring the half-cell asymmetry
  in MSSC's screenshot.
- The glyph paints in ACCENT_PRIMARY when the mode is unlocked and
  TEXT_DISABLED when locked, so the gate reads at a glance.
- When real art lands, swap the Text node for an Image node — the
  rest of the tile layout, focus order, click handling, and chip
  rendering are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:45:30 +00:00
funman300 b73d246b4c feat(engine): Today's Event callout on the Home Daily card
Phase B step 1 of the MSSC-inspired Home rework — surfaces today's
daily-challenge metadata on the Daily card so the picker reads as
"there's something fresh waiting" rather than a generic mode label.

- Date line "Today, May 6" pulled from DailyChallengeResource. Reads
  in STATE_INFO blue while the run is still open.
- Server-fetched goal (when SyncPlugin is wired) appears underneath
  as "Goal: Win in under 5 minutes", matching the toast that already
  fires when the player presses C.
- Once the player has recorded today's completion, the date flips
  to "Today, May 6 \u{2022} Done" in ACCENT_PRIMARY so the picker
  reads as a reward state rather than a TODO.

Headless tests omit DailyChallengePlugin, so HomeContext.daily_today
defaults to None and the card falls back to its baseline layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:28:59 +00:00
funman300 ae40a1db7a feat(engine): MSSC-style Home picker — header chips, score chips, draw mode
Phase A of the Microsoft-Solitaire-Collection-inspired launch picker
rework. Three additive changes inside the Home modal, no core / asset
work:

- Player-stats header strip showing Level / XP / Lifetime Score using
  a compact formatter (1.2M / 12.3K / 1,234). The whole strip is a
  Button — click fires ToggleProfileRequestEvent so Profile opens on
  top of Home; closing it returns to the picker.
- Draw-mode chip row above the mode cards lets the player flip
  Draw 1 / Draw 3 from the picker itself rather than diving into
  Settings. Active chip uses ACCENT_PRIMARY background; the click
  persists settings.json and respawns the modal so the active state
  repaints cleanly.
- Per-mode score/streak chip on each card — "Best 12,345" for
  Classic / Zen / Challenge, "Streak N" for Daily. Hidden on a 0
  best so a fresh profile doesn't read "Best 0" everywhere.

`HomeContext` bundle pulls live data from ProgressResource /
StatsResource / SettingsResource with safe defaults so headless
tests under MinimalPlugins still build cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:16:01 +00:00
funman300 b7c3a4996f fix(engine): Restore-prompt resolution suppresses Home auto-show
Resolving the Welcome-back / Restore prompt (either Continue or New
game) 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 — clicking "New game" would deal
a fresh game AND immediately pop the mode picker on top.

`LaunchHomeShown` becomes pub so `handle_restore_prompt` can flip it
to `true` after either resolution; `M` still re-opens the picker on
demand. Headless tests already pre-set the flag to true via
`HomePlugin::headless()`, so they're unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:44:31 +00:00
funman300 d48b9489db feat(engine): Esc dismisses Home / accepts default on Restore prompt
Home and Restore-prompt previously ignored Esc, which after the last
fix meant Esc just did nothing on those screens. Now both honor the
"Esc closes the modal" convention every other modal already follows.

- Home: Esc behaves like the Cancel button — despawns the modal so
  the player keeps the underlying default deal.
- Restore: Esc maps to Continue rather than New Game; a reflexive
  dismiss press preserves the saved game, matching how the primary
  action already advertises the Enter accelerator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:36:09 +00:00
funman300 08b006ff30 fix(engine): Esc on a modal no longer also opens Pause underneath
A single Esc press while the Confirm New Game / Restore / Home /
Onboarding / Settings modals were open would both close the modal
(via its own input handler) and spawn the Pause overlay on top in
the same frame, dumping the player on a screen they didn't ask for.

toggle_pause now skips when any non-Pause `ModalScrim` is in the
world. The HUD-button path is gated too — clicking Pause while
another modal is up is almost always an accident.

The four modal queries are bundled into a `PauseModalQueries`
SystemParam to stay under Bevy's 16-parameter cap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:20:39 +00:00
funman300 17e0737a10 feat(engine): Enter dismisses Win Summary and starts a fresh deal
The post-win modal's "Play Again" was click-only — keyboard-only
players had to reach for the mouse to leave the celebration screen,
and the button advertised no accelerator the way every other modal
button does.

- handle_win_summary_keyboard reads Enter while WinSummaryOverlay is
  in the world; despawns the overlay and writes the same
  NewGameRequestEvent the click handler takes.
- The button label gains a trailing return-key glyph so the keyboard
  path is discoverable on first sight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:13:26 +00:00
funman300 dd63261999 feat(engine): auto-show Home / mode picker on launch
The Home (mode picker) was only reachable via M during gameplay, so
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
  matching `headless()` test constructor that disables it.
- spawn_home_on_launch flips a one-shot LaunchHomeShown flag once the
  splash has cleared, gated on RestorePromptScreen / PendingRestoredGame
  so the Welcome-back flow still takes precedence on machines with a
  saved game.
- App entry uses HomePlugin::default(); both headless test fixtures
  switch to HomePlugin::headless() so per-test worlds start clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 06:57:25 +00:00
funman300 93660c2217 feat(engine): N keypress now opens the real Confirm/Cancel modal
Previously a first N press during an active game showed a "Press N
again" toast and started a 3-second countdown — a UI-first violation
since the only continuation was another keystroke. The HUD New Game
button already routed through `ConfirmNewGameScreen` with real Cancel
/ New game buttons; this change makes keyboard N do the same.

- handle_keyboard_core fires NewGameRequestEvent::default() directly;
  handle_new_game's existing active-game check spawns the modal.
- Shift+N keeps the keyboard power-user bypass (confirmed: true).
- N is suppressed while the confirm modal or restore prompt is open
  so those modals' own input handlers can process N (cancel /
  start-new-game) without us re-firing the same frame they close.
- KeyboardConfirmState, NEW_GAME_CONFIRM_WINDOW, NewGameConfirmEvent,
  and the "Press N again" toast handler are removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 06:57:14 +00:00
funman300 56e2e6f151 feat(engine): empty-state copy + onboarding hints across panels
- Leaderboard empty state: replace single muted line with a two-tier
  "Be the first on the leaderboard." headline + body invite.
- Achievements panel: surface a first-launch hint above the grid until
  the player unlocks anything, so the greyed-out rows aren't context-free.
- Volume hotkeys ([/]): emit an InfoToastEvent with the new percentage so
  off-panel adjustments give visible feedback (previously silent).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 06:16:37 +00:00
funman300 cc635328be fix(engine): popover rows stay visible regardless of action-bar fade
Quat: opening Modes / Menu showed a solid dark-purple block in the
top-right with no readable content. Cause: the auto-fade system on
the top-level action bar was fading the popover rows too — they
share the `ActionButton` marker so `paint_action_buttons` can still
paint hover/press, but `apply_action_fade` matched the same marker
and dropped their alpha to whatever the cursor-position-based
fade happened to be (typically 0 because the cursor was inside the
opened popover, well below the top reveal zone). The popover
container stayed at full opacity (its background is `BG_ELEVATED`,
not driven by the fade), so what the player saw was the empty
rounded box with no labels.

Fix: new `PopoverRow` marker on the rows in `spawn_modes_popover`
and `spawn_menu_popover` (both share the same row-spawn shape).
`apply_action_fade` excludes `PopoverRow` via `Without<PopoverRow>`.
Hover / press paint still applies — the popover rows just opt out
of the cursor-position auto-fade since they only render when the
player has explicitly opened the dropdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 05:54:34 +00:00
funman300 a4bc063497 fix(engine): Settings rows use full-width layout to prevent overlap
Quat reported the volume UI overlapped with adjacent UI elements in
the Settings panel. The five slider/toggle row helpers
(volume_row × 2, tooltip_delay_row, time_bonus_multiplier_row,
replay_move_interval_row, toggle_row) all used the same flex pattern:

    Node {
        flex_direction: Row,
        align_items: Center,
        column_gap: VAL_SPACE_2,
    }

with no width constraint and no justify_content. Result: every
child packed against the left edge with 8 px gaps. As the value text
varied in width (e.g. "0.80" → "1.00", or "Instant" vs "1.5 s") the
+/− buttons shifted sideways frame to frame, and on narrow windows
the row's natural width could exceed the modal interior, pushing
elements past the right edge or visually merging with neighbours.

Restructured all five helpers to a label-spacer-cluster layout:

    [Label]                      [Value] [-] [+]
    └────── flex-grow=1 ──────┘  └─ cluster ─┘

with `width: Val::Percent(100.0)` on the row so it spans the body
width. The flex-grow spacer absorbs all slack horizontal space; the
controls cluster (value + buttons) sits flush against the right
edge regardless of value-text length. Existing tests still pass —
no behaviour change, just stable layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 05:45:16 +00:00
funman300 540869c851 feat(engine): "Copy share link" Stats button — clipboards the replay URL
Quat: replay sharing as the next punch-list item.

End-to-end:

1. Player wins a game on a server-backed sync backend.
2. `sync_plugin::push_replay_on_win` spawns the upload task on
   `AsyncComputeTaskPool` and stores the handle in the new
   `PendingReplayUpload` resource. The previous in-flight task (if
   any) is dropped — the most recent win is the one whose share link
   the player will care about.
3. `poll_replay_upload_result` harvests the task on the main thread
   each frame; on success writes `<server>/replays/<id>` to
   `LastSharedReplayUrl`. `UnsupportedPlatform` (LocalOnlyProvider)
   is silently absorbed; real network/auth errors warn-log.
4. The Stats overlay's action bar gains a "Copy share link" button.
   Click writes `LastSharedReplayUrl` to the OS clipboard via
   `arboard` and surfaces a "Copied: <url>" toast.

Trait change: `SyncProvider::push_replay` now returns `Result<String,
SyncError>` (the share URL) instead of `Result<(), SyncError>`. The
default (`UnsupportedPlatform`) is unchanged for non-server backends;
`SolitaireServerClient` parses the response body's `id` field and
composes `<base_url>/replays/<id>`. Both call paths (initial + 401
retry) go through the new `share_url_from_response` helper so the
parse logic isn't duplicated.

New deps:
- `arboard` (~10 KB, cross-platform clipboard) added to workspace +
  `solitaire_engine`. `default-features = false` keeps the X11/Wayland
  binary-feature deps off the dependency graph; arboard handles the
  fallback. Approved per the ASK BEFORE rule.

Persistence: the URL is in-memory only — the player must share within
the session of the win. A future revision can persist it alongside
the replay history file if cross-session sharing is needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 05:32:57 +00:00
funman300 bdac754b26 feat(engine): "Won before" HUD indicator on rematched seeds
When the current deal's (seed, draw_mode, mode) triple matches an
entry in the rolling ReplayHistory, the HUD's tier-2 context row
now shows "✓ Won before" in the success-green colour. Cleared when
the active game itself is won (the on-screen victory cue is enough)
and on fresh deals the player hasn't beaten before.

The indicator answers a question the rolling-history feature
implicitly raised: when a new game starts on a seed the player has
already conquered, surface that fact so they know they can try for
a faster / higher-scoring win on the same layout. Seed re-rolls in
"Winnable deals only" + system-time seeds make this a natural pace
for the indicator to fire — usually empty, occasionally lit.

Implementation: new `HudWonPreviously` marker spawned in tier-2
alongside Mode / Challenge / DrawCycle. Driven by a separate
`update_won_previously` system rather than threading the marker
through `update_hud`'s ten-way query disambiguation. Reads the
existing `ReplayHistoryResource` from `stats_plugin`; gracefully
no-ops in headless tests that don't load StatsPlugin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 05:23:16 +00:00
funman300 f863d85c35 fix(engine): preserve saved game while restore prompt is unanswered
Quat reported the restore prompt didn't appear and noticed their
save file ended up with move_count 0 — diagnosed as a destructive
overwrite. The flow:

1. Player exits with moves; game_state.json has move_count > 0.
2. Player relaunches. Plugin build sees moves > 0, holds the saved
   game in `PendingRestoredGame`, seeds `GameStateResource` with a
   fresh deal so the board doesn't show the half-played game until
   the player picks Continue.
3. The restore prompt should appear. (Why it didn't on Quat's run
   is still TBD — needs a fresh test.)
4. Player exits. `save_game_state_on_exit` writes
   `GameStateResource` (the fresh-deal placeholder) to disk,
   overwriting the meaningful saved game with move_count 0.

Both `save_game_state_on_exit` and `auto_save_game_state` now check
`PendingRestoredGame`: if it still holds an unanswered saved game,
they save THAT (or skip entirely in the auto-save path). The real
saved game on disk is preserved across launches no matter how many
times the player exits without answering the prompt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 05:15:31 +00:00
funman300 3c7a0eb4fb feat(engine): restore prompt on launch — Continue or start fresh
Previously the engine silently restored any saved in-progress game
from `game_state.json` on startup. Players who launched expecting a
fresh deal got dropped back into a half-played game with no signal
that a save had been picked up; players who wanted to continue had
no clear acknowledgement either way.

Now: when launching with a saved game that has at least one move
and isn't already won, the engine holds the saved state in a new
`PendingRestoredGame` resource and seeds `GameStateResource` with
a fresh deal. Once the splash overlay finishes, a modal appears:

    Welcome back
    You have an in-progress game. Continue where you left off, or
    start a new one?
    [New game]   [Continue]

- Continue (Enter / C / click) — swaps the saved game into
  `GameStateResource` and fires `StateChangedEvent`. Card sprites
  resync to the restored layout.
- New game (N / click) — drops the saved state, fires
  `NewGameRequestEvent { confirmed: true }`. The existing
  `handle_new_game` flow then deletes `game_state.json` and deals.

Save files with `move_count == 0` (a fresh deal that was never
played) skip the prompt and load directly — there's nothing
meaningful to "continue" there. Won games skip too (the existing
flow already deletes their save file on win).

The spawn system gates on `SplashRoot` being absent so the modal
doesn't pop up over the brand splash on first launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:57:49 +00:00
funman300 d489e7a31b feat(engine): solver-vetted seed selection on AsyncComputeTaskPool
"Winnable deals only" used to call `choose_winnable_seed` inline on
the main thread inside `handle_new_game`. Each rejected attempt costs
~120 ms (`SolverConfig::default()` budget); the loop caps at
`SOLVER_DEAL_RETRY_CAP` = 50, so a pathological run could stall the
UI for ~6 s on a New Game click. Quat flagged this as the highest-
impact UX regression left in the engine.

Reorganised so the solver runs on `AsyncComputeTaskPool`:

- New `PendingNewGameSeed` resource holds an `Option<PendingSeedTask>`
  carrying the in-flight `Task<u64>` plus the request's `mode` and
  `confirmed` flags so the polling system can replay them on a
  synthetic `NewGameRequestEvent` once the task resolves.
- `handle_new_game` now writes to that resource (and `continue`s)
  for the winnable-only / Classic / random-seed branch, instead of
  calling `choose_winnable_seed` synchronously.
- `poll_pending_new_game_seed` runs `.before(GameMutation)` so the
  synthetic event lands in the same frame's `handle_new_game` —
  the player sees no extra-frame visual lag once the solver
  completes.
- Cancel-on-replace: when a fresh `NewGameRequestEvent` arrives
  while a previous task is in flight, `pending_seed.inner = None`
  drops the old task (Bevy's `Task` Drop cancels cooperatively at
  the next await point) before processing the new request.

Two tests:

- `winnable_seed_search_runs_async_and_completes_eventually` —
  spawns the task, drives `app.update()` in a wall-clock-bounded
  loop with `std::thread::yield_now()` so the shared
  `AsyncComputeTaskPool` gets a chance to schedule between polls.
- `winnable_seed_search_drops_in_flight_task_on_new_request` —
  fires a winnable-only request, then before the task can complete
  fires an explicit-seed request that bypasses the solver entirely.
  Asserts the explicit seed wins, verifying the cancel-on-replace
  contract.

Existing solver tests pass unchanged: explicit-seed paths skip the
new branch and run synchronously like before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:49:19 +00:00
funman300 f2f30c8002 docs: adopt unified-3.0 Claude rule set + trim duplications
Adopts the four-file rule set the player added to the working tree:

- CLAUDE.md grows from a 114-line pointer doc to the 571-line
  `unified-3.0` rulebook: hard global constraints (§2), engine
  rules (§3), asset rules (§4), code standards (§5), build +
  verification (§6), git workflow (§7), the change-control
  ASK BEFORE list (§8), and the Context Injection System (§14).
- CLAUDE_SPEC.md — formal architecture spec: crate dependency
  graph with forbidden_deps, data ownership map, state-machine
  invariants ("52 cards always exist", "no duplicate IDs",
  "all cards belong to exactly one pile"), sync merge contract,
  server contract, validation checklist.
- CLAUDE_WORKFLOW.md — two-agent Builder/Guardian pipeline with
  hard-fail patterns that auto-reject (core uses IO/Bevy/network,
  GameState mutated outside GameLogicSystem, blocking async on
  main thread, duplicate logic, merge altered incorrectly).
- CLAUDE_PROMPT_PACK.md — task-type templates.

Three duplicate rule passages removed:

- CLAUDE_SPEC.md §0 dropped no_panics_in_core / core_is_pure /
  event_driven_engine — already canonical in CLAUDE.md §2.1, §2.3,
  §3.1. Kept single_source_of_truth and sync_is_additive (those
  describe data flow, not in CLAUDE.md).
- CLAUDE_SPEC.md §11 Prohibited Patterns now references CLAUDE.md
  §11 instead of restating the same five forbidden items.
- ARCHITECTURE.md Design Principles dropped the pure-core /
  no-panics / UI-first bullets — those are enforcement constraints
  living in CLAUDE.md §2.1, §2.3, §3.3; this file describes the
  design that motivates them. Kept the offline-first, one-language,
  and plugin-based-Bevy bullets (those are descriptive, not
  enforcement).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:42:24 +00:00
funman300 a49a340a30 chore: prune low-value tests per CLAUDE_SPEC.md §10 + WORKFLOW §8
The Quat-flagged "≥3 tests per feature" inflation produced 43 tests
that don't earn their existence — default-value, serde-derive
round-trips on plain structs, single-field clamp tests, near-
duplicates, and trivial constant-equals-itself tests. None pin a
behaviour contract or a regression on a real bug.

Removed across `solitaire_data` and `solitaire_core`:

  settings.rs   −22  default-value, round-trip, legacy-format,
                     and per-field sanitized clamp tests. Adjust
                     and load-error tests retained — those exercise
                     real method logic.
  progress.rs    −1  generic round-trip on plain struct.
  challenge.rs   −1  challenge_count() returns CHALLENGE_SEEDS.len()
                     literally — testing it asserts the implementation
                     against itself.
  game_state.rs  −3  undo_count starts at 0, GameMode default is
                     Classic, time_attack score starts at 0 — all
                     default-value tests on freshly-constructed state.
  card.rs        −5  rank_value_ace + rank_value_king subsumed by
                     rank_values_are_sequential; suit_red + suit_black
                     consolidated into one complementarity test;
                     card_face_up_field_reflects_construction was
                     testing the struct literal.

Workspace: 1208 → 1165 passing tests (−43). clippy --workspace
--all-targets clean.

Future work: brief sub-agents for tests that pin a behaviour
contract or regression on a real bug, not a count of N. See
`feedback_test_discipline.md` in auto-memory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:42:05 +00:00
funman300 27cdf78ce0 docs: cut v0.17.0 — solver-driven hints + replay-rate slider
Two follow-up commits on top of v0.16.0:
- 87275bf: H-key hint asks the v0.15.0 solver for the actual best
  first move, with the existing heuristic kept as fallback.
- 53e3b81: Settings → Gameplay slider tunes replay playback rate
  (0.10–1.00 s, default 0.45 s) read per frame from SettingsResource.

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

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

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

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

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

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

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

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

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

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

New public API (additive — try_solve unchanged):

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:08:46 +00:00
41 changed files with 6404 additions and 1496 deletions
+2 -3
View File
@@ -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
View File
@@ -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
+531 -74
View File
@@ -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.12.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
+497
View File
@@ -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
View File
@@ -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
+335
View File
@@ -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
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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 (AD); 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.02.0, default 1.0, "Off" at zero. Multiplies the time-bonus shown in the win modal. Cosmetic only — does NOT affect achievement unlock thresholds. | | 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 AF. Don't pick unilaterally. OPEN AT THE START: ask which of AD. Don't pick unilaterally.
``` ```
+1 -1
View File
@@ -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())
+6 -31
View File
@@ -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);
} }
} }
-16
View File
@@ -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
View File
@@ -65,7 +65,7 @@ use std::hash::{Hash, Hasher};
use crate::card::{Card, Suit}; use crate::card::{Card, Suit};
use crate::deck::{deal_klondike, Deck}; use crate::deck::{deal_klondike, Deck};
use crate::game_state::DrawMode; use crate::game_state::{DrawMode, GameState};
use crate::pile::{Pile, PileType}; use crate::pile::{Pile, PileType};
use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence}; use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence};
@@ -108,6 +108,42 @@ impl Default for SolverConfig {
} }
} }
/// A single move the solver can recommend, expressed in terms of the
/// engine-level `(source, dest, count)` triple used by `MoveRequestEvent`.
///
/// Returned as part of [`SolveOutcome::first_move`] when
/// [`try_solve_with_first_move`] or [`try_solve_from_state`] proves the
/// position winnable. The hint system surfaces this to the player as the
/// "provably best" first move.
///
/// `count` is always `1` for non-tableau-to-tableau moves (foundation moves
/// always move a single card; waste moves a single card; draws use a
/// dedicated representation that the public API surfaces as
/// `source: PileType::Stock, dest: PileType::Waste, count: 1`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SolverMove {
/// Pile the move originates from.
pub source: PileType,
/// Pile the move lands on.
pub dest: PileType,
/// Number of cards in the move (1 for non-tableau-to-tableau moves).
pub count: usize,
}
/// Solver verdict plus, when winnable, the first move on a winning path.
///
/// `result == Winnable` guarantees `first_move == Some(_)`; the inverse
/// holds only when the search proved a verdict — `Inconclusive` and
/// `Unwinnable` always carry `first_move == None`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SolveOutcome {
/// The high-level verdict (Winnable / Unwinnable / Inconclusive).
pub result: SolverResult,
/// First move on the solution path when `result == Winnable`,
/// otherwise `None`.
pub first_move: Option<SolverMove>,
}
/// Tries to solve a fresh Classic-mode game from `seed` + `draw_mode`. /// Tries to solve a fresh Classic-mode game from `seed` + `draw_mode`.
/// ///
/// This is a pure function — same input always yields the same /// This is a pure function — same input always yields the same
@@ -120,18 +156,47 @@ impl Default for SolverConfig {
/// [`is_valid_tableau_sequence`]) drive move enumeration so the /// [`is_valid_tableau_sequence`]) drive move enumeration so the
/// solver's notion of "legal" exactly matches the live game. /// solver's notion of "legal" exactly matches the live game.
pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> SolverResult { pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> SolverResult {
// Delegate to the path-recording variant and discard the move. The path
// recording is cheap (a single Option<SolverMove> per stack frame) so
// this preserves `try_solve`'s existing performance characteristics —
// the new-game retry loop, which is the hot caller, sees no slowdown.
try_solve_with_first_move(seed, draw_mode, config).result
}
/// Tries to solve a fresh Classic-mode game from `seed` + `draw_mode` and,
/// when a win is found, returns the first move on the winning path.
///
/// Same semantics as [`try_solve`] for the verdict; the only difference is
/// that the [`SolveOutcome::first_move`] is populated when `result ==
/// SolverResult::Winnable`. `Unwinnable` and `Inconclusive` always carry
/// `first_move == None`.
///
/// Used by the engine hint system to promote H-key suggestions from a
/// heuristic to the provably-optimal first move; the hint system falls
/// back to its heuristic when this returns `Inconclusive`.
pub fn try_solve_with_first_move(
seed: u64,
draw_mode: DrawMode,
config: &SolverConfig,
) -> SolveOutcome {
let state = SolverState::initial(seed, draw_mode); let state = SolverState::initial(seed, draw_mode);
let mut visited: HashSet<u64> = HashSet::new(); state.solve(config)
let mut moves_consumed: u64 = 0; }
let mut budget_exceeded = false;
let won = state.search(config, &mut visited, &mut moves_consumed, &mut budget_exceeded); /// Tries to solve from an existing in-progress [`GameState`].
if won { ///
SolverResult::Winnable /// Mirrors [`try_solve_with_first_move`] but takes a live `GameState`
} else if budget_exceeded { /// instead of a fresh seed. The hint system uses this so it can ask the
SolverResult::Inconclusive /// solver about the actual board the player is staring at, not just the
} else { /// initial deal.
SolverResult::Unwinnable ///
} /// Reads `state.draw_mode` and the current pile contents. The active
/// `GameMode` is irrelevant — the solver only models Classic Klondike
/// rules, which are a strict subset of every other mode (Zen / Challenge
/// only differ in scoring and undo-availability).
pub fn try_solve_from_state(state: &GameState, config: &SolverConfig) -> SolveOutcome {
let solver_state = SolverState::from_game_state(state);
solver_state.solve(config)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -141,9 +206,11 @@ pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> Solve
/// The candidate moves the solver enumerates at each step. Distinct /// The candidate moves the solver enumerates at each step. Distinct
/// from `MoveRequestEvent` (engine-level) and `move_cards` (game-level) /// from `MoveRequestEvent` (engine-level) and `move_cards` (game-level)
/// because the solver also needs to model the stock-draw + recycle as a /// because the solver also needs to model the stock-draw + recycle as a
/// first-class move. /// first-class move. Distinct from the public [`SolverMove`] because the
/// internal form encodes each move kind structurally for fast pattern
/// matching during enumeration.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SolverMove { enum InternalMove {
/// Move `count` cards from a tableau column to another tableau column. /// Move `count` cards from a tableau column to another tableau column.
TableauToTableau { from: usize, to: usize, count: usize }, TableauToTableau { from: usize, to: usize, count: usize },
/// Move the top of a tableau column to a foundation slot. /// Move the top of a tableau column to a foundation slot.
@@ -156,6 +223,41 @@ enum SolverMove {
Draw, Draw,
} }
impl InternalMove {
/// Convert this internal move into the public [`SolverMove`] form
/// suitable for handing off to the engine layer. Cheap — `O(1)` field
/// rewrites with no allocation.
fn to_public(self) -> SolverMove {
match self {
InternalMove::TableauToTableau { from, to, count } => SolverMove {
source: PileType::Tableau(from),
dest: PileType::Tableau(to),
count,
},
InternalMove::TableauToFoundation { from, slot } => SolverMove {
source: PileType::Tableau(from),
dest: PileType::Foundation(slot),
count: 1,
},
InternalMove::WasteToTableau { to } => SolverMove {
source: PileType::Waste,
dest: PileType::Tableau(to),
count: 1,
},
InternalMove::WasteToFoundation { slot } => SolverMove {
source: PileType::Waste,
dest: PileType::Foundation(slot),
count: 1,
},
InternalMove::Draw => SolverMove {
source: PileType::Stock,
dest: PileType::Waste,
count: 1,
},
}
}
}
/// Compact replica of `GameState` tailored for the solver. Strips /// Compact replica of `GameState` tailored for the solver. Strips
/// undo / score / move-count tracking and replaces the `HashMap` of /// undo / score / move-count tracking and replaces the `HashMap` of
/// piles with fixed arrays so the canonical hash is cheap to compute. /// piles with fixed arrays so the canonical hash is cheap to compute.
@@ -232,8 +334,8 @@ impl SolverState {
/// The order matters — foundation moves shrink the search frontier /// The order matters — foundation moves shrink the search frontier
/// fastest, and stock-draws are the costliest. See the top-of-file /// fastest, and stock-draws are the costliest. See the top-of-file
/// algorithm note. /// algorithm note.
fn enumerate_moves(&self) -> Vec<SolverMove> { fn enumerate_moves(&self) -> Vec<InternalMove> {
let mut moves: Vec<SolverMove> = Vec::new(); let mut moves: Vec<InternalMove> = Vec::new();
// 1) Foundation moves from tableau tops. // 1) Foundation moves from tableau tops.
for (i, col) in self.tableau.iter().enumerate() { for (i, col) in self.tableau.iter().enumerate() {
@@ -246,7 +348,7 @@ impl SolverState {
&self.foundation[slot as usize], &self.foundation[slot as usize],
); );
if can_place_on_foundation(top, &foundation_pile) { if can_place_on_foundation(top, &foundation_pile) {
moves.push(SolverMove::TableauToFoundation { from: i, slot }); moves.push(InternalMove::TableauToFoundation { from: i, slot });
} }
} }
} }
@@ -260,7 +362,7 @@ impl SolverState {
&self.foundation[slot as usize], &self.foundation[slot as usize],
); );
if can_place_on_foundation(top, &foundation_pile) { if can_place_on_foundation(top, &foundation_pile) {
moves.push(SolverMove::WasteToFoundation { slot }); moves.push(InternalMove::WasteToFoundation { slot });
} }
} }
@@ -298,7 +400,7 @@ impl SolverState {
{ {
continue; continue;
} }
moves.push(SolverMove::TableauToTableau { from: src, to: dst, count }); moves.push(InternalMove::TableauToTableau { from: src, to: dst, count });
} }
} }
} }
@@ -308,7 +410,7 @@ impl SolverState {
for dst in 0..7usize { for dst in 0..7usize {
let dst_pile = Self::pile_view(PileType::Tableau(dst), &self.tableau[dst]); let dst_pile = Self::pile_view(PileType::Tableau(dst), &self.tableau[dst]);
if can_place_on_tableau(top, &dst_pile) { if can_place_on_tableau(top, &dst_pile) {
moves.push(SolverMove::WasteToTableau { to: dst }); moves.push(InternalMove::WasteToTableau { to: dst });
} }
} }
} }
@@ -326,7 +428,7 @@ impl SolverState {
let cycled_without_progress = let cycled_without_progress =
self.consecutive_draws > stock_cycle_len.saturating_add(1); self.consecutive_draws > stock_cycle_len.saturating_add(1);
if can_draw && !cycled_without_progress { if can_draw && !cycled_without_progress {
moves.push(SolverMove::Draw); moves.push(InternalMove::Draw);
} }
moves moves
@@ -334,11 +436,11 @@ impl SolverState {
/// Apply `mv` to `self`, returning the previous `consecutive_draws` /// Apply `mv` to `self`, returning the previous `consecutive_draws`
/// value so the caller can restore it on backtrack. /// value so the caller can restore it on backtrack.
fn apply_move(&mut self, mv: SolverMove) -> SolverStateUndo { fn apply_move(&mut self, mv: InternalMove) -> SolverStateUndo {
let prev_just_drew = self.just_drew; let prev_just_drew = self.just_drew;
let prev_consec = self.consecutive_draws; let prev_consec = self.consecutive_draws;
match mv { match mv {
SolverMove::TableauToTableau { from, to, count } => { InternalMove::TableauToTableau { from, to, count } => {
let start = self.tableau[from].len() - count; let start = self.tableau[from].len() - count;
let moved: Vec<Card> = self.tableau[from].split_off(start); let moved: Vec<Card> = self.tableau[from].split_off(start);
self.tableau[to].extend(moved); self.tableau[to].extend(moved);
@@ -351,7 +453,7 @@ impl SolverState {
self.just_drew = false; self.just_drew = false;
self.consecutive_draws = 0; self.consecutive_draws = 0;
} }
SolverMove::TableauToFoundation { from, slot } => { InternalMove::TableauToFoundation { from, slot } => {
if let Some(card) = self.tableau[from].pop() { if let Some(card) = self.tableau[from].pop() {
self.foundation[slot as usize].push(card); self.foundation[slot as usize].push(card);
if let Some(top) = self.tableau[from].last_mut() if let Some(top) = self.tableau[from].last_mut()
@@ -363,21 +465,21 @@ impl SolverState {
self.just_drew = false; self.just_drew = false;
self.consecutive_draws = 0; self.consecutive_draws = 0;
} }
SolverMove::WasteToTableau { to } => { InternalMove::WasteToTableau { to } => {
if let Some(card) = self.waste.pop() { if let Some(card) = self.waste.pop() {
self.tableau[to].push(card); self.tableau[to].push(card);
} }
self.just_drew = false; self.just_drew = false;
self.consecutive_draws = 0; self.consecutive_draws = 0;
} }
SolverMove::WasteToFoundation { slot } => { InternalMove::WasteToFoundation { slot } => {
if let Some(card) = self.waste.pop() { if let Some(card) = self.waste.pop() {
self.foundation[slot as usize].push(card); self.foundation[slot as usize].push(card);
} }
self.just_drew = false; self.just_drew = false;
self.consecutive_draws = 0; self.consecutive_draws = 0;
} }
SolverMove::Draw => { InternalMove::Draw => {
if self.stock.is_empty() { if self.stock.is_empty() {
// Recycle waste back to stock face-down, reversed. // Recycle waste back to stock face-down, reversed.
let mut recycled: Vec<Card> = self.waste.drain(..).collect(); let mut recycled: Vec<Card> = self.waste.drain(..).collect();
@@ -415,39 +517,55 @@ impl SolverState {
/// stack lives on the heap and grows only with `Vec` capacity, not /// stack lives on the heap and grows only with `Vec` capacity, not
/// with thread-stack pages. /// with thread-stack pages.
/// ///
/// Returns `true` as soon as a winning leaf is found. Sets /// Returns `Some(first_move)` (the move applied at the root that led
/// `*budget_exceeded = true` if either budget trips before a /// to a winning leaf) as soon as a winning leaf is found. Returns
/// verdict. /// `None` if the search exhausts (Unwinnable) or a budget trips —
/// callers distinguish those two cases via `*budget_exceeded`.
///
/// Path recording is implemented by stashing the root-level move on
/// each pushed frame and propagating it unchanged into deeper
/// children. Cost: one `Option<InternalMove>` (≤ 16 bytes) per
/// frame and one branch on push. Negligible on the hot path; the
/// new-game retry loop sees no measurable slowdown.
fn search( fn search(
self, self,
config: &SolverConfig, config: &SolverConfig,
visited: &mut HashSet<u64>, visited: &mut HashSet<u64>,
moves_consumed: &mut u64, moves_consumed: &mut u64,
budget_exceeded: &mut bool, budget_exceeded: &mut bool,
) -> bool { ) -> Option<SolverMove> {
// Each stack frame keeps a state plus the move iterator we // Each stack frame keeps a state plus the move iterator we
// haven't yet expanded. Popping a frame is the backtrack. // haven't yet expanded. Popping a frame is the backtrack.
struct Frame { struct Frame {
state: SolverState, state: SolverState,
pending: std::vec::IntoIter<SolverMove>, pending: std::vec::IntoIter<InternalMove>,
/// First move on the path from the root to this frame's
/// state. `None` for the root frame; populated when a child
/// frame is pushed. Propagates unchanged from parent to deeper
/// children so any winning leaf can read it directly.
root_move: Option<InternalMove>,
} }
// Quick exits before allocating the stack. // Quick exits before allocating the stack. An already-won state
// surfaces as Winnable with no move to recommend (the player has
// nothing left to do); the engine treats this gracefully —
// `is_won` callers gate H-key hints on `!is_won` already.
if self.is_won() { if self.is_won() {
return true; return None;
} }
if *moves_consumed >= config.move_budget || visited.len() >= config.state_budget { if *moves_consumed >= config.move_budget || visited.len() >= config.state_budget {
*budget_exceeded = true; *budget_exceeded = true;
return false; return None;
} }
let root_hash = self.canonical_hash(); let root_hash = self.canonical_hash();
if !visited.insert(root_hash) { if !visited.insert(root_hash) {
return false; return None;
} }
let root_moves = self.enumerate_moves(); let root_moves = self.enumerate_moves();
let mut stack: Vec<Frame> = Vec::new(); let mut stack: Vec<Frame> = Vec::new();
stack.push(Frame { stack.push(Frame {
state: self, state: self,
pending: root_moves.into_iter(), pending: root_moves.into_iter(),
root_move: None,
}); });
while let Some(frame) = stack.last_mut() { while let Some(frame) = stack.last_mut() {
@@ -457,7 +575,7 @@ impl SolverState {
|| visited.len() >= config.state_budget || visited.len() >= config.state_budget
{ {
*budget_exceeded = true; *budget_exceeded = true;
return false; return None;
} }
let Some(mv) = frame.pending.next() else { let Some(mv) = frame.pending.next() else {
// Exhausted this frame's children — backtrack. // Exhausted this frame's children — backtrack.
@@ -465,10 +583,15 @@ impl SolverState {
continue; continue;
}; };
*moves_consumed = moves_consumed.saturating_add(1); *moves_consumed = moves_consumed.saturating_add(1);
// Determine the root-level move for the *child* we are about
// to push: if the current frame is the root (root_move is
// None) then the child's root move is `mv` itself; otherwise
// it inherits from the parent.
let child_root_move = frame.root_move.unwrap_or(mv);
let mut next = frame.state.clone(); let mut next = frame.state.clone();
next.apply_move(mv); next.apply_move(mv);
if next.is_won() { if next.is_won() {
return true; return Some(child_root_move.to_public());
} }
let h = next.canonical_hash(); let h = next.canonical_hash();
if !visited.insert(h) { if !visited.insert(h) {
@@ -478,9 +601,74 @@ impl SolverState {
stack.push(Frame { stack.push(Frame {
state: next, state: next,
pending: next_moves.into_iter(), pending: next_moves.into_iter(),
root_move: Some(child_root_move),
}); });
} }
false None
}
/// Drive [`SolverState::search`] and convert the raw outcome into a
/// public [`SolveOutcome`]. Shared by [`try_solve_with_first_move`]
/// and [`try_solve_from_state`].
fn solve(self, config: &SolverConfig) -> SolveOutcome {
let mut visited: HashSet<u64> = HashSet::new();
let mut moves_consumed: u64 = 0;
let mut budget_exceeded = false;
let already_won = self.is_won();
let first_move = self.search(config, &mut visited, &mut moves_consumed, &mut budget_exceeded);
let result = if already_won || first_move.is_some() {
SolverResult::Winnable
} else if budget_exceeded {
SolverResult::Inconclusive
} else {
SolverResult::Unwinnable
};
SolveOutcome { result, first_move }
}
/// Build a `SolverState` from an in-progress [`GameState`].
///
/// Reads the live pile contents and `draw_mode`. Missing piles are
/// treated as empty — the engine's `GameState::new` always populates
/// every pile slot, but defensive code keeps this loader safe in the
/// face of partially-constructed test fixtures.
///
/// The search-metadata fields (`just_drew`, `consecutive_draws`)
/// reset to "no draws yet" — the solver is concerned with future
/// reachability from this position, not the engine's own draw
/// history.
fn from_game_state(game: &GameState) -> Self {
let tableau: [Vec<Card>; 7] = core::array::from_fn(|i| {
game.piles
.get(&PileType::Tableau(i))
.map(|p| p.cards.clone())
.unwrap_or_default()
});
let foundation: [Vec<Card>; 4] = core::array::from_fn(|i| {
game.piles
.get(&PileType::Foundation(i as u8))
.map(|p| p.cards.clone())
.unwrap_or_default()
});
let stock = game
.piles
.get(&PileType::Stock)
.map(|p| p.cards.clone())
.unwrap_or_default();
let waste = game
.piles
.get(&PileType::Waste)
.map(|p| p.cards.clone())
.unwrap_or_default();
Self {
tableau,
foundation,
stock,
waste,
draw_mode: game.draw_mode.clone(),
just_drew: false,
consecutive_draws: 0,
}
} }
/// Build a deterministic 64-bit hash of the visible game state. /// Build a deterministic 64-bit hash of the visible game state.
@@ -656,9 +844,9 @@ mod tests {
let mut moves_consumed: u64 = 0; let mut moves_consumed: u64 = 0;
let mut budget_exceeded = false; let mut budget_exceeded = false;
let cfg = SolverConfig::default(); let cfg = SolverConfig::default();
let won = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded); let first_move = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded);
assert!(won, "obviously-winnable position must be recognised as Winnable"); assert!(first_move.is_some(), "obviously-winnable position must be recognised as Winnable");
assert!(!budget_exceeded); assert!(!budget_exceeded);
assert!( assert!(
moves_consumed < 1000, moves_consumed < 1000,
@@ -699,8 +887,8 @@ mod tests {
let mut visited: HashSet<u64> = HashSet::new(); let mut visited: HashSet<u64> = HashSet::new();
let mut moves_consumed: u64 = 0; let mut moves_consumed: u64 = 0;
let mut budget_exceeded = false; let mut budget_exceeded = false;
let won = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded); let first_move = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded);
assert!(!won, "buried Ace under same-suit Two with no recovery must not solve"); assert!(first_move.is_none(), "buried Ace under same-suit Two with no recovery must not solve");
assert!(!budget_exceeded, "small synthetic state must complete within budget"); assert!(!budget_exceeded, "small synthetic state must complete within budget");
} }
@@ -890,4 +1078,170 @@ mod tests {
counts[0], counts[1], counts[2], counts[0], counts[1], counts[2],
); );
} }
// -----------------------------------------------------------------------
// First-move-recording API: try_solve_with_first_move /
// try_solve_from_state. Exercised by the engine hint system.
// -----------------------------------------------------------------------
/// A synthetic GameState with each foundation holding A..Q for its
/// suit, the four Kings sitting on tableau columns 0..3, empty stock
/// and empty waste. Exactly four legal moves exist — one Tableau→
/// Foundation per King — and any one of them is the first move on a
/// solution path.
fn near_finished_game_state() -> GameState {
use crate::card::Rank;
let mut game = GameState::new(1, DrawMode::DrawOne);
// Wipe every pile.
for slot in 0..4_u8 {
game.piles
.get_mut(&PileType::Foundation(slot))
.unwrap()
.cards
.clear();
}
for i in 0..7_usize {
game.piles
.get_mut(&PileType::Tableau(i))
.unwrap()
.cards
.clear();
}
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
// Foundations: A through Q for each suit. Slot 0=Clubs,
// 1=Diamonds, 2=Hearts, 3=Spades to match
// `target_foundation_slot` ordering.
let suit_for_slot = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
let ranks_below_king = [
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
Rank::Jack, Rank::Queen,
];
for (slot, suit) in suit_for_slot.iter().enumerate() {
let pile = game
.piles
.get_mut(&PileType::Foundation(slot as u8))
.unwrap();
for (i, rank) in ranks_below_king.iter().enumerate() {
pile.cards.push(Card {
id: (slot as u32) * 13 + i as u32,
suit: *suit,
rank: *rank,
face_up: true,
});
}
}
// Tableau 0..3: one King each, face-up.
for (col, suit) in suit_for_slot.iter().enumerate() {
game.piles
.get_mut(&PileType::Tableau(col))
.unwrap()
.cards
.push(Card {
id: 100 + col as u32,
suit: *suit,
rank: Rank::King,
face_up: true,
});
}
game
}
#[test]
fn try_solve_with_first_move_returns_some_move_for_winnable_state() {
let game = near_finished_game_state();
let cfg = SolverConfig::default();
let outcome = try_solve_from_state(&game, &cfg);
assert_eq!(
outcome.result,
SolverResult::Winnable,
"near-finished state must solve as Winnable"
);
let mv = outcome.first_move.expect("Winnable must include a first_move");
// The first move must be a King going from a tableau column to
// its matching foundation slot. Single-card move.
assert_eq!(mv.count, 1);
assert!(matches!(mv.source, PileType::Tableau(c) if c < 4));
assert!(matches!(mv.dest, PileType::Foundation(s) if s < 4));
}
#[test]
fn try_solve_with_first_move_returns_none_for_unwinnable_state() {
use crate::card::Rank;
// The "buried Ace under same-suit Two with no recovery" fixture
// used by `solver_recognises_obviously_unwinnable_deal`, lifted
// into a real `GameState` so we can exercise `try_solve_from_state`.
let mut game = GameState::new(1, DrawMode::DrawOne);
for slot in 0..4_u8 {
game.piles
.get_mut(&PileType::Foundation(slot))
.unwrap()
.cards
.clear();
}
for i in 0..7_usize {
game.piles
.get_mut(&PileType::Tableau(i))
.unwrap()
.cards
.clear();
}
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
// Tableau 0: A♠ on the bottom, 2♠ on top — the 2♠ has no legal
// destination, so the Ace is buried forever.
let t0 = game.piles.get_mut(&PileType::Tableau(0)).unwrap();
t0.cards.push(Card { id: 0, suit: Suit::Spades, rank: Rank::Ace, face_up: true });
t0.cards.push(Card { id: 1, suit: Suit::Spades, rank: Rank::Two, face_up: true });
// Tableau 1: a face-up King with nothing else — irrelevant; the
// pruning check elides "King → empty" no-ops.
game.piles
.get_mut(&PileType::Tableau(1))
.unwrap()
.cards
.push(Card { id: 2, suit: Suit::Clubs, rank: Rank::King, face_up: true });
let cfg = SolverConfig::default();
let outcome = try_solve_from_state(&game, &cfg);
assert_eq!(
outcome.result,
SolverResult::Unwinnable,
"buried-Ace fixture must be proved Unwinnable"
);
assert!(
outcome.first_move.is_none(),
"Unwinnable verdict must carry first_move == None"
);
}
#[test]
fn try_solve_with_first_move_is_deterministic() {
// Same state run multiple times yields the same first_move.
let game = near_finished_game_state();
let cfg = SolverConfig::default();
let a = try_solve_from_state(&game, &cfg);
let b = try_solve_from_state(&game, &cfg);
let c = try_solve_from_state(&game, &cfg);
assert_eq!(a, b, "repeat solves must yield the same outcome");
assert_eq!(b, c);
}
#[test]
fn try_solve_with_first_move_seed_form_matches_state_form() {
// For a fresh seed, the two public entry points must agree —
// they share the same internal `solve()` implementation, but
// route through different state constructors. This is the
// smoke test that catches drift between them.
let cfg = SolverConfig {
move_budget: 5_000,
state_budget: 5_000,
};
let a = try_solve_with_first_move(7, DrawMode::DrawOne, &cfg);
let game = GameState::new(7, DrawMode::DrawOne);
let b = try_solve_from_state(&game, &cfg);
assert_eq!(a.result, b.result, "verdicts must match across the two entry points");
assert_eq!(a.first_move, b.first_move, "first_move must match across the two entry points");
}
} }
-5
View File
@@ -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());
}
} }
+10 -9
View File
@@ -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,
}; };
-15
View File
@@ -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
View File
@@ -181,6 +181,17 @@ pub struct Settings {
/// solver retry loop — see `solitaire_engine::handle_new_game`. /// solver retry loop — see `solitaire_engine::handle_new_game`.
#[serde(default)] #[serde(default)]
pub winnable_deals_only: bool, pub winnable_deals_only: bool,
/// Per-move duration during replay playback, in seconds. Range
/// `[REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS]`;
/// default mirrors `solitaire_engine::replay_playback::REPLAY_MOVE_INTERVAL_SECS`
/// (0.45 s/move) so existing playback behaviour is unchanged for
/// players who never touch the slider. Smaller values scrub
/// faster through the recorded move list. Older `settings.json`
/// files written before this field existed deserialize cleanly to
/// the default via
/// `#[serde(default = "default_replay_move_interval_secs")]`.
#[serde(default = "default_replay_move_interval_secs")]
pub replay_move_interval_secs: f32,
} }
fn default_draw_mode() -> DrawMode { fn default_draw_mode() -> DrawMode {
@@ -238,6 +249,33 @@ fn default_time_bonus_multiplier() -> f32 {
1.0 1.0
} }
/// Default per-move duration during replay playback, in seconds.
/// Mirrors `solitaire_engine::replay_playback::REPLAY_MOVE_INTERVAL_SECS`
/// so legacy `settings.json` files load to the existing baseline and
/// playback feels identical for players who never touch the slider.
/// The constant is duplicated across the data and engine crates
/// because `solitaire_data` cannot depend on the engine crate — keep
/// the two values in sync when adjusting either.
fn default_replay_move_interval_secs() -> f32 {
0.45
}
/// Lower bound of the player-tunable replay-playback per-move interval,
/// in seconds. Below this the cards barely register visually before
/// the next move fires; the cap keeps the playback legible.
pub const REPLAY_MOVE_INTERVAL_MIN_SECS: f32 = 0.10;
/// Upper bound of the player-tunable replay-playback per-move interval,
/// in seconds. One second per move is a comfortable upper limit for
/// players who want to study a recorded game frame by frame.
pub const REPLAY_MOVE_INTERVAL_MAX_SECS: f32 = 1.00;
/// Increment applied by the replay-playback decrement / increment
/// buttons. 0.05 s gives 19 stops between MIN and MAX — fine-grained
/// enough to land on any "round" speed (0.10 s, 0.25 s, 0.45 s, etc.)
/// without making the slider feel stuck on the same value.
pub const REPLAY_MOVE_INTERVAL_STEP_SECS: f32 = 0.05;
/// Maximum number of seed retries [`solitaire_engine::handle_new_game`] /// Maximum number of seed retries [`solitaire_engine::handle_new_game`]
/// is willing to attempt before giving up and accepting the latest /// is willing to attempt before giving up and accepting the latest
/// candidate seed when [`Settings::winnable_deals_only`] is on. If /// candidate seed when [`Settings::winnable_deals_only`] is on. If
@@ -268,14 +306,16 @@ impl Default for Settings {
tooltip_delay_secs: default_tooltip_delay(), tooltip_delay_secs: default_tooltip_delay(),
time_bonus_multiplier: default_time_bonus_multiplier(), time_bonus_multiplier: default_time_bonus_multiplier(),
winnable_deals_only: false, winnable_deals_only: false,
replay_move_interval_secs: default_replay_move_interval_secs(),
} }
} }
} }
impl Settings { impl Settings {
/// Clamps `sfx_volume`, `music_volume`, `tooltip_delay_secs`, and /// Clamps `sfx_volume`, `music_volume`, `tooltip_delay_secs`,
/// `time_bonus_multiplier` into their respective ranges after /// `time_bonus_multiplier`, and `replay_move_interval_secs` into
/// deserialization or hand-editing of `settings.json`. /// their respective ranges after deserialization or hand-editing of
/// `settings.json`.
pub fn sanitized(self) -> Self { pub fn sanitized(self) -> Self {
Self { Self {
sfx_volume: self.sfx_volume.clamp(0.0, 1.0), sfx_volume: self.sfx_volume.clamp(0.0, 1.0),
@@ -286,6 +326,9 @@ impl Settings {
time_bonus_multiplier: self time_bonus_multiplier: self
.time_bonus_multiplier .time_bonus_multiplier
.clamp(TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX), .clamp(TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX),
replay_move_interval_secs: self
.replay_move_interval_secs
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS),
..self ..self
} }
} }
@@ -324,6 +367,21 @@ impl Settings {
self.time_bonus_multiplier = (raw * 10.0).round() / 10.0; self.time_bonus_multiplier = (raw * 10.0).round() / 10.0;
self.time_bonus_multiplier self.time_bonus_multiplier
} }
/// Adjust the replay-playback per-move interval by `delta`
/// seconds, clamped to
/// `[REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS]`.
/// The result is rounded to two decimal places so the readout
/// stays clean across repeated `±` clicks at the 0.05 s step
/// (avoids float drift like `0.45000003`). Returns the new value.
pub fn adjust_replay_move_interval(&mut self, delta: f32) -> f32 {
let raw = (self.replay_move_interval_secs + delta)
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS);
// Round to 2 decimal places — the slider step is 0.05, so this
// collapses any FP drift introduced by repeated additions.
self.replay_move_interval_secs = (raw * 100.0).round() / 100.0;
self.replay_move_interval_secs
}
} }
/// Returns the platform-specific path to `settings.json`, or `None` if /// Returns the platform-specific path to `settings.json`, or `None` if
@@ -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
); );
} }
} }
+33 -18
View File
@@ -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))
} }
} }
+1
View File
@@ -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 }
+219 -65
View File
@@ -7,6 +7,7 @@
use std::path::PathBuf; use std::path::PathBuf;
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*; use bevy::prelude::*;
use chrono::{Local, Timelike, Utc}; use chrono::{Local, Timelike, Utc};
use solitaire_core::achievement::{ use solitaire_core::achievement::{
@@ -31,6 +32,7 @@ use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::stats_plugin::{StatsResource, StatsUpdate}; use crate::stats_plugin::{StatsResource, StatsUpdate};
use crate::ui_modal::{ use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
@@ -48,6 +50,19 @@ pub struct AchievementsScreen;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct AchievementRow; pub struct AchievementRow;
/// Marker on the scrollable body Node inside the Achievements modal.
///
/// The Achievements list can grow to ~19 rows which overflows the modal at
/// the 800x600 minimum window. This marker tags the inner container that
/// carries `Overflow::scroll_y()` plus a `max_height` constraint so the
/// content scrolls instead of clipping. Mirrors the
/// `SettingsPanelScrollable` pattern in `settings_plugin`.
///
/// `scroll_achievements_panel` reads this marker to route mouse-wheel
/// events into the body's `ScrollPosition`.
#[derive(Component, Debug)]
pub struct AchievementsScrollable;
/// All per-player achievement records (one per known achievement). /// All per-player achievement records (one per known achievement).
#[derive(Resource, Debug, Clone)] #[derive(Resource, Debug, Clone)]
pub struct AchievementsResource(pub Vec<AchievementRecord>); pub struct AchievementsResource(pub Vec<AchievementRecord>);
@@ -96,6 +111,11 @@ impl Plugin for AchievementPlugin {
.add_message::<XpAwardedEvent>() .add_message::<XpAwardedEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_message::<ToggleAchievementsRequestEvent>() .add_message::<ToggleAchievementsRequestEvent>()
// `MouseWheel` is emitted by Bevy's input plugin under
// `DefaultPlugins`; register it explicitly so the
// achievements-scroll system also runs cleanly under
// `MinimalPlugins` in tests.
.add_message::<MouseWheel>()
// Run after GameMutation (so GameWonEvent is available), after // Run after GameMutation (so GameWonEvent is available), after
// StatsUpdate (so stats reflect this win), and after ProgressUpdate // StatsUpdate (so stats reflect this win), and after ProgressUpdate
// (so daily_challenge_streak is up to date for daily_devotee). // (so daily_challenge_streak is up to date for daily_devotee).
@@ -118,6 +138,7 @@ impl Plugin for AchievementPlugin {
) )
.add_systems(Update, toggle_achievements_screen) .add_systems(Update, toggle_achievements_screen)
.add_systems(Update, handle_achievements_close_button) .add_systems(Update, handle_achievements_close_button)
.add_systems(Update, scroll_achievements_panel)
// Event-driven unlock: observe `ReplayPlaybackState` and unlock // Event-driven unlock: observe `ReplayPlaybackState` and unlock
// `cinephile` the first time playback runs to natural completion. // `cinephile` the first time playback runs to natural completion.
// Reads the resource via `Option<Res<_>>` so headless tests that // Reads the resource via `Option<Res<_>>` so headless tests that
@@ -395,6 +416,38 @@ fn handle_achievements_close_button(
} }
} }
/// Routes mouse-wheel events into the Achievements modal's scrollable body
/// while the panel is open.
///
/// `offset_y` increases downward (0 = top). Scrolling down (`ev.y < 0`) adds
/// to the offset; scrolling up subtracts. Clamped to >= 0 so the viewport
/// never scrolls past the top. Mirrors `scroll_settings_panel` in
/// `settings_plugin`. The query is empty when no `AchievementsScrollable`
/// is in the world (modal closed) so this is a no-op outside the open
/// state without an explicit gate resource.
fn scroll_achievements_panel(
mut scroll_evr: MessageReader<MouseWheel>,
mut scrollables: Query<&mut ScrollPosition, With<AchievementsScrollable>>,
) {
if scrollables.is_empty() {
scroll_evr.clear();
return;
}
let delta_y: f32 = scroll_evr
.read()
.map(|ev| match ev.unit {
MouseScrollUnit::Line => ev.y * 50.0,
MouseScrollUnit::Pixel => ev.y,
})
.sum();
if delta_y == 0.0 {
return;
}
for mut sp in scrollables.iter_mut() {
sp.0.y = (sp.0.y - delta_y).max(0.0);
}
}
fn spawn_achievements_screen( fn spawn_achievements_screen(
commands: &mut Commands, commands: &mut Commands,
records: &[AchievementRecord], records: &[AchievementRecord],
@@ -421,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
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
+1 -12
View File
@@ -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
+108 -19
View File
@@ -2,9 +2,19 @@
//! //!
//! **Cursor icons** (`update_cursor_icon`) //! **Cursor icons** (`update_cursor_icon`)
//! - Cards are being dragged → `Grabbing` (closed hand) //! - Cards are being dragged → `Grabbing` (closed hand)
//! - A UI `Button` entity is hovered (and no drag in progress) → `Pointer`
//! (the hand-with-extended-index-finger icon). This telegraphs
//! clickability for every modal button, HUD action, mode-launcher
//! card, settings toggle, etc.
//! - Cursor hovers over a face-up draggable card → `Grab` (open hand) //! - Cursor hovers over a face-up draggable card → `Grab` (open hand)
//! - Otherwise → `Default` (arrow) //! - Otherwise → `Default` (arrow)
//! //!
//! Priority order: dragging > button-hover > card-hover > default. A
//! button-overlapping-a-card edge case favours `Pointer` because UI
//! elements take precedence over world-space cards; in practice
//! buttons are always on UI nodes and cards are sprites, so they
//! cannot occupy the same hit region simultaneously.
//!
//! **Drop-target highlights** (`update_drop_highlights`) //! **Drop-target highlights** (`update_drop_highlights`)
//! While a drag is in progress every `PileMarker` sprite is tinted: //! While a drag is in progress every `PileMarker` sprite is tinted:
//! - **Green** if the dragged stack can legally land there. //! - **Green** if the dragged stack can legally land there.
@@ -70,6 +80,31 @@ impl Plugin for CursorPlugin {
// #31 — Cursor icon // #31 — Cursor icon
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Pure decision function for the cursor icon, separated from the Bevy
/// system so it can be unit-tested without `PrimaryWindow` /
/// `Camera` / `Time` plumbing.
///
/// Priority order (highest first):
/// 1. `is_dragging` → `Grabbing`
/// 2. `any_button_hovered` → `Pointer`
/// 3. `any_card_hovered` → `Grab`
/// 4. otherwise → `Default`
fn pick_cursor_icon(
is_dragging: bool,
any_button_hovered: bool,
any_card_hovered: bool,
) -> SystemCursorIcon {
if is_dragging {
SystemCursorIcon::Grabbing
} else if any_button_hovered {
SystemCursorIcon::Pointer
} else if any_card_hovered {
SystemCursorIcon::Grab
} else {
SystemCursorIcon::Default
}
}
/// Updates the primary-window cursor icon based on drag state and hover. /// Updates the primary-window cursor icon based on drag state and hover.
fn update_cursor_icon( fn update_cursor_icon(
drag: Res<DragState>, drag: Res<DragState>,
@@ -77,32 +112,39 @@ fn update_cursor_icon(
cameras: Query<(&Camera, &GlobalTransform)>, cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
game: Option<Res<GameStateResource>>, game: Option<Res<GameStateResource>>,
button_q: Query<&Interaction, With<Button>>,
mut commands: Commands, mut commands: Commands,
) { ) {
let Ok((win_entity, window)) = windows.single() else { return }; let Ok((win_entity, window)) = windows.single() else { return };
if !drag.is_idle() { let is_dragging = !drag.is_idle();
commands
.entity(win_entity)
.insert(CursorIcon::from(SystemCursorIcon::Grabbing));
return;
}
let hovering = (|| { // A UI button is "hovered" if any `Button` entity has its
let cursor = window.cursor_position()?; // `Interaction` set to `Hovered` or `Pressed`. We include
let (camera, cam_xf) = cameras.single().ok()?; // `Pressed` so the pointer icon stays visible while a click is
let world = camera.viewport_to_world_2d(cam_xf, cursor).ok()?; // being held, matching browser behaviour.
let layout = layout.as_ref()?.0.clone(); let any_button_hovered = button_q
let game = game.as_ref()?; .iter()
Some(cursor_over_draggable(world, &game.0, &layout)) .any(|i| matches!(i, Interaction::Hovered | Interaction::Pressed));
})()
.unwrap_or(false);
commands.entity(win_entity).insert(CursorIcon::from(if hovering { let any_card_hovered = if is_dragging || any_button_hovered {
SystemCursorIcon::Grab // No need to do the world-space hit test when a higher
// priority branch already wins.
false
} else { } else {
SystemCursorIcon::Default (|| {
})); let cursor = window.cursor_position()?;
let (camera, cam_xf) = cameras.single().ok()?;
let world = camera.viewport_to_world_2d(cam_xf, cursor).ok()?;
let layout = layout.as_ref()?.0.clone();
let game = game.as_ref()?;
Some(cursor_over_draggable(world, &game.0, &layout))
})()
.unwrap_or(false)
};
let icon = pick_cursor_icon(is_dragging, any_button_hovered, any_card_hovered);
commands.entity(win_entity).insert(CursorIcon::from(icon));
} }
/// Returns `true` if `cursor` (world-space) is over any face-up draggable card. /// Returns `true` if `cursor` (world-space) is over any face-up draggable card.
@@ -482,6 +524,53 @@ mod tests {
); );
} }
// -----------------------------------------------------------------------
// pick_cursor_icon priority-order tests
// -----------------------------------------------------------------------
#[test]
fn cursor_picks_grabbing_when_dragging_overrides_button_hover() {
// Dragging always wins regardless of button or card hover state.
assert!(matches!(
pick_cursor_icon(true, true, true),
SystemCursorIcon::Grabbing
));
assert!(matches!(
pick_cursor_icon(true, false, false),
SystemCursorIcon::Grabbing
));
}
#[test]
fn cursor_picks_pointer_when_button_hovered_and_no_drag() {
// Button hover beats card hover when not dragging.
assert!(matches!(
pick_cursor_icon(false, true, false),
SystemCursorIcon::Pointer
));
assert!(matches!(
pick_cursor_icon(false, true, true),
SystemCursorIcon::Pointer
));
}
#[test]
fn cursor_picks_grab_when_card_hovered_and_no_button() {
// Card hover wins only when no drag and no button-hover.
assert!(matches!(
pick_cursor_icon(false, false, true),
SystemCursorIcon::Grab
));
}
#[test]
fn cursor_picks_default_when_nothing_hovered() {
assert!(matches!(
pick_cursor_icon(false, false, false),
SystemCursorIcon::Default
));
}
#[test] #[test]
fn cursor_over_draggable_returns_false_for_empty_game() { fn cursor_over_draggable_returns_false_for_empty_game() {
use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::game_state::{DrawMode, GameState};
-7
View File
@@ -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)]
+2 -2
View File
@@ -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 %
+402 -17
View File
@@ -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",
);
}
} }
+145 -49
View File
@@ -4,12 +4,14 @@
//! is an optional accelerator. Listed shortcuts are grouped by intent — //! is an optional accelerator. Listed shortcuts are grouped by intent —
//! gameplay, modes, and overlays. //! gameplay, modes, and overlays.
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*; use bevy::prelude::*;
use crate::events::HelpRequestEvent; use crate::events::HelpRequestEvent;
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::ui_modal::{ use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
Z_MODAL_PANEL, BORDER_SUBTLE, RADIUS_SM, SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, Z_MODAL_PANEL, BORDER_SUBTLE, RADIUS_SM, SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
@@ -24,6 +26,16 @@ pub struct HelpScreen;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct HelpCloseButton; pub struct HelpCloseButton;
/// Marker on the scrollable body Node inside the Help modal.
///
/// The controls reference is six sections totalling ~28 rows, which
/// overflows the modal on the 800x600 minimum window. This marker tags
/// the inner container that carries `Overflow::scroll_y()` plus a
/// `max_height` constraint so every row stays reachable. Mirrors the
/// `SettingsPanelScrollable` pattern.
#[derive(Component, Debug)]
pub struct HelpScrollable;
/// Spawns and despawns the help / controls overlay shown when the player /// Spawns and despawns the help / controls overlay shown when the player
/// clicks the "Help" HUD button or presses `F1`. All hotkeys and gesture /// clicks the "Help" HUD button or presses `F1`. All hotkeys and gesture
/// guides live here. /// guides live here.
@@ -32,7 +44,14 @@ pub struct HelpPlugin;
impl Plugin for HelpPlugin { impl Plugin for HelpPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_message::<HelpRequestEvent>() app.add_message::<HelpRequestEvent>()
.add_systems(Update, (toggle_help_screen, handle_help_close_button)); // `MouseWheel` is emitted by Bevy's input plugin under
// `DefaultPlugins`; register it explicitly so the help-scroll
// system also runs cleanly under `MinimalPlugins` in tests.
.add_message::<MouseWheel>()
.add_systems(
Update,
(toggle_help_screen, handle_help_close_button, scroll_help_panel),
);
} }
} }
@@ -71,6 +90,32 @@ fn handle_help_close_button(
} }
} }
/// Routes mouse-wheel events into the Help modal's scrollable body while
/// the panel is open. No-op when no `HelpScrollable` exists in the world
/// (modal closed). Mirrors `scroll_settings_panel`.
fn scroll_help_panel(
mut scroll_evr: MessageReader<MouseWheel>,
mut scrollables: Query<&mut ScrollPosition, With<HelpScrollable>>,
) {
if scrollables.is_empty() {
scroll_evr.clear();
return;
}
let delta_y: f32 = scroll_evr
.read()
.map(|ev| match ev.unit {
MouseScrollUnit::Line => ev.y * 50.0,
MouseScrollUnit::Pixel => ev.y,
})
.sum();
if delta_y == 0.0 {
return;
}
for mut sp in scrollables.iter_mut() {
sp.0.y = (sp.0.y - delta_y).max(0.0);
}
}
/// Each entry in the controls reference table. /// Each entry in the controls reference table.
struct ControlRow { struct ControlRow {
keys: &'static str, keys: &'static str,
@@ -165,62 +210,80 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
..default() ..default()
}; };
spawn_modal(commands, HelpScreen, Z_MODAL_PANEL, |card| { let scrim = spawn_modal(commands, HelpScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Controls", font_res); spawn_modal_header(card, "Controls", font_res);
for section in CONTROL_SECTIONS { // Scrollable body — the controls reference is six sections totalling
// Section title in muted text — distinguishes from row content. // ~28 rows, which overflows the modal on the 800x600 minimum
card.spawn(( // window. Wrapping in an `Overflow::scroll_y()` Node with a
Text::new(section.title), // constrained `max_height` keeps every row reachable; the Done
font_section.clone(), // button below stays fixed outside the scroll.
TextColor(TEXT_SECONDARY), card.spawn((
)); HelpScrollable,
ScrollPosition::default(),
Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_2,
max_height: Val::Vh(70.0),
overflow: Overflow::scroll_y(),
..default()
},
))
.with_children(|body| {
for section in CONTROL_SECTIONS {
// Section title in muted text — distinguishes from row content.
body.spawn((
Text::new(section.title),
font_section.clone(),
TextColor(TEXT_SECONDARY),
));
// Each row is a flex-row: kbd-style chip + description. // Each row is a flex-row: kbd-style chip + description.
for row in section.rows { for row in section.rows {
card.spawn(Node { body.spawn(Node {
flex_direction: FlexDirection::Row, flex_direction: FlexDirection::Row,
align_items: AlignItems::Center, align_items: AlignItems::Center,
column_gap: VAL_SPACE_3, column_gap: VAL_SPACE_3,
..default() ..default()
}) })
.with_children(|line| { .with_children(|line| {
// The hotkey rendered as a small chip with a border — // The hotkey rendered as a small chip with a border —
// visual cue that it's a key reference, not part of // visual cue that it's a key reference, not part of
// the description text. // the description text.
line.spawn(( line.spawn((
Node { Node {
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1), padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
min_width: Val::Px(64.0), min_width: Val::Px(64.0),
justify_content: JustifyContent::Center, justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)), border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)), border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default() ..default()
}, },
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
)) ))
.with_children(|chip| { .with_children(|chip| {
chip.spawn(( chip.spawn((
Text::new(row.keys), Text::new(row.keys),
font_kbd.clone(), font_kbd.clone(),
TextColor(TEXT_PRIMARY),
));
});
line.spawn((
Text::new(row.description),
font_row.clone(),
TextColor(TEXT_PRIMARY), TextColor(TEXT_PRIMARY),
)); ));
}); });
line.spawn(( }
Text::new(row.description),
font_row.clone(), // Section spacer — small empty box. Keeps each section
TextColor(TEXT_PRIMARY), // visually grouped.
)); body.spawn(Node {
height: Val::Px(SPACE_2),
..default()
}); });
} }
});
// Section spacer — small empty box. Keeps each section
// visually grouped.
card.spawn(Node {
height: Val::Px(SPACE_2),
..default()
});
}
spawn_modal_actions(card, |actions| { spawn_modal_actions(card, |actions| {
spawn_modal_button( spawn_modal_button(
@@ -233,6 +296,9 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
); );
}); });
}); });
// Help is read-only — clicking the scrim outside the card dismisses
// alongside the existing F1 / Esc / Done paths.
commands.entity(scrim).insert(ScrimDismissible);
} }
#[cfg(test)] #[cfg(test)]
@@ -264,6 +330,36 @@ mod tests {
); );
} }
#[test]
fn help_modal_body_is_scrollable() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::F1);
app.update();
let count = app
.world_mut()
.query::<&HelpScrollable>()
.iter(app.world())
.count();
assert_eq!(
count, 1,
"Help modal must spawn exactly one HelpScrollable body"
);
let mut q = app
.world_mut()
.query_filtered::<&Node, With<HelpScrollable>>();
let nodes: Vec<&Node> = q.iter(app.world()).collect();
assert_ne!(
nodes[0].max_height,
Val::Auto,
"scrollable body must set a non-default max_height"
);
assert_eq!(nodes[0].overflow, Overflow::scroll_y());
}
#[test] #[test]
fn pressing_f1_twice_closes_help_screen() { fn pressing_f1_twice_closes_help_screen() {
let mut app = headless_app(); let mut app = headless_app();
+710 -33
View File
@@ -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
+82 -1
View File
@@ -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>,
+287 -76
View File
@@ -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)"
);
}
} }
+165 -65
View File
@@ -9,6 +9,7 @@
//! When the provider does not support leaderboards (e.g. `LocalOnlyProvider`) //! When the provider does not support leaderboards (e.g. `LocalOnlyProvider`)
//! the panel shows "Not available" immediately. //! the panel shows "Not available" immediately.
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*; use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task}; use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use solitaire_data::settings::SyncBackend; use solitaire_data::settings::SyncBackend;
@@ -20,10 +21,11 @@ use crate::settings_plugin::SettingsResource;
use crate::sync_plugin::SyncProviderResource; use crate::sync_plugin::SyncProviderResource;
use crate::ui_modal::{ use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_INFO, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, ACCENT_PRIMARY, BORDER_SUBTLE, STATE_INFO, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_4, Z_MODAL_PANEL, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_4, Z_MODAL_PANEL,
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -66,6 +68,18 @@ struct LeaderboardFetchTask(Option<Task<Result<Vec<LeaderboardEntry>, String>>>)
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct LeaderboardScreen; pub struct LeaderboardScreen;
/// Marker on the scrollable body Node inside the Leaderboard modal.
///
/// The leaderboard caps at the top 10 entries today, but rendering the
/// caption + opt-in/opt-out row + 10 data rows on the 800x600 minimum
/// window is right at the edge of overflowing — long display names or
/// future row-count expansion would cut off entries below the fold.
/// Wrapping the data section in an `Overflow::scroll_y()` Node with a
/// constrained `max_height` keeps every row reachable. Mirrors the
/// `SettingsPanelScrollable` pattern.
#[derive(Component, Debug)]
pub struct LeaderboardScrollable;
/// Marker on the "Opt In" button inside the leaderboard panel. /// Marker on the "Opt In" button inside the leaderboard panel.
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct LeaderboardOptInButton; struct LeaderboardOptInButton;
@@ -98,6 +112,11 @@ impl Plugin for LeaderboardPlugin {
.init_resource::<OptInTask>() .init_resource::<OptInTask>()
.init_resource::<OptOutTask>() .init_resource::<OptOutTask>()
.add_message::<ToggleLeaderboardRequestEvent>() .add_message::<ToggleLeaderboardRequestEvent>()
// `MouseWheel` is emitted by Bevy's input plugin under
// `DefaultPlugins`; register it explicitly so the
// leaderboard-scroll system also runs cleanly under
// `MinimalPlugins` in tests.
.add_message::<MouseWheel>()
.add_systems( .add_systems(
Update, Update,
( (
@@ -112,7 +131,8 @@ impl Plugin for LeaderboardPlugin {
poll_opt_out_task, poll_opt_out_task,
) )
.chain(), .chain(),
); )
.add_systems(Update, scroll_leaderboard_panel);
} }
} }
@@ -222,6 +242,33 @@ fn update_leaderboard_panel(
} }
/// Click handler for the modal's "Done" button — despawns the overlay. /// Click handler for the modal's "Done" button — despawns the overlay.
/// Routes mouse-wheel events into the Leaderboard modal's scrollable
/// data body while the panel is open. No-op when no
/// `LeaderboardScrollable` exists in the world (modal closed). Mirrors
/// `scroll_settings_panel`.
fn scroll_leaderboard_panel(
mut scroll_evr: MessageReader<MouseWheel>,
mut scrollables: Query<&mut ScrollPosition, With<LeaderboardScrollable>>,
) {
if scrollables.is_empty() {
scroll_evr.clear();
return;
}
let delta_y: f32 = scroll_evr
.read()
.map(|ev| match ev.unit {
MouseScrollUnit::Line => ev.y * 50.0,
MouseScrollUnit::Pixel => ev.y,
})
.sum();
if delta_y == 0.0 {
return;
}
for mut sp in scrollables.iter_mut() {
sp.0.y = (sp.0.y - delta_y).max(0.0);
}
}
fn handle_leaderboard_close_button( fn handle_leaderboard_close_button(
mut commands: Commands, mut commands: Commands,
close_buttons: Query<&Interaction, (With<LeaderboardCloseButton>, Changed<Interaction>)>, close_buttons: Query<&Interaction, (With<LeaderboardCloseButton>, Changed<Interaction>)>,
@@ -346,7 +393,7 @@ fn spawn_leaderboard_screen(
remote_available: bool, remote_available: bool,
font_res: Option<&FontResource>, font_res: Option<&FontResource>,
) { ) {
spawn_modal(commands, LeaderboardScreen, Z_MODAL_PANEL, |card| { let scrim = spawn_modal(commands, LeaderboardScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Leaderboard", font_res); spawn_modal_header(card, "Leaderboard", font_res);
// Subhead — what the screen does + what the buttons control. // Subhead — what the screen does + what the buttons control.
@@ -420,76 +467,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();
+1 -1
View File
@@ -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,
+31 -4
View File
@@ -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()) {
+282 -169
View File
@@ -4,6 +4,7 @@
//! summary in a single scrollable panel. Spawned on the first `P` keypress and //! summary in a single scrollable panel. Spawned on the first `P` keypress and
//! despawned on the second. //! despawned on the second.
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use chrono::{Duration, Local, NaiveDate}; use chrono::{Duration, Local, NaiveDate};
@@ -19,6 +20,7 @@ use crate::settings_plugin::SettingsResource;
use crate::stats_plugin::{format_fastest_win, format_win_rate, StatsResource}; use crate::stats_plugin::{format_fastest_win, format_win_rate, StatsResource};
use crate::ui_modal::{ use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, SPACE_1, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY, ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, SPACE_1, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY,
@@ -60,10 +62,60 @@ pub struct ProfilePlugin;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct ProfileCloseButton; pub struct ProfileCloseButton;
/// Marker on the scrollable body Node inside the Profile modal.
///
/// The Profile panel renders sync info, progression (incl. 14-day
/// calendar), every unlocked achievement (up to ~18), and a stats
/// summary, which can overflow the modal on the 800x600 minimum window
/// once a player has unlocked several achievements. This marker tags
/// the inner container that carries `Overflow::scroll_y()` plus a
/// `max_height` constraint. Mirrors the `SettingsPanelScrollable`
/// pattern.
#[derive(Component, Debug)]
pub struct ProfileScrollable;
impl Plugin for ProfilePlugin { impl Plugin for ProfilePlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_message::<ToggleProfileRequestEvent>() app.add_message::<ToggleProfileRequestEvent>()
.add_systems(Update, (toggle_profile_screen, handle_profile_close_button)); // `MouseWheel` is emitted by Bevy's input plugin under
// `DefaultPlugins`; register it explicitly so the
// profile-scroll system also runs cleanly under
// `MinimalPlugins` in tests.
.add_message::<MouseWheel>()
.add_systems(
Update,
(
toggle_profile_screen,
handle_profile_close_button,
scroll_profile_panel,
),
);
}
}
/// Routes mouse-wheel events into the Profile modal's scrollable body
/// while the panel is open. No-op when no `ProfileScrollable` exists in
/// the world (modal closed). Mirrors `scroll_settings_panel`.
fn scroll_profile_panel(
mut scroll_evr: MessageReader<MouseWheel>,
mut scrollables: Query<&mut ScrollPosition, With<ProfileScrollable>>,
) {
if scrollables.is_empty() {
scroll_evr.clear();
return;
}
let delta_y: f32 = scroll_evr
.read()
.map(|ev| match ev.unit {
MouseScrollUnit::Line => ev.y * 50.0,
MouseScrollUnit::Pixel => ev.y,
})
.sum();
if delta_y == 0.0 {
return;
}
for mut sp in scrollables.iter_mut() {
sp.0.y = (sp.0.y - delta_y).max(0.0);
} }
} }
@@ -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();
+154 -3
View File
@@ -45,11 +45,34 @@ use solitaire_data::{Replay, ReplayMove};
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent}; use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent};
use crate::game_plugin::{GameMutation, RecordingReplay}; use crate::game_plugin::{GameMutation, RecordingReplay};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::settings_plugin::SettingsResource;
/// Per-move duration during playback. Tunable in Settings later; /// Default per-move duration during playback, in seconds. Acts as the
/// hardcoded for v1. /// fallback when `SettingsResource` is absent — i.e. in headless test
/// fixtures that don't install [`crate::settings_plugin::SettingsPlugin`].
/// In production the live value is read from
/// [`solitaire_data::Settings::replay_move_interval_secs`] every frame
/// so Settings adjustments take effect on the next playback tick.
///
/// Kept in sync with `solitaire_data::settings::default_replay_move_interval_secs`
/// (the data crate cannot depend on this engine crate, so the constant
/// is duplicated). The
/// `settings_replay_move_interval_default_matches_engine_constant`
/// test in `solitaire_engine::settings_plugin` enforces equality.
pub const REPLAY_MOVE_INTERVAL_SECS: f32 = 0.45; pub const REPLAY_MOVE_INTERVAL_SECS: f32 = 0.45;
/// Helper: returns the live per-move replay interval. Reads
/// [`SettingsResource::replay_move_interval_secs`] when the resource is
/// installed, falling back to [`REPLAY_MOVE_INTERVAL_SECS`] otherwise.
/// Also clamps below by `f32::EPSILON` so a hand-edited 0.0 cannot
/// busy-loop the playback tick.
fn current_move_interval_secs(settings: Option<&SettingsResource>) -> f32 {
let raw = settings
.map(|s| s.0.replay_move_interval_secs)
.unwrap_or(REPLAY_MOVE_INTERVAL_SECS);
raw.max(f32::EPSILON)
}
/// How long the [`ReplayPlaybackState::Completed`] state lingers before /// How long the [`ReplayPlaybackState::Completed`] state lingers before
/// the auto-clear system transitions it back to /// the auto-clear system transitions it back to
/// [`ReplayPlaybackState::Inactive`]. Gives the overlay UI time to /// [`ReplayPlaybackState::Inactive`]. Gives the overlay UI time to
@@ -161,6 +184,12 @@ pub fn start_replay_playback(
let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode.clone(), replay.mode); let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode.clone(), replay.mode);
commands.insert_resource(GameStateResource(fresh)); commands.insert_resource(GameStateResource(fresh));
// Initial `secs_to_next` uses the constant rather than reading
// `SettingsResource` because this entry point takes `Commands` /
// `ResMut<ReplayPlaybackState>` only. The first-tick latency may
// therefore lag the configured interval by up to ~0.45 s on an
// unusually short setting; subsequent ticks read the live setting
// every frame via [`tick_replay_playback`].
**state = ReplayPlaybackState::Playing { **state = ReplayPlaybackState::Playing {
replay, replay,
cursor: 0, cursor: 0,
@@ -207,11 +236,13 @@ pub fn stop_replay_playback(
/// so the loop runs at most once per frame. /// so the loop runs at most once per frame.
fn tick_replay_playback( fn tick_replay_playback(
time: Res<Time>, time: Res<Time>,
settings: Option<Res<SettingsResource>>,
mut state: ResMut<ReplayPlaybackState>, mut state: ResMut<ReplayPlaybackState>,
mut moves_writer: MessageWriter<MoveRequestEvent>, mut moves_writer: MessageWriter<MoveRequestEvent>,
mut draws_writer: MessageWriter<DrawRequestEvent>, mut draws_writer: MessageWriter<DrawRequestEvent>,
) { ) {
let dt = time.delta_secs(); let dt = time.delta_secs();
let interval = current_move_interval_secs(settings.as_deref());
let mut transition_to_completed = false; let mut transition_to_completed = false;
if let ReplayPlaybackState::Playing { if let ReplayPlaybackState::Playing {
@@ -235,7 +266,7 @@ fn tick_replay_playback(
} }
} }
*cursor += 1; *cursor += 1;
*secs_to_next += REPLAY_MOVE_INTERVAL_SECS; *secs_to_next += interval;
} }
if *cursor >= replay.moves.len() { if *cursor >= replay.moves.len() {
@@ -679,4 +710,124 @@ mod tests {
"recording must not grow while playback is active", "recording must not grow while playback is active",
); );
} }
/// With `SettingsResource::replay_move_interval_secs` set to 0.10 s
/// (well below the 0.45 s default), playback over a fixed
/// wall-clock window must dispatch strictly more moves than the
/// same fixture would at the 0.45 s default. This is the
/// regression check that the tick reads from the live Settings
/// value rather than the hardcoded
/// [`REPLAY_MOVE_INTERVAL_SECS`] constant.
///
/// The follow-up assertion exercises the boundary condition: at
/// the 0.10 s/move setting, exactly six 0.10 s ticks must yield
/// fewer moves than six 0.20 s ticks (because the latter doubles
/// the per-update advance and pays off two intervals each tick).
#[test]
fn replay_playback_tick_uses_settings_interval() {
use solitaire_data::Settings;
#[derive(Resource, Default)]
struct CapturedDraws(usize);
fn collect_draws(
mut events: MessageReader<DrawRequestEvent>,
mut sink: ResMut<CapturedDraws>,
) {
for _ in events.read() {
sink.0 += 1;
}
}
// Long replay so the fast cadence has plenty of moves to
// chew through and the 0.45 s vs 0.10 s difference is easy
// to observe.
fn ten_draws_replay() -> Replay {
Replay::new(
7,
DrawMode::DrawOne,
GameMode::Classic,
10,
100,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![ReplayMove::StockClick; 10],
)
}
// ---- Run 1: 0.10 s/move (Settings override) ----
let mut fast_app = headless_app();
fast_app.insert_resource(SettingsResource(Settings {
replay_move_interval_secs: 0.10,
..Settings::default()
}));
fast_app
.init_resource::<CapturedDraws>()
.add_systems(Update, collect_draws);
start_playback(&mut fast_app, ten_draws_replay());
fast_app.update();
// 1.0 s of virtual time at 0.10 s/move dispatches ~5 moves
// after the default 0.45 s startup interval is consumed.
advance_by(&mut fast_app, 1.0);
let fast_count = fast_app.world().resource::<CapturedDraws>().0;
// ---- Run 2: 0.45 s/move (default — no SettingsResource) ----
let mut slow_app = headless_app();
// `tick_replay_playback` falls back to `REPLAY_MOVE_INTERVAL_SECS`
// (0.45 s) when `SettingsResource` is absent.
slow_app
.init_resource::<CapturedDraws>()
.add_systems(Update, collect_draws);
start_playback(&mut slow_app, ten_draws_replay());
slow_app.update();
advance_by(&mut slow_app, 1.0);
let slow_count = slow_app.world().resource::<CapturedDraws>().0;
assert!(
fast_count > slow_count,
"at 0.10 s/move the tick must dispatch strictly more moves \
than at the 0.45 s default over the same wall-clock window: \
fast={fast_count}, slow={slow_count}",
);
// ---- Boundary: a 0.05 s/tick cadence over the same window
// dispatches NO MORE moves than a 0.10 s/tick cadence, because
// 0.05 s < 0.10 s configured interval — the secs_to_next clock
// never crosses the threshold inside a single tick. ----
//
// We don't assert "exactly zero" because the leading update()
// after `start_playback` may run before the strategy is
// applied (cf. comments on `tick_advances_cursor_after_interval`),
// but the count must not exceed what we'd get with one-tick
// advances at the same total wall-clock window.
fn count_after_window(interval_secs: f32, tick_secs: f32, total_secs: f32) -> usize {
let mut app = headless_app();
app.insert_resource(SettingsResource(Settings {
replay_move_interval_secs: interval_secs,
..Settings::default()
}));
app.init_resource::<CapturedDraws>()
.add_systems(Update, collect_draws);
start_playback(&mut app, ten_draws_replay());
app.update();
app.insert_resource(TimeUpdateStrategy::ManualDuration(
Duration::from_secs_f32(tick_secs),
));
let ticks = (total_secs / tick_secs).ceil() as usize + 1;
for _ in 0..ticks {
app.update();
}
app.world().resource::<CapturedDraws>().0
}
let count_at_05 = count_after_window(0.10, 0.05, 1.0);
let count_at_20 = count_after_window(0.10, 0.20, 1.0);
assert!(
count_at_05 <= count_at_20,
"0.05 s ticks (strictly less than the 0.10 s interval) must \
dispatch no more moves than 0.20 s ticks over the same \
wall-clock window: count_at_05={count_at_05}, count_at_20={count_at_20}",
);
}
} }
+252 -51
View File
@@ -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);
});
}); });
} }
+322 -148
View File
@@ -8,6 +8,7 @@
use std::path::PathBuf; use std::path::PathBuf;
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_data::{ use solitaire_data::{
@@ -28,6 +29,7 @@ use crate::resources::GameStateResource;
use crate::time_attack_plugin::TimeAttackResource; use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_modal::{ use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, STATE_WARNING, STREAK_MILESTONES, ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, STATE_WARNING, STREAK_MILESTONES,
@@ -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`]
+49 -11
View File
@@ -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;
}
} }
} }
+5 -1
View File
@@ -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();
+161 -1
View File
@@ -121,11 +121,34 @@ impl Plugin for UiFocusPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<FocusedButton>() app.init_resource::<FocusedButton>()
.add_systems(Startup, spawn_focus_overlay) .add_systems(Startup, spawn_focus_overlay)
// Attach + auto-focus run in `PostUpdate` so they see entities
// a click-handler in `Update` queued via `Commands` earlier in
// the same frame. If they ran in `Update` they'd race the
// click handler: there's no ordering edge between an arbitrary
// modal-spawning system and the focus chain, so Bevy's
// `auto_insert_apply_deferred` pass cannot synchronise them.
// Pushing the attach / auto-focus pair into `PostUpdate` puts
// the natural schedule-boundary sync point between every
// modal spawn and focus arrival — `FocusedButton` is always
// populated before the same `app.update()` returns.
//
// The remaining systems stay in `Update` so they keep
// observing input on the frame it occurs. They read
// `FocusedButton` written during the *previous* tick's
// `PostUpdate`, which is exactly what we want: the very next
// user keypress after a modal opens lands on a populated
// resource.
.add_systems( .add_systems(
Update, PostUpdate,
( (
attach_focusable_to_modal_buttons, attach_focusable_to_modal_buttons,
auto_focus_on_modal_open, auto_focus_on_modal_open,
)
.chain(),
)
.add_systems(
Update,
(
sync_focus_on_mouse_click, sync_focus_on_mouse_click,
clear_hud_focus_on_unhover, clear_hud_focus_on_unhover,
handle_focus_keys, handle_focus_keys,
@@ -827,6 +850,143 @@ mod tests {
assert_eq!(focused, Some(a), "Primary button A should auto-focus"); assert_eq!(focused, Some(a), "Primary button A should auto-focus");
} }
/// One-shot trigger resource consumed by the production-shaped test
/// system [`spawn_modal_via_system`]. When set to `true`, the system
/// queues a `spawn_modal` call on the next `Update` and clears the
/// flag. Mirrors the real production flow where a click-handler
/// system queues the modal spawn via `Commands` rather than the
/// test fixture using `world.flush()` ahead of time.
#[derive(Resource, Default)]
struct SpawnModalTrigger(bool);
/// Production-shaped modal spawner: a regular Bevy `System` that
/// reads a trigger flag and queues a 2-button modal via `Commands`.
/// Crucially this system has **no** ordering relationship with
/// `UiFocusPlugin`'s chain — exactly the situation that surfaces the
/// "focus arrives one frame late" bug in production.
fn spawn_modal_via_system(
mut commands: Commands,
mut trigger: ResMut<SpawnModalTrigger>,
) {
if !trigger.0 {
return;
}
trigger.0 = false;
spawn_modal(&mut commands, TestModal, 0, |card| {
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
TestButtonB,
"B",
None,
ButtonVariant::Secondary,
None,
);
spawn_modal_button(
actions,
TestButtonA,
"A",
None,
ButtonVariant::Primary,
None,
);
});
});
}
/// Same-frame-focus contract: when a modal is spawned by an
/// independent system during the same `Update` as the focus chain,
/// `FocusedButton` must be populated with the primary button by the
/// time `handle_focus_keys` runs in that **same** update — so a Tab
/// pressed in the very next tick advances focus rather than
/// landing on "nothing focused → primary".
#[test]
fn primary_button_is_focused_on_modal_spawn_same_frame() {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<SpawnModalTrigger>();
// Register the production-shaped spawn system in `Update` with
// no chain relationship to `UiFocusPlugin`.
app.add_systems(Update, spawn_modal_via_system);
// Initial Startup pass.
app.update();
// Trigger the spawn and run exactly ONE update — the same
// `Update` cycle that the focus chain runs in. By the end of
// this update, `FocusedButton` must already point at the
// primary button.
app.world_mut().resource_mut::<SpawnModalTrigger>().0 = true;
app.update();
let primary = app
.world_mut()
.query_filtered::<Entity, With<TestButtonA>>()
.iter(app.world())
.next()
.expect("Primary button should exist after the spawn update");
assert_eq!(
app.world().resource::<FocusedButton>().0,
Some(primary),
"FocusedButton must be populated with the primary on the same frame the modal spawns"
);
}
/// Tab pressed on the very next tick after a modal opens must
/// advance focus from the primary to the secondary — not from
/// "nothing focused" to the primary. The latter would mean focus
/// arrived a frame late and Tab was wasted on first-focus instead
/// of advancing.
#[test]
fn first_tab_after_modal_open_advances_to_secondary() {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<SpawnModalTrigger>();
app.add_systems(Update, spawn_modal_via_system);
app.update();
// Spawn the modal in update N.
app.world_mut().resource_mut::<SpawnModalTrigger>().0 = true;
app.update();
// Press Tab on update N+1. If focus arrived correctly in N,
// Tab advances primary → secondary. If focus arrived late,
// Tab promotes "no focus" to primary (the bug).
let primary = app
.world_mut()
.query_filtered::<Entity, With<TestButtonA>>()
.iter(app.world())
.next()
.expect("primary spawned");
let secondary = app
.world_mut()
.query_filtered::<Entity, With<TestButtonB>>()
.iter(app.world())
.next()
.expect("secondary spawned");
press_key(&mut app, KeyCode::Tab);
app.update();
let focused_after_tab = app.world().resource::<FocusedButton>().0;
assert_ne!(
focused_after_tab,
Some(primary),
"first Tab after modal open should advance off the primary, not land on it (focus arrived late)"
);
assert_eq!(
focused_after_tab,
Some(secondary),
"first Tab from primary should land on the secondary"
);
}
#[test] #[test]
fn tab_advances_focus_in_spawn_order() { fn tab_advances_focus_in_spawn_order() {
let mut app = headless_app(); let mut app = headless_app();
+323
View File
@@ -49,6 +49,8 @@
//! ``` //! ```
use bevy::prelude::*; use bevy::prelude::*;
use bevy::ui::{ComputedNode, UiGlobalTransform};
use bevy::window::PrimaryWindow;
use solitaire_data::AnimSpeed; use solitaire_data::AnimSpeed;
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
@@ -74,6 +76,19 @@ pub struct ModalScrim;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct ModalCard; pub struct ModalCard;
/// Marker on a [`ModalScrim`] entity opting that modal into the
/// click-outside-to-dismiss behaviour.
///
/// When attached, [`dismiss_modal_on_scrim_click`] despawns the scrim
/// (and its hierarchy) on a left mouse press whose cursor falls on the
/// scrim and outside every [`ModalCard`]. Modals with destructive
/// actions or unsaved state (Settings, Onboarding, Pause, Forfeit
/// confirmation, Confirm New Game, etc.) intentionally do not opt in
/// — those require an explicit Cancel / Done / Confirm so an
/// accidental scrim click cannot lose work.
#[derive(Component, Debug, Clone, Copy)]
pub struct ScrimDismissible;
/// Marker on a header `Text` (`TYPE_HEADLINE` + `TEXT_PRIMARY`). /// Marker on a header `Text` (`TYPE_HEADLINE` + `TEXT_PRIMARY`).
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct ModalHeader; pub struct ModalHeader;
@@ -474,6 +489,89 @@ pub fn advance_modal_enter(
} }
} }
// ---------------------------------------------------------------------------
// Click-outside-to-dismiss
// ---------------------------------------------------------------------------
/// Returns `true` when the cursor at `cursor_logical` falls inside the
/// axis-aligned rectangle described by `centre_logical` (rectangle
/// centre, logical pixels) and `size_logical` (full width × height,
/// logical pixels).
///
/// Pure helper extracted from [`dismiss_modal_on_scrim_click`] so the
/// hit-test decision can be tested without a real `Window` /
/// rendered UI tree.
#[inline]
fn cursor_is_inside_rect(cursor_logical: Vec2, centre_logical: Vec2, size_logical: Vec2) -> bool {
let half = size_logical * 0.5;
cursor_logical.x >= centre_logical.x - half.x
&& cursor_logical.x <= centre_logical.x + half.x
&& cursor_logical.y >= centre_logical.y - half.y
&& cursor_logical.y <= centre_logical.y + half.y
}
/// Despawns the topmost [`ScrimDismissible`] modal when the player
/// presses the left mouse button while the cursor is over the scrim
/// AND outside every [`ModalCard`]. Modals without the marker are
/// untouched, and existing dismiss paths (Cancel / Done / Esc /
/// dedicated buttons) keep working unchanged.
///
/// **Topmost-only.** Stacked dismissible modals would otherwise all
/// dismiss together on a single click. The system processes at most
/// one entity per frame: the first match in the query is taken,
/// matching the click-handler convention used elsewhere in the engine.
/// Spawn order is the practical tiebreaker — dismissible modals are
/// rarely stacked, so picking any one is acceptable.
///
/// **No same-frame dismissal.** `just_pressed` is true only on the
/// frame the button transitions to pressed. The press that *opens* a
/// modal happens on one frame; this system fires on a subsequent
/// press, so a modal can never be opened and dismissed in a single
/// click.
///
/// `cards`/`scrims` queries read [`UiGlobalTransform`] (window-space
/// physical pixels) and [`ComputedNode`] (size in physical pixels);
/// both are converted to logical pixels via
/// `ComputedNode::inverse_scale_factor` so they can be compared with
/// the cursor position from `Window::cursor_position` (logical px).
#[allow(clippy::type_complexity)]
pub fn dismiss_modal_on_scrim_click(
mut commands: Commands,
mouse: Option<Res<ButtonInput<MouseButton>>>,
windows: Query<&Window, With<PrimaryWindow>>,
scrims: Query<Entity, (With<ModalScrim>, With<ScrimDismissible>)>,
cards: Query<(&UiGlobalTransform, &ComputedNode), With<ModalCard>>,
) {
let Some(mouse) = mouse else { return };
if !mouse.just_pressed(MouseButton::Left) {
return;
}
let Ok(window) = windows.single() else {
return;
};
let Some(cursor) = window.cursor_position() else {
return;
};
// Topmost-only: bail after the first dismissible scrim. Stacked
// dismissible modals are not currently a real case, but this guard
// keeps the behaviour predictable if they ever arise.
let Some(scrim_entity) = scrims.iter().next() else {
return;
};
let cursor_over_card = cards.iter().any(|(transform, computed)| {
let inv = computed.inverse_scale_factor;
let size_logical = computed.size() * inv;
let centre_logical = transform.translation * inv;
cursor_is_inside_rect(cursor, centre_logical, size_logical)
});
if !cursor_over_card {
commands.entity(scrim_entity).despawn();
}
}
/// Repaints every `ModalButton` on `Changed<Interaction>` so hover and /// Repaints every `ModalButton` on `Changed<Interaction>` so hover and
/// press states are visible without each overlay registering its own /// press states are visible without each overlay registering its own
/// paint system. /// paint system.
@@ -515,6 +613,12 @@ impl Plugin for UiModalPlugin {
Update, Update,
(apply_modal_enter_speed, advance_modal_enter, paint_modal_buttons).chain(), (apply_modal_enter_speed, advance_modal_enter, paint_modal_buttons).chain(),
); );
// Click-outside-to-dismiss is independent of the open
// animation chain — it reads `just_pressed(Left)` and runs
// every tick. `just_pressed` is true only on the frame the
// button transitions to pressed, so the press that *opens* a
// modal cannot dismiss the same modal on the next frame.
app.add_systems(Update, dismiss_modal_on_scrim_click);
} }
} }
@@ -668,5 +772,224 @@ mod tests {
Duration::from_secs_f32(secs), Duration::from_secs_f32(secs),
)); ));
} }
// -----------------------------------------------------------------------
// Click-outside-to-dismiss
// -----------------------------------------------------------------------
/// Pure-helper hit-test: cursor inside the rectangle returns true.
#[test]
fn cursor_is_inside_rect_inside_returns_true() {
// 100×60 rectangle centred at (200, 150).
let centre = Vec2::new(200.0, 150.0);
let size = Vec2::new(100.0, 60.0);
// Centre + a few corners just inside.
assert!(cursor_is_inside_rect(centre, centre, size));
assert!(cursor_is_inside_rect(Vec2::new(151.0, 121.0), centre, size));
assert!(cursor_is_inside_rect(Vec2::new(249.0, 179.0), centre, size));
}
/// Pure-helper hit-test: cursor outside the rectangle returns false
/// on every side.
#[test]
fn cursor_is_inside_rect_outside_returns_false() {
let centre = Vec2::new(200.0, 150.0);
let size = Vec2::new(100.0, 60.0);
assert!(!cursor_is_inside_rect(Vec2::new(149.0, 150.0), centre, size)); // left
assert!(!cursor_is_inside_rect(Vec2::new(251.0, 150.0), centre, size)); // right
assert!(!cursor_is_inside_rect(Vec2::new(200.0, 119.0), centre, size)); // above
assert!(!cursor_is_inside_rect(Vec2::new(200.0, 181.0), centre, size)); // below
}
/// Builds a headless app capable of running
/// `dismiss_modal_on_scrim_click`: registers the plugin, primes the
/// `ButtonInput<MouseButton>` resource that `MinimalPlugins`
/// doesn't provide, and spawns a synthetic `PrimaryWindow`.
fn dismiss_test_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(UiModalPlugin);
app.init_resource::<ButtonInput<MouseButton>>();
// Synthetic primary window. `MinimalPlugins` doesn't ship
// `WindowPlugin`, so spawning the entity by hand is fine —
// `dismiss_modal_on_scrim_click` only reads `cursor_position`
// off it, not any platform-backed state.
app.world_mut().spawn((
Window {
resolution: bevy::window::WindowResolution::new(800, 600),
..default()
},
PrimaryWindow,
));
app
}
/// Marker for synthetic-modal tests below.
#[derive(Component, Debug)]
struct DismissTestModal;
/// Spawns a synthetic scrim + card pair pre-populated with
/// `ComputedNode` + `UiGlobalTransform` so the dismiss system has
/// real geometry to hit-test against without running the full UI
/// layout pipeline. `card_centre` and `card_size` are in physical
/// pixels (matching `ComputedNode.size`); the synthetic
/// `inverse_scale_factor` is 1.0 so logical == physical.
fn spawn_synthetic_modal(
app: &mut App,
dismissible: bool,
card_centre: Vec2,
card_size: Vec2,
) -> Entity {
let world = app.world_mut();
let mut scrim = world.spawn((DismissTestModal, ModalScrim));
if dismissible {
scrim.insert(ScrimDismissible);
}
let scrim_entity = scrim.id();
let card_entity = world
.spawn((
ModalCard,
{
let mut node = ComputedNode {
stack_index: 0,
size: card_size,
content_size: card_size,
scrollbar_size: Vec2::ZERO,
scroll_position: Vec2::ZERO,
outline_width: 0.0,
outline_offset: 0.0,
unrounded_size: card_size,
border: bevy::sprite::BorderRect::default(),
border_radius: bevy::ui::ResolvedBorderRadius::default(),
padding: bevy::sprite::BorderRect::default(),
inverse_scale_factor: 1.0,
};
// `is_empty` guard inside Bevy treats zero-size
// nodes as inert; we always pass a non-zero size.
node.size = card_size;
node
},
UiGlobalTransform::from_translation(card_centre),
))
.id();
// Parent the card to the scrim so a `commands.entity(scrim).despawn()`
// also takes the card down — matching the real `spawn_modal` hierarchy.
world.entity_mut(scrim_entity).add_child(card_entity);
scrim_entity
}
/// Sets the synthetic primary window's cursor position (logical px,
/// since we use `inverse_scale_factor = 1.0` everywhere in tests).
fn set_cursor(app: &mut App, position: Option<Vec2>) {
let world = app.world_mut();
let mut q = world.query_filtered::<&mut Window, With<PrimaryWindow>>();
let mut window = q.single_mut(world).expect("primary window");
window.set_cursor_position(position);
}
/// Drives a fresh `just_pressed(Left)` for the next `app.update()`.
/// `MinimalPlugins` doesn't run the input clear pass, so we mark
/// the clear by hand on the resource between presses.
fn press_left_mouse(app: &mut App) {
let mut input = app
.world_mut()
.resource_mut::<ButtonInput<MouseButton>>();
input.clear();
input.press(MouseButton::Left);
}
/// Click outside the card on a dismissible modal despawns it.
#[test]
fn dismissible_scrim_despawns_on_scrim_click_outside_card() {
let mut app = dismiss_test_app();
let scrim = spawn_synthetic_modal(
&mut app,
/* dismissible: */ true,
Vec2::new(400.0, 300.0),
Vec2::new(200.0, 100.0),
);
// Cursor far outside the card — top-left corner of the window.
set_cursor(&mut app, Some(Vec2::new(50.0, 50.0)));
press_left_mouse(&mut app);
app.update();
assert!(
app.world().get_entity(scrim).is_err(),
"dismissible scrim should be despawned on a scrim-area click"
);
}
/// Click *inside* the card area must NOT dismiss the modal — the
/// player intends to interact with the card content.
#[test]
fn dismissible_scrim_does_not_despawn_on_card_click() {
let mut app = dismiss_test_app();
let scrim = spawn_synthetic_modal(
&mut app,
/* dismissible: */ true,
Vec2::new(400.0, 300.0),
Vec2::new(200.0, 100.0),
);
// Cursor at the card centre — definitely inside.
set_cursor(&mut app, Some(Vec2::new(400.0, 300.0)));
press_left_mouse(&mut app);
app.update();
assert!(
app.world().get_entity(scrim).is_ok(),
"click inside the card must not dismiss the modal"
);
}
/// Modals without `ScrimDismissible` ignore scrim clicks entirely.
/// Settings, Onboarding, Pause, etc. rely on this opt-out.
#[test]
fn non_dismissible_scrim_does_not_despawn_on_scrim_click() {
let mut app = dismiss_test_app();
let scrim = spawn_synthetic_modal(
&mut app,
/* dismissible: */ false,
Vec2::new(400.0, 300.0),
Vec2::new(200.0, 100.0),
);
set_cursor(&mut app, Some(Vec2::new(50.0, 50.0)));
press_left_mouse(&mut app);
app.update();
assert!(
app.world().get_entity(scrim).is_ok(),
"non-dismissible scrim must survive a scrim-area click"
);
}
/// Stacked dismissible modals: one click despawns at most one
/// modal per frame (the one the query yields first). The other
/// stays put until the next press.
#[test]
fn stacked_modals_dismiss_at_most_one_per_click() {
let mut app = dismiss_test_app();
let a = spawn_synthetic_modal(
&mut app,
/* dismissible: */ true,
Vec2::new(400.0, 300.0),
Vec2::new(200.0, 100.0),
);
let b = spawn_synthetic_modal(
&mut app,
/* dismissible: */ true,
Vec2::new(400.0, 300.0),
Vec2::new(200.0, 100.0),
);
// Cursor outside both cards.
set_cursor(&mut app, Some(Vec2::new(50.0, 50.0)));
press_left_mouse(&mut app);
app.update();
let a_alive = app.world().get_entity(a).is_ok();
let b_alive = app.world().get_entity(b).is_ok();
assert!(
a_alive ^ b_alive,
"exactly one of the two stacked dismissible modals should remain"
);
}
} }
+30 -1
View File
@@ -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),
)); ));