Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da3e5423dc | |||
| a1864271de | |||
| f63db769ae | |||
| 4437a1aaf9 | |||
| e7345aed6c | |||
| 140251beae | |||
| d6f32d3154 | |||
| 8fdc41f36f | |||
| 2e25476d0a | |||
| d3cb1a51d4 | |||
| c8358f4275 | |||
| a2432dfe7a | |||
| 511550232c | |||
| e5c4f51a6e | |||
| 23902cdc44 | |||
| 3cc8eacafa | |||
| 90e24d9711 | |||
| decbe0bbd9 | |||
| 1873b3f9be | |||
| d11d97e677 | |||
| d322abf67b | |||
| c9e4c0b4cd | |||
| fe68861e10 | |||
| c33b39cf11 | |||
| 23ff62c397 | |||
| 0b2ffca016 | |||
| fbe48acef6 | |||
| cd79877933 | |||
| 52befa6199 | |||
| e63046700c | |||
| ab857bbb6e | |||
| 886e0cf8a1 | |||
| 3d92a91e3b | |||
| 9113cdb483 | |||
| c153363626 | |||
| 93b67f1d0b | |||
| 279e23d0af | |||
| 12fba2157a |
+481
-1
@@ -6,9 +6,489 @@ project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
No threads in flight. v0.21.2 cut on 2026-05-08; CHANGELOG accumulates
|
No threads in flight. v0.21.6 cut on 2026-05-08; CHANGELOG accumulates
|
||||||
the next cycle here.
|
the next cycle here.
|
||||||
|
|
||||||
|
## [0.21.6] — 2026-05-08
|
||||||
|
|
||||||
|
Patch release for the post-v0.21.5 work. Through-line:
|
||||||
|
**Move Log panel + scrub-UX polish**. v0.21.5 closed out the
|
||||||
|
keyboard-accelerator surface (Space / Esc / ← / →) and the
|
||||||
|
keybind footer; v0.21.6 builds on that with two parallel
|
||||||
|
threads — accessibility + scrub-on-hold polish for the v0.21.5
|
||||||
|
surfaces, plus a brand-new Move Log panel anchored to the
|
||||||
|
viewport's bottom edge that gives players a 5-row recent-and-
|
||||||
|
upcoming move history alongside the existing top-edge banner.
|
||||||
|
|
||||||
|
The Move Log panel is the first replay-overlay surface that
|
||||||
|
*isn't* attached to the banner — it lives at a separate screen
|
||||||
|
anchor (bottom: 0) with its own spawn/despawn lifecycle.
|
||||||
|
Establishes the pattern for "multi-anchor replay UI" that the
|
||||||
|
remaining B-2 sub-piece (mini-tableau preview) will inherit.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **HC-mode coverage for the scrub track + quarter-mark notch
|
||||||
|
ticks** (`d3cb1a5`). Adds parallel primitive
|
||||||
|
`HighContrastBackground` to `ui_theme` and a paint system
|
||||||
|
`update_high_contrast_backgrounds` in `settings_plugin` that
|
||||||
|
mirrors the existing border-marker pattern but targets
|
||||||
|
`BackgroundColor` instead of `BorderColor`. Tags the 1 px
|
||||||
|
scrub track Node and all five quarter-mark notch ticks so
|
||||||
|
they bump from `BORDER_SUBTLE` (`#505050`) →
|
||||||
|
`BORDER_SUBTLE_HC` (`#a0a0a0`) under HC mode. Scrub fill
|
||||||
|
(`ACCENT_PRIMARY`) and WIN MOVE marker (`STATE_SUCCESS`)
|
||||||
|
don't get the marker — accent and state colours are already
|
||||||
|
saturated and don't need an HC luminance variant.
|
||||||
|
- **Continuous scrub on key-held arrow keys** (`2e25476`).
|
||||||
|
Holding ← or → triggers continuous step at 100 ms cadence
|
||||||
|
(10 steps/sec) — matches the mockup's `[← →] scrub`
|
||||||
|
terminology while keeping single-press = single-step
|
||||||
|
semantics. Per-key accumulators in a new
|
||||||
|
`ReplayScrubKeyHold` resource; `just_pressed` events bypass
|
||||||
|
the accumulator and fire immediately. Release resets to 0
|
||||||
|
so the next fresh press fires immediately rather than at
|
||||||
|
half-interval.
|
||||||
|
- **Move Log panel** (`d6f32d3` + `140251b` + `e7345ae` +
|
||||||
|
`4437a1a`). New bottom-edge UI panel showing a 5-row window
|
||||||
|
onto recent + upcoming moves: 2 prev rows above the active
|
||||||
|
row + active row highlighted in `ACCENT_PRIMARY` + 2 next
|
||||||
|
rows below. Header reads `▌ MOVE LOG · N/M` (or
|
||||||
|
`▌ MOVE LOG · COMPLETE` when finished). Active row carries
|
||||||
|
a `▶` focus prefix and `TEXT_PRIMARY_HC` text colour for
|
||||||
|
legible contrast against the brick-red highlight. Prev /
|
||||||
|
next rows render in `TEXT_SECONDARY` so the active row
|
||||||
|
stays the focal point.
|
||||||
|
- Sibling-of-banner pattern (separate root entity anchored
|
||||||
|
at viewport bottom, not a banner child) — same
|
||||||
|
spawn/despawn lifecycle as `ReplayFloatingProgressChip`,
|
||||||
|
different screen anchor.
|
||||||
|
- Five pure helpers handle the formatting:
|
||||||
|
`format_pile`, `format_move_body`,
|
||||||
|
`format_move_log_header`, `format_kth_recent_row` (active
|
||||||
|
+ prev), `format_kth_next_row` (next). 1-indexed display
|
||||||
|
numbers throughout (`Foundation(2)` reads as "foundation
|
||||||
|
3" rather than the enum's 0-index).
|
||||||
|
- Panel grows from 56 → 84 → 112 px across the four
|
||||||
|
move-log commits. `MOVE_LOG_PREV_ROWS` and
|
||||||
|
`MOVE_LOG_NEXT_ROWS` constants (both = 2) parameterise
|
||||||
|
the row count; `format_kth_recent_row` and
|
||||||
|
`format_kth_next_row` return empty for out-of-range k so
|
||||||
|
panels gracefully under-fill at the start (cursor=1) and
|
||||||
|
end (cursor=N-1) of a replay.
|
||||||
|
- HC marker on the panel's top border so the 1 px edge
|
||||||
|
bumps under HC mode (same pattern as the keybind footer).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`react_to_state_change` despawns the Move Log panel** on
|
||||||
|
`Playing → Inactive` alongside the banner root and floating
|
||||||
|
progress chip. Third query in the same defer-and-despawn
|
||||||
|
cycle.
|
||||||
|
- **Move Log panel height grew 56 → 84 → 112 px** across the
|
||||||
|
prev-rows and next-rows commits. The panel is sized to fit
|
||||||
|
the chosen row count + header + padding; tunable via the
|
||||||
|
`MOVE_LOG_PANEL_HEIGHT` const.
|
||||||
|
- **`format_active_move_row` now prefixes the `▶` focus
|
||||||
|
marker** (`e7345ae`). Wraps `format_kth_recent_row(state, 1)`
|
||||||
|
and prepends the prefix when the body is non-empty. Empty
|
||||||
|
case still returns empty — cursor=0 doesn't paint a stray
|
||||||
|
`▶` on an otherwise-empty row.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- `SESSION_HANDOFF.md` refreshed twice this cycle — once
|
||||||
|
recording the HC paint + continuous-scrub polish, then
|
||||||
|
again as the Move Log arc shipped commit-by-commit. The
|
||||||
|
Resume menu's B option now traces the full arc:
|
||||||
|
notches → labels → footer → ESC → HC → arrow keys →
|
||||||
|
HC paint → continuous scrub → move log.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- **1273 passing tests / 0 failing** across the workspace
|
||||||
|
(net +23 from v0.21.5's 1250 baseline):
|
||||||
|
- 2 from `d3cb1a5` (HC marker on track + notches).
|
||||||
|
- 2 from `2e25476` (continuous-scrub repeat-while-held +
|
||||||
|
release-resets-accumulator).
|
||||||
|
- 8 from `d6f32d3` (move-log panel init + 5 helpers + 3
|
||||||
|
spawn / lifecycle scenarios).
|
||||||
|
- 4 from `140251b` (prev rows: helper k coverage + spawn
|
||||||
|
cardinality + spawn texts + repaint on cursor advance).
|
||||||
|
- 3 from `e7345ae` (active row highlight: wrapper bg +
|
||||||
|
text colour + focus prefix + cursor=0 stays empty).
|
||||||
|
- 4 from `4437a1a` (next rows: helper k coverage + spawn
|
||||||
|
cardinality + spawn texts + under-fill at replay end).
|
||||||
|
- Clippy clean across the workspace.
|
||||||
|
|
||||||
|
## [0.21.5] — 2026-05-08
|
||||||
|
|
||||||
|
Patch release for the post-v0.21.4 work. One through-line:
|
||||||
|
**replay-overlay scrubbing affordances + accessibility**. v0.21.4
|
||||||
|
shipped pause / resume / step + the WIN MOVE marker as the first
|
||||||
|
*scrubbing-shaped* additions to the replay overlay; v0.21.5
|
||||||
|
fills out the rest of the scrubbing UX so the player has both
|
||||||
|
visual anchor points (notches + labels) and a complete keyboard
|
||||||
|
control surface (Space / Esc / ← / →) for navigating a paused
|
||||||
|
replay.
|
||||||
|
|
||||||
|
Two of the six commits in this cycle are layout-changing — they
|
||||||
|
grow the banner height from 60 px → 76 px → 92 px to make room
|
||||||
|
for the notch labels and keybind footer. Banner geometry was
|
||||||
|
fixed for every prior B-2 commit; this release establishes the
|
||||||
|
"grow the container, add a flex-column child" pattern that the
|
||||||
|
remaining B-2 sub-pieces (move-log scroller, mini-tableau
|
||||||
|
preview) will inherit when they land.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Quarter-mark scrub-bar notches** (`fe68861`). Five 1 px
|
||||||
|
vertical ticks at 0 / 25 / 50 / 75 / 100 % give the player
|
||||||
|
visual anchor points without needing to mentally bisect the
|
||||||
|
bar. Pure helper `scrub_notch_positions()` returns the fixed
|
||||||
|
array; spawn loop sits next to the WIN MOVE marker spawn so
|
||||||
|
the lifecycles match. Notches paint in `BORDER_SUBTLE` (same
|
||||||
|
as the unfilled track) and rely on extending past the 1 px
|
||||||
|
track (5 px tall, anchored 2 px above the track top) for
|
||||||
|
visibility — same trick the WIN MOVE marker uses. Spawned
|
||||||
|
*after* the WIN MOVE marker so a notch and the marker
|
||||||
|
landing on the same percentage paint the marker on top.
|
||||||
|
- **Percentage labels under each notch** (`d322abf`). Five
|
||||||
|
`0%` / `25%` / `50%` / `75%` / `100%` labels in a new 16 px
|
||||||
|
row beneath the 1 px scrub track give the player explicit
|
||||||
|
quarter-mark readouts. Banner grew from 60 → 76 px to
|
||||||
|
accommodate the row — first **layout-changing** commit in
|
||||||
|
the B-2 arc. Pure helper `scrub_notch_labels()` returns the
|
||||||
|
fixed array, paired index-for-index with
|
||||||
|
`scrub_notch_positions()`. Spawn loop applies an "endpoints
|
||||||
|
flush, middle three percent-anchored" positioning pattern:
|
||||||
|
leftmost label gets `left: 0`, rightmost gets `right: 0`,
|
||||||
|
middle three anchor at `left: Val::Percent(p)` since Bevy
|
||||||
|
0.18 UI lacks a clean CSS-style `translate-x: -50%`
|
||||||
|
centering primitive. Label colour is `TEXT_SECONDARY`
|
||||||
|
rather than the mockup's `BORDER_SUBTLE` (the latter would
|
||||||
|
match the notches but is too low-contrast against
|
||||||
|
`BG_ELEVATED_HI` to read at 12 px).
|
||||||
|
- **Keybind-hint footer** (`1873b3f`). Vim-style mode line on
|
||||||
|
the left (`▌ NORMAL │ replay`) plus a keybind hint on the
|
||||||
|
right at the bottom edge of the banner. Banner grew from
|
||||||
|
76 → 92 px to fit the 16 px footer row. Surfaces every
|
||||||
|
wired keyboard accelerator visually so CLAUDE.md §3.3's
|
||||||
|
UI-first contract holds for keyboard accelerators too. The
|
||||||
|
footer lists *only* keybinds that are actually wired —
|
||||||
|
the only-wired-keybinds discipline means each release
|
||||||
|
cycle's hint string is a precise honest contract with the
|
||||||
|
player. Two pure helpers (`keybind_footer_mode_text`,
|
||||||
|
`keybind_footer_hint_text`) keep the static text testable.
|
||||||
|
1 px top border in `BORDER_SUBTLE` separates the footer
|
||||||
|
from the labels row.
|
||||||
|
- **ESC keyboard accelerator for replay-stop** (`90e24d9`).
|
||||||
|
New `handle_stop_keyboard` system parallels
|
||||||
|
`handle_pause_keyboard` in shape — fires only when state
|
||||||
|
is `Playing`, calls `stop_replay_playback`. Cross-plugin
|
||||||
|
coordination via `pause_plugin::toggle_pause`: added a
|
||||||
|
fourth defer-if check
|
||||||
|
(`replay_state.is_some_and(|s| s.is_playing())`) right
|
||||||
|
after the existing `other_modal_scrims` check so ESC
|
||||||
|
during active replay belongs to the replay overlay, not
|
||||||
|
the pause modal.
|
||||||
|
- **HC-mode coverage for the keybind-footer top border**
|
||||||
|
(`23902cd`).
|
||||||
|
`HighContrastBorder::with_default(BORDER_SUBTLE)` marker
|
||||||
|
on the footer's border-carrying Node so the existing
|
||||||
|
`apply_high_contrast_borders` system bumps the 1 px top
|
||||||
|
border from `#505050` → `#a0a0a0` when
|
||||||
|
`Settings::high_contrast_mode` is on. Without the marker
|
||||||
|
the footer reads as floating loose under HC because the
|
||||||
|
border that anchors it to the labels row is
|
||||||
|
near-invisible.
|
||||||
|
- **← / → keyboard accelerators for paused stepping**
|
||||||
|
(`e5c4f51`). New `step_backwards_replay_playback` in
|
||||||
|
`replay_playback.rs` decrements the cursor and dispatches
|
||||||
|
`UndoRequestEvent`; the game's `handle_undo` reads it
|
||||||
|
next frame to reverse its most-recent move. Hooks the
|
||||||
|
existing undo system rather than replaying-forward-from-
|
||||||
|
zero — every replay-applied move pushes to the undo stack
|
||||||
|
the same way a player move would, so undo is the right
|
||||||
|
reversal primitive. Both arrow keys are paused-only via
|
||||||
|
the same destructure-gate pattern the forward step uses.
|
||||||
|
The mockup labels these `[← →] scrub`; single-move step
|
||||||
|
is the closest behaviour shippable today, so the footer
|
||||||
|
hint reads `[← →] step` — only-wired-keybinds discipline.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Banner height grew 60 → 76 → 92 px** across two
|
||||||
|
layout-changing commits (`d322abf` then `1873b3f`). Top
|
||||||
|
row's `flex_grow: 1.0` still consumes 59 px so the
|
||||||
|
existing content (label / progress chip / buttons) has
|
||||||
|
the same vertical space; the new rows (16 px labels +
|
||||||
|
16 px footer) extend the banner downward into the
|
||||||
|
gameplay area. Banner geometry is now mutable — every
|
||||||
|
prior B-2 commit fit inside fixed 60 px space.
|
||||||
|
- **Keybind-footer hint text grew alongside the wirings**:
|
||||||
|
`[SPACE] pause/resume` →
|
||||||
|
`[SPACE] pause/resume · [ESC] stop` →
|
||||||
|
`[SPACE] pause/resume · [ESC] stop · [← →] step`.
|
||||||
|
- **`pause_plugin::toggle_pause` now defers when a replay
|
||||||
|
is active** (`90e24d9`). Adds a fourth defer-if check to
|
||||||
|
the existing modal-stack pattern.
|
||||||
|
- **`ReplayOverlayPlugin` registers
|
||||||
|
`add_message::<UndoRequestEvent>()`** (`e5c4f51`).
|
||||||
|
Defensive registration so the plugin runs cleanly under
|
||||||
|
`MinimalPlugins` without `GamePlugin` attached.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- `SESSION_HANDOFF.md` refreshed five times this cycle.
|
||||||
|
The B option in the Resume menu now traces the full arc:
|
||||||
|
notches → labels → footer → ESC → HC → arrow keys.
|
||||||
|
- The pre-existing `daily_challenge` warning test that
|
||||||
|
fails when wall-clock UTC is within 30 minutes of
|
||||||
|
midnight is documented in this cycle's handoff. Same
|
||||||
|
shape as the earlier `winnable_seed_search` flake —
|
||||||
|
time-dependent, deterministically passes outside the
|
||||||
|
trigger window.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- **1250 total tests / 1249 passing / 1 pre-existing
|
||||||
|
time-dependent flake** across the workspace (net +22 from
|
||||||
|
v0.21.4's 1228 baseline):
|
||||||
|
- 4 from `fe68861` (scrub-notch coverage)
|
||||||
|
- 4 from `d322abf` (notch-label coverage)
|
||||||
|
- 4 from `1873b3f` (keybind-footer coverage)
|
||||||
|
- 3 from `90e24d9` (ESC-accelerator coverage)
|
||||||
|
- 1 from `23902cd` (HC-marker coverage)
|
||||||
|
- 6 from `e5c4f51` (arrow-keyboard coverage)
|
||||||
|
- **Pre-existing flake**:
|
||||||
|
`daily_challenge_plugin::tests::check_system_fires_warning_event_only_once_per_day`
|
||||||
|
fails when wall-clock UTC is within 30 minutes of
|
||||||
|
midnight. Verified pre-existing by stash-and-retest
|
||||||
|
before each commit. Will pass deterministically outside
|
||||||
|
the trigger window. Not introduced by this release.
|
||||||
|
- Clippy clean across the workspace.
|
||||||
|
|
||||||
|
## [0.21.4] — 2026-05-08
|
||||||
|
|
||||||
|
Patch release for the post-v0.21.3 work. One through-line:
|
||||||
|
**replay-scrubbing accessibility**. The replay overlay used to be
|
||||||
|
pure-passive — the player started a replay, watched it execute,
|
||||||
|
and waited for it to end. v0.21.4 adds the scaffolding for
|
||||||
|
*navigating within* a replay: a WIN MOVE marker on the scrub bar
|
||||||
|
so the player can see at a glance where the winning move sits,
|
||||||
|
and pause / resume / step controls so they can stop on any move
|
||||||
|
and inspect the board.
|
||||||
|
|
||||||
|
The work is also the first three commits on the B-2 replay
|
||||||
|
screen-takeover redesign arc. The remaining pieces (screen-
|
||||||
|
takeover layout, move-log scroller, mini-tableau preview) are
|
||||||
|
deferred to a future cycle because they need a layout reflow
|
||||||
|
that the existing banner-only overlay can't carry.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`Replay::win_move_index: Option<usize>` data field**
|
||||||
|
(`ab857bb`). Additive optional field on the persisted
|
||||||
|
`Replay` shape. `#[serde(default)]` keeps older
|
||||||
|
`latest_replay.json` / `replays.json` files loadable without
|
||||||
|
bumping `REPLAY_SCHEMA_VERSION` — this is purely additive.
|
||||||
|
Populated at the live recording site
|
||||||
|
(`game_plugin::handle_game_won`) via a new builder-style
|
||||||
|
setter `Replay::with_win_move_index`. For fresh recordings
|
||||||
|
the value is always `Some(moves.len() - 1)` because recording
|
||||||
|
freezes on win, but storing it explicitly lets the playback
|
||||||
|
UI read the WIN MOVE position directly without re-deriving
|
||||||
|
on every render.
|
||||||
|
- **WIN MOVE scrub-bar marker** (`52befa6`). New
|
||||||
|
`ReplayOverlayWinMoveMarker` component spawned as a sibling
|
||||||
|
to `ReplayOverlayScrubFill` under the 1px scrub track,
|
||||||
|
absolute-positioned at `replay.win_move_index / total %` of
|
||||||
|
the bar. Painted in `STATE_SUCCESS` (green) so the marker
|
||||||
|
reads as "this is where the win lives." Pure helper
|
||||||
|
`win_move_marker_pct` returns `None` for any state where the
|
||||||
|
marker shouldn't draw (Inactive, Completed, replay missing
|
||||||
|
the field, empty move list); percentage clamps to `[0, 100]`
|
||||||
|
defensively. Spawn-time only — the position never changes
|
||||||
|
during a single playback because the underlying `Replay` is
|
||||||
|
immutable while `Playing`.
|
||||||
|
- **Pause / Resume / Step playback controls** (`fbe48ac`). New
|
||||||
|
`paused: bool` field on `ReplayPlaybackState::Playing`.
|
||||||
|
`tick_replay_playback` skips the `secs_to_next` decrement
|
||||||
|
entirely while paused so cursor and timer freeze together;
|
||||||
|
resuming starts the next move from a full interval. New
|
||||||
|
public API: `toggle_pause_replay_playback` and
|
||||||
|
`step_replay_playback` (the latter hard-gated to `Playing {
|
||||||
|
paused: true }` via the destructure pattern itself, so
|
||||||
|
manual stepping can't race the tick loop). On-screen Pause
|
||||||
|
and Step buttons sit alongside the existing Stop button;
|
||||||
|
`Space` keyboard accelerator toggles pause / resume.
|
||||||
|
- **`Replay::with_win_move_index` builder** (`ab857bb`).
|
||||||
|
Chainable setter so the recording site can write
|
||||||
|
`Replay::new(...).with_win_move_index(idx)`. Keeps
|
||||||
|
`Replay::new`'s signature stable across the 13+ existing
|
||||||
|
test-fixture call sites that don't care about the field.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`Replay::new` writes `win_move_index: None`** (`ab857bb`).
|
||||||
|
Existing canonical constructor stays signature-compatible
|
||||||
|
with all existing callers. The field is opt-in via the
|
||||||
|
builder.
|
||||||
|
- **`game_plugin::handle_game_won` populates the new field**
|
||||||
|
(`ab857bb`). The recording site computes
|
||||||
|
`recording.moves.len().checked_sub(1)` as the win-move
|
||||||
|
index. `checked_sub` rather than direct subtraction guards
|
||||||
|
the unreachable empty-recording branch (which is also
|
||||||
|
guarded earlier in the function).
|
||||||
|
- **`tick_replay_playback` honors the new `paused` flag**
|
||||||
|
(`fbe48ac`). Skipping the timer decrement is the only
|
||||||
|
behavior change; the loop body and Completed-detection are
|
||||||
|
unchanged. Stepping fires moves directly via
|
||||||
|
`step_replay_playback`, bypassing the tick path entirely.
|
||||||
|
- **Pause / Resume button label is reactive** (`fbe48ac`).
|
||||||
|
`update_pause_button_label` walks `Children` from the
|
||||||
|
marked button to its inner `Text` and repaints the label
|
||||||
|
whenever `ReplayPlaybackState` changes. Pure helper
|
||||||
|
`pause_button_label` covers all four state arms (running,
|
||||||
|
paused, inactive, completed).
|
||||||
|
- **25 existing `Playing { ... }` construction sites gained
|
||||||
|
`paused: false`** (`fbe48ac`). Mechanical edit across
|
||||||
|
`replay_overlay`, `achievement_plugin`, and
|
||||||
|
`replay_playback` tests to satisfy the new field
|
||||||
|
requirement. No behavioral change.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- `SESSION_HANDOFF.md` refreshed three times this cycle —
|
||||||
|
once after each post-cut feature commit. The B-2 entry in
|
||||||
|
the Visual-identity follow-ups list now points at the
|
||||||
|
remaining sub-pieces (screen-takeover layout, move-log
|
||||||
|
scroller, mini-tableau preview) as a single multi-session
|
||||||
|
arc rather than three independent ones, since they share a
|
||||||
|
layout-reflow prerequisite.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- **1228 passing tests / 0 failing** across the workspace
|
||||||
|
(net +21 from v0.21.3's 1207 baseline):
|
||||||
|
- 5 from `ab857bb`'s `win_move_index` coverage: default
|
||||||
|
constructor, builder set / set-None, on-disk round-trip,
|
||||||
|
legacy-JSON-loads-with-None backward-compat. The last
|
||||||
|
test pins the no-schema-bump claim — if a future refactor
|
||||||
|
drops the `#[serde(default)]`, that test catches it.
|
||||||
|
- 8 from `52befa6`'s WIN MOVE marker: pure-helper truth
|
||||||
|
table (Inactive / Completed / no-field / correct-position
|
||||||
|
/ clamp) + spawn-presence-with-field /
|
||||||
|
spawn-absence-without / despawn-with-overlay observables.
|
||||||
|
- 8 from `fbe48ac`'s playback controls: label truth table,
|
||||||
|
label repaint on state change, click-toggles-paused,
|
||||||
|
step advances cursor by exactly one with paused
|
||||||
|
preserved, step-while-running no-op, Space toggles
|
||||||
|
paused.
|
||||||
|
- Zero clippy warnings under `cargo clippy --workspace
|
||||||
|
--all-targets -- -D warnings`.
|
||||||
|
- `cargo test --workspace` clean.
|
||||||
|
|
||||||
|
## [0.21.3] — 2026-05-08
|
||||||
|
|
||||||
|
Patch release for the post-v0.21.2 work. One through-line:
|
||||||
|
**accessibility arc closure**. v0.21.2 explicitly carved out
|
||||||
|
"dynamic-paint sites" (HUD action buttons, modal buttons, radial
|
||||||
|
menu rim) on the assumption that their existing paint cycles would
|
||||||
|
race the central `update_high_contrast_borders` system. v0.21.3
|
||||||
|
walks the actual code, finds the carve-out was over-cautious, and
|
||||||
|
closes it. Bonus: the first real consumer of `ToastVariant::Warning`
|
||||||
|
also lands here, making the `ToastVariant` enum fully load-bearing
|
||||||
|
(every variant has at least one driver).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`WarningToastEvent(String)` — first `ToastVariant::Warning`
|
||||||
|
consumer** (`279e23d`). Generic carrier message that any system
|
||||||
|
can fire to spawn a 4 s amber-bordered fire-and-forget toast.
|
||||||
|
Mirrors the v0.21.2 `MoveRejectedEvent` → `Error` toast wiring:
|
||||||
|
domain message crosses the plugin boundary, the animation
|
||||||
|
plugin's `handle_warning_toast` system reads it and spawns. Not
|
||||||
|
queued (Warning is alert-shaped, not info-shaped — should never
|
||||||
|
block on a queue).
|
||||||
|
- **Daily-challenge-expiry warning** (`279e23d`). First in-engine
|
||||||
|
driver of `WarningToastEvent`. New
|
||||||
|
`daily_challenge_plugin::check_daily_expiry_warning` system
|
||||||
|
fires at most once per `DailyChallengeResource::date` when the
|
||||||
|
player is within 30 min of UTC midnight reset and today's
|
||||||
|
challenge isn't yet complete. Suppression decided by a pure
|
||||||
|
helper (`compute_expiry_warning_minutes`) covering: already-
|
||||||
|
completed-today, already-shown-for-this-date, outside the
|
||||||
|
threshold window, post-midnight rollover. Pure-helper-plus-
|
||||||
|
thin-system shape because `Utc::now()` can't be pinned without
|
||||||
|
injecting a clock resource — overkill for one consumer.
|
||||||
|
- **`radial_rim_outline` pure helper** (`c153363`). Decision
|
||||||
|
logic for the radial-menu rim outline colour. Resting outlines
|
||||||
|
always carry `BORDER_SUBTLE`; focused outlines carry
|
||||||
|
`BORDER_STRONG` normally and `BORDER_SUBTLE_HC` under HC. Naive
|
||||||
|
marker substitution would invert the focused-vs-resting
|
||||||
|
hierarchy because `BORDER_SUBTLE_HC` (`#a0a0a0`) is *lighter*
|
||||||
|
than `BORDER_STRONG` (`#505050`); folding the choice in here
|
||||||
|
keeps the focused rim more visible under HC, not less.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **HC marker pattern extended to HUD action buttons + modal
|
||||||
|
buttons** (`c153363`). Re-reading the code revealed both sites'
|
||||||
|
paint systems (`paint_action_buttons`, `paint_modal_buttons`)
|
||||||
|
only mutate `BackgroundColor` — `BorderColor` is set once at
|
||||||
|
spawn and never touched. So the existing
|
||||||
|
`HighContrastBorder::with_default(BORDER_SUBTLE)` marker
|
||||||
|
pattern works cleanly for both, no race. v0.21.2's carve-out
|
||||||
|
comment was based on assumed-but-not-actual race risk; this
|
||||||
|
cycle treats it as the doc-vs-implementation drift pattern in
|
||||||
|
the wild and verifies before trusting.
|
||||||
|
- **Radial menu rim folds HC into per-frame respawn**
|
||||||
|
(`c153363`). The rim is the only true dynamic-painter of the
|
||||||
|
three carved-out sites — `radial_redraw_overlay` despawns and
|
||||||
|
respawns all rim sprites every frame the radial is `Active`.
|
||||||
|
The `HighContrastBorder` marker can't apply (entities don't
|
||||||
|
persist across frames) so HC is read directly in the system
|
||||||
|
via `Option<Res<SettingsResource>>` and routed through
|
||||||
|
`radial_rim_outline`. The `Option<Res<...>>` shape preserves
|
||||||
|
test compatibility under `MinimalPlugins`.
|
||||||
|
- **Animation plugin registers `WarningToastEvent`** (`279e23d`).
|
||||||
|
Joins `InfoToastEvent`, `MoveRejectedEvent` etc. in
|
||||||
|
`AnimationPlugin::build`. Daily-challenge plugin also
|
||||||
|
registers it (idempotent) so the message exists when running
|
||||||
|
the daily plugin under `MinimalPlugins` without the animation
|
||||||
|
plugin attached.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- `SESSION_HANDOFF.md` refreshed twice this cycle — once after
|
||||||
|
the Toast Warning wiring (menu trimmed 5 → 4 options), and
|
||||||
|
again after the HC dynamic-paint rollout (menu trimmed 4 → 3,
|
||||||
|
with all remaining options now flagged as multi-session). The
|
||||||
|
`High-contrast accessibility mode` entry in the Visual-identity
|
||||||
|
follow-ups list is updated to reflect that no "un-tagged
|
||||||
|
because race-risk" surfaces remain.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- **1207 passing tests / 0 failing** across the workspace
|
||||||
|
(net +12 from v0.21.2's 1195 baseline):
|
||||||
|
- 7 tests for `compute_expiry_warning_minutes` (`279e23d`)
|
||||||
|
covering each suppression rule + the inclusive boundary at
|
||||||
|
exactly 30 min remaining.
|
||||||
|
- 1 in-Bevy test (`check_system_fires_warning_event_only_once_per_day`)
|
||||||
|
pinning `DailyExpiryWarningShown`'s once-per-date
|
||||||
|
suppression and the symmetric "already-completed-today"
|
||||||
|
suppression.
|
||||||
|
- 4 truth-table tests for `radial_rim_outline` (`c153363`):
|
||||||
|
focused × HC. The "resting stays subtle under HC" test
|
||||||
|
explicitly documents *why* — it's the hierarchy-preservation
|
||||||
|
invariant a future refactor might be tempted to break.
|
||||||
|
- Zero clippy warnings under `cargo clippy --workspace
|
||||||
|
--all-targets -- -D warnings`.
|
||||||
|
- `cargo test --workspace` clean.
|
||||||
|
|
||||||
## [0.21.2] — 2026-05-08
|
## [0.21.2] — 2026-05-08
|
||||||
|
|
||||||
Patch release for the post-v0.21.1 polish work. Three through-
|
Patch release for the post-v0.21.1 polish work. Three through-
|
||||||
|
|||||||
+157
-76
@@ -1,46 +1,85 @@
|
|||||||
# Solitaire Quest — Session Handoff
|
# Solitaire Quest — Session Handoff
|
||||||
|
|
||||||
**Last updated:** 2026-05-08 — **v0.21.1 cut and tagged at `daa655a`**,
|
**Last updated:** 2026-05-08 — **v0.21.6 cut and tagged at
|
||||||
working tree clean, all post-tag work pushed to origin.
|
`f63db76`**, working tree clean, all post-tag work pushed to
|
||||||
|
origin.
|
||||||
|
|
||||||
v0.21.1 is a patch release for the post-v0.21.0 work: closes
|
v0.21.6 is a patch release with through-line:
|
||||||
Resume-prompt Options A (app icon — runtime `Window::icon` plus
|
**Move Log panel + scrub-UX polish**. v0.21.5 closed out the
|
||||||
the 9-size PNG hierarchy) and F (high-contrast + reduce-motion
|
keyboard-accelerator surface (Space / Esc / ← / →) and the
|
||||||
accessibility modes — Settings flags wired through engine and
|
keybind footer; v0.21.6 builds on that with two parallel
|
||||||
UI). Plus a card-visual iteration cycle that moved through three
|
threads — accessibility + scrub-on-hold polish for the v0.21.5
|
||||||
states (v0.21.0 Terminal pink/gray → brief 4-colour-deck
|
surfaces, plus a brand-new Move Log panel anchored to the
|
||||||
experiment → traditional 2-colour Microsoft-Solitaire-on-dark-mode
|
viewport's bottom edge that gives players a 5-row recent-and-
|
||||||
red/near-white) and two visible-bug fixes (suit-coloured border
|
upcoming move history alongside the existing top-edge banner.
|
||||||
anti-aliasing artifact at rounded corners, pile-marker
|
|
||||||
bleed-through producing "gray L" shapes at occupied piles —
|
|
||||||
the latter implemented the previously-documented-but-not-enforced
|
|
||||||
"markers visible only at empty piles" invariant).
|
|
||||||
|
|
||||||
Full v0.21.1 detail lives in `CHANGELOG.md` § [0.21.1]. This
|
The Move Log panel is the first replay-overlay surface that
|
||||||
|
*isn't* attached to the banner — it lives at a separate screen
|
||||||
|
anchor (`bottom: 0`) with its own spawn/despawn lifecycle.
|
||||||
|
Establishes the "multi-anchor replay UI" pattern that the
|
||||||
|
remaining B-2 sub-piece (mini-tableau preview) will inherit.
|
||||||
|
|
||||||
|
Six commits on the B-2 replay screen-takeover redesign arc land
|
||||||
|
here, bringing the post-v0.21.4 total to 12. The remaining B-2
|
||||||
|
piece — mini-tableau preview that dims the gameplay tableau
|
||||||
|
during replay — is the only major sub-piece still open.
|
||||||
|
|
||||||
|
Full v0.21.6 detail lives in `CHANGELOG.md` § [0.21.6]. This
|
||||||
file from here on focuses on what's *open* post-cut and how to
|
file from here on focuses on what's *open* post-cut and how to
|
||||||
resume.
|
resume.
|
||||||
|
|
||||||
## Status at pause
|
## Status at pause
|
||||||
|
|
||||||
- **HEAD locally:** see `git rev-parse HEAD`. The cut commit is
|
- **HEAD locally:** see `git rev-parse HEAD`. The cut commit is
|
||||||
`daa655a`; any post-cut docs edits ride on top of that.
|
`f63db76`; any post-cut docs edits ride on top of that.
|
||||||
- **HEAD on origin:** matches local. v0.21.1 is fully on origin.
|
- **HEAD on origin:** matches local. v0.21.6 is fully on origin.
|
||||||
- **Working tree:** clean. No WIP outstanding.
|
- **Working tree:** clean. No WIP outstanding.
|
||||||
- **`artwork/` directory:** still untracked. Intentional.
|
- **`artwork/` directory:** still untracked. Intentional.
|
||||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
|
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
|
||||||
clean.
|
clean.
|
||||||
- **Tests:** **1192 passing / 0 failing** across the workspace
|
- **Tests:** **1273 passing / 0 failing** across the workspace.
|
||||||
(net +8 from v0.21.0's 1184 baseline). Detail in
|
Detail in `CHANGELOG.md` § [0.21.6] § Stats.
|
||||||
`CHANGELOG.md` § [0.21.1] § Stats.
|
- **Tags on origin:** `v0.9.0` through `v0.21.6`. v0.21.6 is on
|
||||||
- **Tags on origin:** `v0.9.0` through `v0.21.1`. v0.21.1 is on
|
`f63db76`; v0.21.5 stays on `a2432df`; v0.21.4 stays on
|
||||||
`daa655a`; v0.21.0 stays on `04f9bf9`; v0.20.0 stays on
|
`23ff62c`; v0.21.3 stays on `3d92a91`; v0.21.2 stays on
|
||||||
`41a009a`.
|
`f23df3b`; v0.21.1 stays on `daa655a`; v0.21.0 stays on
|
||||||
|
`04f9bf9`; v0.20.0 stays on `41a009a`.
|
||||||
|
|
||||||
## Since the v0.21.1 cut
|
## Since the v0.21.6 cut
|
||||||
|
|
||||||
No threads in flight. Working tree clean as of 2026-05-08. New
|
No threads in flight. Working tree clean as of 2026-05-08. New
|
||||||
work since the cut would land here as commit narratives; for
|
work since the cut would land here as commit narratives; for
|
||||||
the v0.21.1 contents themselves, see `CHANGELOG.md` § [0.21.1].
|
the v0.21.6 contents themselves, see `CHANGELOG.md` § [0.21.6].
|
||||||
|
|
||||||
|
Open next-step menu (Move Log + scrub-UX + keyboard accelerator
|
||||||
|
coverage + accessibility are all complete):
|
||||||
|
1. **Mini-tableau preview** — the only remaining major B-2
|
||||||
|
sub-piece. Mockup shows a 240 px-tall band at 50 % opacity
|
||||||
|
showing the gameplay tableau peeking through the replay
|
||||||
|
chrome. Implementation needs to add a settings-aware dim
|
||||||
|
overlay or alpha modulation on the tableau cards during
|
||||||
|
replay. Architectural — touches `card_plugin` rendering.
|
||||||
|
Multi-session.
|
||||||
|
2. **Move Log auto-scroll** — only relevant if the panel's
|
||||||
|
row count grows beyond the current 5-row fixed window.
|
||||||
|
Currently the prev-2 / active / next-2 layout fits all
|
||||||
|
visible content, so auto-scroll is unneeded. Becomes
|
||||||
|
relevant if a future commit expands the panel's row
|
||||||
|
capacity (e.g. 10-row scrolling list).
|
||||||
|
3. **Polish: notch label centering.** Bevy 0.18 lacks a
|
||||||
|
clean `translate-x: -50%` primitive so middle three
|
||||||
|
labels sit slightly right-of-notch. Could use a child
|
||||||
|
Text wrapper with computed left-margin compensation.
|
||||||
|
Tiny commit, requires visual review.
|
||||||
|
4. **Polish: WIN MOVE marker HC bump.** Currently uses
|
||||||
|
`STATE_SUCCESS` lime which stays visible under HC, but a
|
||||||
|
contrast bump under HC would make it even more legible
|
||||||
|
alongside the bumped notches. Optional.
|
||||||
|
|
||||||
|
Recommended order: option 1 (mini-tableau preview) is the
|
||||||
|
big remaining piece that closes B-2 — best tackled in a
|
||||||
|
fresh session because it crosses into `card_plugin`. Options
|
||||||
|
3 and 4 are visual polish that benefit from user review.
|
||||||
|
|
||||||
## Open punch list
|
## Open punch list
|
||||||
|
|
||||||
@@ -77,29 +116,65 @@ palette refresh all shipped in v0.20.0 + v0.21.0. What stays open:
|
|||||||
mini-tableau preview, playback controls, move-log scroll, and
|
mini-tableau preview, playback controls, move-log scroll, and
|
||||||
a WIN MOVE marker on the scrub bar. Banner-local pieces all
|
a WIN MOVE marker on the scrub bar. Banner-local pieces all
|
||||||
shipped in v0.21.0 (`c84d9f4` + `6204db8` + `54005d5` +
|
shipped in v0.21.0 (`c84d9f4` + `6204db8` + `54005d5` +
|
||||||
`e080b49`); the screen-takeover is a multi-session redesign
|
`e080b49`); the floating MOVE chip above the focused card
|
||||||
with data-layer impact (move-log scroller; WIN MOVE needs a
|
shipped in v0.21.2 (`2fb2d63`). The WIN MOVE scrub-bar marker
|
||||||
`win_move_index` field on `Replay` that doesn't yet exist).
|
shipped post-v0.21.3 in `ab857bb` (data field) + `52befa6`
|
||||||
- **Floating `MOVE N/M` chip above the focused card during
|
(UI). Playback controls (pause / resume / step + Space
|
||||||
playback.** Cross-plugin work — `update_progress_text` writes
|
accelerator) shipped post-v0.21.3 in `fbe48ac`. v0.21.5
|
||||||
the banner chip but the card-position lookup belongs in
|
bundled six more commits under "replay-overlay scrubbing
|
||||||
`card_plugin`. Smaller scope than the screen-takeover.
|
affordances + accessibility" (scrub notches + labels +
|
||||||
- **Toast Warning / Error variants.** `ToastVariant` has slots
|
keybind footer + ESC and ← / → accelerators + HC border).
|
||||||
for `Warning` (gold) and `Error` (pink) but no in-engine
|
v0.21.6 bundled six more under "Move Log panel + scrub-UX
|
||||||
event uses them yet. Wire when a warning- or error-flavoured
|
polish" — bottom-edge Move Log panel with prev/active/next
|
||||||
toast event materialises.
|
rows + active highlight, HC-mode coverage for the scrub
|
||||||
|
track + notches, continuous scrub on key-held arrows. Banner
|
||||||
|
height grew 60 → 76 → 92 px across two layout-changing
|
||||||
|
commits in v0.21.5; Move Log panel grew 56 → 84 → 112 px
|
||||||
|
across the v0.21.6 move-log commits. Per-commit detail in
|
||||||
|
`CHANGELOG.md` § [0.21.5] and § [0.21.6]. The only major
|
||||||
|
B-2 piece left is the mini-tableau preview — the mockup's
|
||||||
|
"Game Peek Band" at 50 % opacity. Architectural; touches
|
||||||
|
`card_plugin` rendering.
|
||||||
|
Multi-session.
|
||||||
|
- *Floating `MOVE N/M` chip above the focused card during
|
||||||
|
playback — closed 2026-05-08 by `2fb2d63`.* World-space
|
||||||
|
`Text2d` entity sibling to the banner overlay; uses the same
|
||||||
|
`LayoutResource` pile coordinates so it survives window
|
||||||
|
resizes without UI/camera math.
|
||||||
|
- *Toast Warning variant wiring — closed 2026-05-08 by `279e23d`.*
|
||||||
|
Daily-challenge-expiry toast fires once per `daily.date` when
|
||||||
|
within 30 min of UTC midnight reset and today is incomplete.
|
||||||
|
`ToastVariant` is now fully load-bearing (every variant has at
|
||||||
|
least one real driver). Future Warning drivers can either reuse
|
||||||
|
the generic `WarningToastEvent(String)` carrier or add their
|
||||||
|
own domain message + `animation_plugin` handler.
|
||||||
|
- *Toast Error variant wiring — closed 2026-05-08 by `68d50b5`.*
|
||||||
|
`MoveRejectedEvent` now fires a 2-second pink-bordered
|
||||||
|
"Invalid move" toast as the third leg of the
|
||||||
|
audio + visual + text rejection-feedback stool.
|
||||||
- *High-contrast accessibility mode — closed 2026-05-08 by
|
- *High-contrast accessibility mode — closed 2026-05-08 by
|
||||||
`c5787c6` + `07e0357`.* Card text rendering picks up
|
`c5787c6` + `07e0357` (engine + UI) + v0.21.2's HC chrome
|
||||||
`TEXT_PRIMARY_HC` (`#f5f5f5`) and `RED_SUIT_COLOUR_HC`
|
rollout (`c9af1ea` + `d87761d` + `ec804d5`) + post-cut
|
||||||
(`#ff8aa0`); Settings panel has a toggle. Future scope:
|
dynamic-paint rollout (`c153363`).* Card text rendering plus
|
||||||
extend HC through chrome borders (`BORDER_SUBTLE_HC` already
|
8 static-border chrome surfaces (modal scaffold, tooltip,
|
||||||
defined, not yet consumed), buttons, popover edges.
|
onboarding key chips, help panel key chips, stats panel
|
||||||
- *Reduced-motion mode — closed 2026-05-08 by the same pair.*
|
cells, home Level/XP/Score row, home mode buttons, home
|
||||||
`effective_slide_secs` forces 0 when on, regardless of the
|
mode-hotkey chips, 4 settings panel surfaces) all boost
|
||||||
`AnimSpeed` setting. Future scope: gate splash scanline
|
borders to `BORDER_SUBTLE_HC` under HC via the
|
||||||
overlay + cursor pulse animation on the same flag, gate
|
`HighContrastBorder` marker. The previously-carved-out
|
||||||
warning-chip pulse, gate any future card-lift z-bump
|
dynamic-paint sites are now also covered: HUD action buttons
|
||||||
animation.
|
and modal buttons take the same marker (their paint cycles
|
||||||
|
only mutate `BackgroundColor`, so no race); the radial menu
|
||||||
|
rim folds HC into its per-frame spawn via
|
||||||
|
`radial_rim_outline` so the focused rim boosts to
|
||||||
|
`BORDER_SUBTLE_HC` under HC (preserving focused-vs-resting
|
||||||
|
hierarchy that naive marker substitution would invert).
|
||||||
|
- *Reduced-motion mode — closed 2026-05-08 by `c5787c6` +
|
||||||
|
v0.21.2's `ed152e2`.* `effective_slide_secs` forces 0 on
|
||||||
|
card animations; `pulse_splash_cursor` skips the per-frame
|
||||||
|
pulse multiplier; `spawn_splash` skips the scanline overlay
|
||||||
|
entirely. Future scope: gate any future card-lift z-bump
|
||||||
|
animation, warning-chip pulse (when one materialises).
|
||||||
|
|
||||||
### Carried forward from v0.19.0
|
### Carried forward from v0.19.0
|
||||||
|
|
||||||
@@ -203,19 +278,23 @@ into a v0.21.1 / v0.22.0 cut.
|
|||||||
```
|
```
|
||||||
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>.
|
Working directory: <Rusty_Solitaire clone path on this machine>.
|
||||||
Branch: master. v0.21.1 is tagged at daa655a (cut 2026-05-08, a
|
Branch: master. v0.21.6 is tagged at f63db76 (cut 2026-05-08, a
|
||||||
patch release rolling up app-icon, accessibility modes, and the
|
patch release rolling up Move Log panel + scrub-UX polish:
|
||||||
card-visual iteration cycle that closed Resume-prompt Options A
|
brand-new bottom-edge Move Log panel with prev / active / next
|
||||||
and F). v0.21.0 stays at 04f9bf9. Working tree clean. See
|
row context + active-row highlight, plus HC-mode coverage for
|
||||||
CHANGELOG.md § [0.21.1] for full detail of what shipped in the
|
scrub track + notches and continuous scrub on key-held arrow
|
||||||
patch release.
|
keys). v0.21.5 stays at a2432df, v0.21.4 at 23ff62c, v0.21.3
|
||||||
|
at 3d92a91, v0.21.2 at f23df3b, v0.21.1 at daa655a, v0.21.0 at
|
||||||
|
04f9bf9. Working tree clean. See CHANGELOG.md § [0.21.6] for
|
||||||
|
full detail.
|
||||||
|
|
||||||
State: HEAD locally — see `git rev-parse HEAD`. All workspace tests
|
State: HEAD locally — see `git rev-parse HEAD`. The cut commit
|
||||||
pass (1192+; check with `cargo test --workspace`), clippy clean.
|
is f63db76; any post-cut docs edits ride on top of that.
|
||||||
|
Workspace tests: 1273 passing / 0 failing. Clippy clean.
|
||||||
|
|
||||||
READ FIRST (in order, before doing anything):
|
READ FIRST (in order, before doing anything):
|
||||||
1. SESSION_HANDOFF.md — this file
|
1. SESSION_HANDOFF.md — this file
|
||||||
2. CHANGELOG.md — [0.21.1] section is the most recent cut
|
2. CHANGELOG.md — [0.21.6] section is the most recent cut
|
||||||
3. CLAUDE.md — unified-3.0 rule set
|
3. CLAUDE.md — unified-3.0 rule set
|
||||||
4. CLAUDE_SPEC.md — formal architecture spec
|
4. CLAUDE_SPEC.md — formal architecture spec
|
||||||
5. ARCHITECTURE.md — crate responsibilities + data flow
|
5. ARCHITECTURE.md — crate responsibilities + data flow
|
||||||
@@ -235,29 +314,27 @@ DECISION TO ASK THE PLAYER FIRST:
|
|||||||
tests can't catch. Likely surfaces JNI ClipboardManager
|
tests can't catch. Likely surfaces JNI ClipboardManager
|
||||||
and Android Keystore stubs that need real bridges. Larger
|
and Android Keystore stubs that need real bridges. Larger
|
||||||
scope; needs an Android device or emulator running.
|
scope; needs an Android device or emulator running.
|
||||||
(Was Resume-prompt B before the post-v0.21.1 menu trim.)
|
B. Replay-overlay screen-takeover redesign — nearly complete
|
||||||
B. Replay-overlay extensions — either the floating `MOVE N/M`
|
after 12 commits across v0.21.4-6. Scrub bar with notches
|
||||||
chip above the focused card (smaller, cross-plugin; needs
|
+ labels + WIN MOVE marker, pause / resume / step / stop
|
||||||
cursor → card-position plumbing in `card_plugin`) or the
|
buttons, Space + Esc + ← / → keyboard accelerators with
|
||||||
full screen-takeover redesign (multi-session: move-log
|
continuous scrub on hold, keybind-hint footer, full HC-mode
|
||||||
scroll, mini tableau preview, WIN MOVE marker, data-layer
|
coverage on the banner pieces, and a brand-new bottom-edge
|
||||||
impact for `Replay::win_move_index`).
|
Move Log panel with a 5-row prev/active/next window all
|
||||||
C. Toast Warning / Error variant wiring. UI infrastructure
|
ship. The only remaining major B-2 sub-piece is the
|
||||||
exists in `ToastVariant`; no in-engine event uses Warning
|
**mini-tableau preview** — the mockup's "Game Peek Band"
|
||||||
(gold) or Error (pink) yet. Wire when a real warning- or
|
at 50 % opacity showing the tableau through the replay
|
||||||
error-flavoured event materialises.
|
chrome. Implementation needs a settings-aware dim overlay
|
||||||
D. Phase 8 (sync) — local storage scaffolding, self-hosted
|
or alpha modulation on the tableau cards during replay.
|
||||||
|
Architectural — touches `card_plugin` rendering. Best
|
||||||
|
tackled in a fresh session because it crosses into a
|
||||||
|
plugin the recent B-2 work hasn't touched. Mockup at
|
||||||
|
`docs/ui-mockups/replay-overlay-mobile.html`.
|
||||||
|
C. Phase 8 (sync) — local storage scaffolding, self-hosted
|
||||||
Axum server, `SolitaireServerClient` impl, GPGS stub
|
Axum server, `SolitaireServerClient` impl, GPGS stub
|
||||||
wired into Settings. The biggest open arc by scope; rolls
|
wired into Settings. The biggest open arc by scope; rolls
|
||||||
up several Phase Android dependencies (Keystore,
|
up several Phase Android dependencies (Keystore,
|
||||||
ClipboardManager).
|
ClipboardManager).
|
||||||
E. Extend high-contrast through chrome — `BORDER_SUBTLE_HC`
|
|
||||||
was defined in v0.21.1 but isn't yet consumed; popover
|
|
||||||
edges, button borders, focus rings still use the default
|
|
||||||
non-HC tokens. Plus reduce-motion still doesn't gate
|
|
||||||
splash scanline / cursor pulse / warning-chip pulse —
|
|
||||||
v0.21.1 only gated card slide_secs. Both are small,
|
|
||||||
finite, half-day scope.
|
|
||||||
|
|
||||||
WORKFLOW NOTES:
|
WORKFLOW NOTES:
|
||||||
- Use the system git config (already correct).
|
- Use the system git config (already correct).
|
||||||
@@ -281,5 +358,9 @@ WORKFLOW NOTES:
|
|||||||
a "this does X" doc comment, verify the code actually does
|
a "this does X" doc comment, verify the code actually does
|
||||||
X and add a test if not. Two layers, two checks.
|
X and add a test if not. Two layers, two checks.
|
||||||
|
|
||||||
OPEN AT THE START: ask which of A–E. Don't pick unilaterally.
|
OPEN AT THE START: ask which of A–C. Don't pick unilaterally.
|
||||||
|
Note: every remaining option is multi-session by nature (A is
|
||||||
|
gated on Android tooling, B and C are explicitly multi-session
|
||||||
|
arcs). A fresh session is a better fit for any of them than the
|
||||||
|
tail of a long working stretch.
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -147,12 +147,38 @@ pub struct Replay {
|
|||||||
/// [`REPLAY_SCHEMA_VERSION`].
|
/// [`REPLAY_SCHEMA_VERSION`].
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub share_url: Option<String>,
|
pub share_url: Option<String>,
|
||||||
|
/// Index into [`moves`](Self::moves) of the move that triggered
|
||||||
|
/// the win condition (i.e. completed the last foundation pile).
|
||||||
|
///
|
||||||
|
/// For replays recorded by the live engine this is always
|
||||||
|
/// `Some(moves.len() - 1)` because recording freezes on win — but
|
||||||
|
/// the field is stored explicitly so the playback UI can read it
|
||||||
|
/// directly without re-deriving "the last move was the win" each
|
||||||
|
/// time, and to leave room for future recording semantics that
|
||||||
|
/// might capture post-win state.
|
||||||
|
///
|
||||||
|
/// `None` for replays loaded from disk that pre-date this field.
|
||||||
|
/// `#[serde(default)]` keeps older `latest_replay.json` /
|
||||||
|
/// `replays.json` files loadable without bumping
|
||||||
|
/// [`REPLAY_SCHEMA_VERSION`] — this is an additive optional
|
||||||
|
/// field, not a schema-breaking change.
|
||||||
|
///
|
||||||
|
/// Surfaced by the replay-overlay scrub bar's WIN MOVE marker
|
||||||
|
/// (B-2 screen-takeover redesign) when present.
|
||||||
|
#[serde(default)]
|
||||||
|
pub win_move_index: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Replay {
|
impl Replay {
|
||||||
/// Construct a fresh replay with the current schema version. The
|
/// Construct a fresh replay with the current schema version. The
|
||||||
/// caller fills in the recorded fields; this is the canonical
|
/// caller fills in the recorded fields; this is the canonical
|
||||||
/// constructor used by the engine on win.
|
/// constructor used by the engine on win.
|
||||||
|
///
|
||||||
|
/// [`win_move_index`](Self::win_move_index) and
|
||||||
|
/// [`share_url`](Self::share_url) default to `None` — the engine
|
||||||
|
/// uses [`with_win_move_index`](Self::with_win_move_index) at the
|
||||||
|
/// recording site to set the former, and `sync_plugin` writes the
|
||||||
|
/// latter directly when the upload task resolves.
|
||||||
pub fn new(
|
pub fn new(
|
||||||
seed: u64,
|
seed: u64,
|
||||||
draw_mode: DrawMode,
|
draw_mode: DrawMode,
|
||||||
@@ -172,8 +198,24 @@ impl Replay {
|
|||||||
recorded_at,
|
recorded_at,
|
||||||
moves,
|
moves,
|
||||||
share_url: None,
|
share_url: None,
|
||||||
|
win_move_index: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builder-style setter for [`win_move_index`](Self::win_move_index).
|
||||||
|
/// Returns `self` so the recording site can chain it onto
|
||||||
|
/// [`Replay::new`]:
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// let replay = Replay::new(...).with_win_move_index(Some(recording.moves.len() - 1));
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// `None` is a valid input — useful for tests that don't care about
|
||||||
|
/// the WIN MOVE marker's scrub-bar position.
|
||||||
|
pub fn with_win_move_index(mut self, idx: Option<usize>) -> Self {
|
||||||
|
self.win_move_index = idx;
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rolling history of the player's most recent winning replays.
|
/// Rolling history of the player's most recent winning replays.
|
||||||
@@ -737,4 +779,71 @@ mod tests {
|
|||||||
|
|
||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// win_move_index — additive optional field for the WIN MOVE marker
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replay_new_defaults_win_move_index_to_none() {
|
||||||
|
let r = sample_replay();
|
||||||
|
assert_eq!(r.win_move_index, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_win_move_index_sets_value() {
|
||||||
|
let r = sample_replay().with_win_move_index(Some(3));
|
||||||
|
assert_eq!(r.win_move_index, Some(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_win_move_index_accepts_none() {
|
||||||
|
// Passing None through the builder is a valid no-op — useful for
|
||||||
|
// tests / synthetic replays that don't care about the marker.
|
||||||
|
let r = sample_replay().with_win_move_index(None);
|
||||||
|
assert_eq!(r.win_move_index, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replay_with_win_move_index_round_trips_on_disk() {
|
||||||
|
let path = tmp_path("win_move_index_round_trip");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
let original = sample_replay().with_win_move_index(Some(3));
|
||||||
|
save_latest_replay_to(&path, &original).expect("save");
|
||||||
|
let loaded = load_latest_replay_from(&path).expect("load");
|
||||||
|
assert_eq!(loaded.win_move_index, Some(3));
|
||||||
|
assert_eq!(loaded, original);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Older replay files written before this field was added must still
|
||||||
|
/// load — `#[serde(default)]` keeps `win_move_index` optional and
|
||||||
|
/// defaults missing fields to `None`. This is the contract that lets
|
||||||
|
/// us add the field without bumping `REPLAY_SCHEMA_VERSION`.
|
||||||
|
#[test]
|
||||||
|
fn replay_without_win_move_index_loads_with_none() {
|
||||||
|
let path = tmp_path("legacy_no_win_move_index");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
// Hand-rolled minimal v2 replay JSON with no win_move_index field.
|
||||||
|
let v2_no_field = r#"{
|
||||||
|
"schema_version": 2,
|
||||||
|
"seed": 1,
|
||||||
|
"draw_mode": "DrawOne",
|
||||||
|
"mode": "Classic",
|
||||||
|
"time_seconds": 60,
|
||||||
|
"final_score": 100,
|
||||||
|
"recorded_at": "2026-05-02",
|
||||||
|
"moves": []
|
||||||
|
}"#;
|
||||||
|
fs::write(&path, v2_no_field).expect("write fixture");
|
||||||
|
|
||||||
|
let loaded = load_latest_replay_from(&path).expect("load");
|
||||||
|
assert_eq!(loaded.win_move_index, None);
|
||||||
|
assert_eq!(loaded.schema_version, REPLAY_SCHEMA_VERSION);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1445,6 +1445,7 @@ mod tests {
|
|||||||
replay: dummy_replay(),
|
replay: dummy_replay(),
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
secs_to_next: 0.0,
|
secs_to_next: 0.0,
|
||||||
|
paused: false,
|
||||||
};
|
};
|
||||||
app.update();
|
app.update();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1480,6 +1481,7 @@ mod tests {
|
|||||||
replay: dummy_replay(),
|
replay: dummy_replay(),
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
secs_to_next: 0.0,
|
secs_to_next: 0.0,
|
||||||
|
paused: false,
|
||||||
};
|
};
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -1512,6 +1514,7 @@ mod tests {
|
|||||||
replay: dummy_replay(),
|
replay: dummy_replay(),
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
secs_to_next: 0.0,
|
secs_to_next: 0.0,
|
||||||
|
paused: false,
|
||||||
};
|
};
|
||||||
app.update();
|
app.update();
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||||
@@ -1534,6 +1537,7 @@ mod tests {
|
|||||||
replay: dummy_replay(),
|
replay: dummy_replay(),
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
secs_to_next: 0.0,
|
secs_to_next: 0.0,
|
||||||
|
paused: false,
|
||||||
};
|
};
|
||||||
app.update();
|
app.update();
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||||
@@ -1559,6 +1563,7 @@ mod tests {
|
|||||||
replay: dummy_replay(),
|
replay: dummy_replay(),
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
secs_to_next: 0.0,
|
secs_to_next: 0.0,
|
||||||
|
paused: false,
|
||||||
};
|
};
|
||||||
app.update();
|
app.update();
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ 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::{
|
use crate::events::{
|
||||||
AchievementUnlockedEvent, GameWonEvent, InfoToastEvent, MoveRejectedEvent, XpAwardedEvent,
|
AchievementUnlockedEvent, GameWonEvent, InfoToastEvent, MoveRejectedEvent, WarningToastEvent,
|
||||||
|
XpAwardedEvent,
|
||||||
};
|
};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
@@ -164,6 +165,7 @@ impl Plugin for AnimationPlugin {
|
|||||||
.add_message::<SettingsChangedEvent>()
|
.add_message::<SettingsChangedEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.add_message::<MoveRejectedEvent>()
|
.add_message::<MoveRejectedEvent>()
|
||||||
|
.add_message::<WarningToastEvent>()
|
||||||
.add_message::<XpAwardedEvent>()
|
.add_message::<XpAwardedEvent>()
|
||||||
.init_resource::<EffectiveSlideDuration>()
|
.init_resource::<EffectiveSlideDuration>()
|
||||||
.init_resource::<ToastQueue>()
|
.init_resource::<ToastQueue>()
|
||||||
@@ -186,6 +188,7 @@ impl Plugin for AnimationPlugin {
|
|||||||
handle_auto_complete_toast,
|
handle_auto_complete_toast,
|
||||||
handle_xp_awarded_toast,
|
handle_xp_awarded_toast,
|
||||||
handle_move_rejected_toast,
|
handle_move_rejected_toast,
|
||||||
|
handle_warning_toast,
|
||||||
tick_toasts,
|
tick_toasts,
|
||||||
(enqueue_toasts, drive_toast_display).chain(),
|
(enqueue_toasts, drive_toast_display).chain(),
|
||||||
)
|
)
|
||||||
@@ -651,6 +654,23 @@ fn handle_move_rejected_toast(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawns a 4-second amber-bordered Warning toast for every incoming
|
||||||
|
/// [`WarningToastEvent`]. First in-engine consumer of
|
||||||
|
/// [`ToastVariant::Warning`] — exercises the variant's amber accent and
|
||||||
|
/// the design-system "act soon" semantic.
|
||||||
|
///
|
||||||
|
/// Mirrors [`handle_move_rejected_toast`] but reads a generic carrier
|
||||||
|
/// event (not a domain-specific one) because Warning has multiple
|
||||||
|
/// candidate drivers and the call-site knows the message wording.
|
||||||
|
fn handle_warning_toast(
|
||||||
|
mut commands: Commands,
|
||||||
|
mut events: MessageReader<WarningToastEvent>,
|
||||||
|
) {
|
||||||
|
for ev in events.read() {
|
||||||
|
spawn_toast(&mut commands, ev.0.clone(), 4.0, ToastVariant::Warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Ticks down `ToastTimer` on each toast and despawns it when the timer expires.
|
/// Ticks down `ToastTimer` on each toast and despawns it when the timer expires.
|
||||||
///
|
///
|
||||||
/// Skipped while the game is paused so toast countdowns freeze along with the
|
/// Skipped while the game is paused so toast countdowns freeze along with the
|
||||||
|
|||||||
@@ -14,13 +14,13 @@
|
|||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||||
use chrono::{Local, NaiveDate};
|
use chrono::{DateTime, Duration, Local, NaiveDate, Utc};
|
||||||
use solitaire_data::{daily_seed_for, save_progress_to};
|
use solitaire_data::{daily_seed_for, save_progress_to};
|
||||||
use solitaire_sync::ChallengeGoal;
|
use solitaire_sync::ChallengeGoal;
|
||||||
|
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartDailyChallengeRequestEvent,
|
GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartDailyChallengeRequestEvent,
|
||||||
XpAwardedEvent,
|
WarningToastEvent, XpAwardedEvent,
|
||||||
};
|
};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
||||||
@@ -30,6 +30,11 @@ use crate::sync_plugin::SyncProviderResource;
|
|||||||
/// Bonus XP awarded for completing today's daily challenge.
|
/// Bonus XP awarded for completing today's daily challenge.
|
||||||
pub const DAILY_BONUS_XP: u64 = 100;
|
pub const DAILY_BONUS_XP: u64 = 100;
|
||||||
|
|
||||||
|
/// Minutes before UTC midnight at which the daily-challenge expiry warning
|
||||||
|
/// fires. The reset is global (UTC), so the warning is global too — local
|
||||||
|
/// midnight may be hours away or already past.
|
||||||
|
pub const DAILY_EXPIRY_WARNING_MINUTES: i64 = 30;
|
||||||
|
|
||||||
/// The active daily challenge — date + RNG seed for that date's deal,
|
/// The active daily challenge — date + RNG seed for that date's deal,
|
||||||
/// plus optional goal metadata fetched from the server.
|
/// plus optional goal metadata fetched from the server.
|
||||||
#[derive(Resource, Debug, Clone)]
|
#[derive(Resource, Debug, Clone)]
|
||||||
@@ -74,6 +79,16 @@ pub struct DailyChallengeCompletedEvent {
|
|||||||
#[derive(Resource, Default)]
|
#[derive(Resource, Default)]
|
||||||
struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>);
|
struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>);
|
||||||
|
|
||||||
|
/// Tracks which `DailyChallengeResource::date` the expiry-warning toast has
|
||||||
|
/// already fired for, so the toast spawns at most once per day.
|
||||||
|
///
|
||||||
|
/// `None` until the first warning fires; thereafter holds the date the
|
||||||
|
/// warning was shown for. When `daily.date` advances (a new local day rolls
|
||||||
|
/// over while the app stays open), this becomes stale and the next warning
|
||||||
|
/// can fire.
|
||||||
|
#[derive(Resource, Default, Debug)]
|
||||||
|
struct DailyExpiryWarningShown(Option<NaiveDate>);
|
||||||
|
|
||||||
/// Fetches today's daily challenge seed and goal from the sync server on startup and tracks completion.
|
/// Fetches today's daily challenge seed and goal from the sync server on startup and tracks completion.
|
||||||
/// Fires `DailyChallengeCompletedEvent` when the player wins a matching game.
|
/// Fires `DailyChallengeCompletedEvent` when the player wins a matching game.
|
||||||
pub struct DailyChallengePlugin;
|
pub struct DailyChallengePlugin;
|
||||||
@@ -82,18 +97,21 @@ impl Plugin for DailyChallengePlugin {
|
|||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.insert_resource(DailyChallengeResource::for_today())
|
app.insert_resource(DailyChallengeResource::for_today())
|
||||||
.init_resource::<DailyChallengeTask>()
|
.init_resource::<DailyChallengeTask>()
|
||||||
|
.init_resource::<DailyExpiryWarningShown>()
|
||||||
.add_message::<DailyChallengeCompletedEvent>()
|
.add_message::<DailyChallengeCompletedEvent>()
|
||||||
.add_message::<DailyGoalAnnouncementEvent>()
|
.add_message::<DailyGoalAnnouncementEvent>()
|
||||||
.add_message::<GameWonEvent>()
|
.add_message::<GameWonEvent>()
|
||||||
.add_message::<NewGameRequestEvent>()
|
.add_message::<NewGameRequestEvent>()
|
||||||
.add_message::<StartDailyChallengeRequestEvent>()
|
.add_message::<StartDailyChallengeRequestEvent>()
|
||||||
|
.add_message::<WarningToastEvent>()
|
||||||
.add_message::<XpAwardedEvent>()
|
.add_message::<XpAwardedEvent>()
|
||||||
.add_systems(Startup, fetch_server_challenge)
|
.add_systems(Startup, fetch_server_challenge)
|
||||||
.add_systems(Update, poll_server_challenge)
|
.add_systems(Update, poll_server_challenge)
|
||||||
// record/award after the base ProgressUpdate so we don't fight
|
// record/award after the base ProgressUpdate so we don't fight
|
||||||
// ProgressPlugin's add_xp on the same frame.
|
// ProgressPlugin's add_xp on the same frame.
|
||||||
.add_systems(Update, handle_daily_completion.after(ProgressUpdate))
|
.add_systems(Update, handle_daily_completion.after(ProgressUpdate))
|
||||||
.add_systems(Update, handle_start_daily_request.before(GameMutation));
|
.add_systems(Update, handle_start_daily_request.before(GameMutation))
|
||||||
|
.add_systems(Update, check_daily_expiry_warning);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +233,71 @@ fn handle_start_daily_request(
|
|||||||
announce.write(DailyGoalAnnouncementEvent(desc));
|
announce.write(DailyGoalAnnouncementEvent(desc));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pure decision logic for the daily-challenge expiry warning. Returns the
|
||||||
|
/// integer minutes-until-UTC-midnight if a warning toast should fire on this
|
||||||
|
/// frame, or `None` if any suppression condition holds.
|
||||||
|
///
|
||||||
|
/// Suppression rules (in order):
|
||||||
|
/// 1. Player has already completed today's daily challenge.
|
||||||
|
/// 2. The warning has already fired for `daily_date`.
|
||||||
|
/// 3. UTC midnight is more than [`DAILY_EXPIRY_WARNING_MINUTES`] away.
|
||||||
|
/// 4. UTC midnight has already passed for the current calendar day (the
|
||||||
|
/// minutes-remaining is negative — happens for at most one frame at the
|
||||||
|
/// rollover boundary).
|
||||||
|
///
|
||||||
|
/// Factored out so the threshold/clock behavior is unit-testable without an
|
||||||
|
/// `App`.
|
||||||
|
fn compute_expiry_warning_minutes(
|
||||||
|
daily_date: NaiveDate,
|
||||||
|
last_completed: Option<NaiveDate>,
|
||||||
|
last_shown: Option<NaiveDate>,
|
||||||
|
now_utc: DateTime<Utc>,
|
||||||
|
threshold_mins: i64,
|
||||||
|
) -> Option<i64> {
|
||||||
|
if last_completed == Some(daily_date) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if last_shown == Some(daily_date) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let next_midnight = (now_utc.date_naive() + Duration::days(1))
|
||||||
|
.and_hms_opt(0, 0, 0)?
|
||||||
|
.and_utc();
|
||||||
|
let mins_remaining = (next_midnight - now_utc).num_minutes();
|
||||||
|
if !(0..=threshold_mins).contains(&mins_remaining) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(mins_remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each-frame check for the daily-challenge expiry warning. Fires a single
|
||||||
|
/// [`WarningToastEvent`] when the player is within
|
||||||
|
/// [`DAILY_EXPIRY_WARNING_MINUTES`] of UTC midnight reset and hasn't yet
|
||||||
|
/// completed today's challenge.
|
||||||
|
///
|
||||||
|
/// Idempotent — `DailyExpiryWarningShown` ensures the toast spawns at most
|
||||||
|
/// once per `daily.date`.
|
||||||
|
fn check_daily_expiry_warning(
|
||||||
|
daily: Res<DailyChallengeResource>,
|
||||||
|
progress: Res<ProgressResource>,
|
||||||
|
mut shown: ResMut<DailyExpiryWarningShown>,
|
||||||
|
mut warning: MessageWriter<WarningToastEvent>,
|
||||||
|
) {
|
||||||
|
let Some(mins) = compute_expiry_warning_minutes(
|
||||||
|
daily.date,
|
||||||
|
progress.0.daily_challenge_last_completed,
|
||||||
|
shown.0,
|
||||||
|
Utc::now(),
|
||||||
|
DAILY_EXPIRY_WARNING_MINUTES,
|
||||||
|
) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
shown.0 = Some(daily.date);
|
||||||
|
warning.write(WarningToastEvent(format!(
|
||||||
|
"Daily challenge expires in {mins} min"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -385,4 +468,141 @@ mod tests {
|
|||||||
assert_eq!(r.target_score, Some(1_000));
|
assert_eq!(r.target_score, Some(1_000));
|
||||||
assert_eq!(r.max_time_secs, Some(300));
|
assert_eq!(r.max_time_secs, Some(300));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Daily-expiry warning toast (compute_expiry_warning_minutes + system)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn ymd(y: i32, m: u32, d: u32) -> NaiveDate {
|
||||||
|
NaiveDate::from_ymd_opt(y, m, d).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct a UTC `DateTime` at the given calendar position. Used to
|
||||||
|
/// drive the pure helper through every threshold edge.
|
||||||
|
fn utc_at(y: i32, m: u32, d: u32, h: u32, min: u32) -> DateTime<Utc> {
|
||||||
|
ymd(y, m, d).and_hms_opt(h, min, 0).unwrap().and_utc()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn warning_fires_inside_threshold_when_incomplete_and_unseen() {
|
||||||
|
// 23:50 UTC, 10 min until reset, < 30 min threshold.
|
||||||
|
let now = utc_at(2026, 5, 8, 23, 50);
|
||||||
|
let mins = compute_expiry_warning_minutes(ymd(2026, 5, 8), None, None, now, 30);
|
||||||
|
assert_eq!(mins, Some(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn warning_fires_at_exact_threshold_boundary() {
|
||||||
|
// 23:30 UTC, exactly 30 min remaining — the inclusive boundary.
|
||||||
|
let now = utc_at(2026, 5, 8, 23, 30);
|
||||||
|
let mins = compute_expiry_warning_minutes(ymd(2026, 5, 8), None, None, now, 30);
|
||||||
|
assert_eq!(mins, Some(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn warning_suppressed_outside_threshold() {
|
||||||
|
// 23:00 UTC, 60 min remaining — outside the 30 min window.
|
||||||
|
let now = utc_at(2026, 5, 8, 23, 0);
|
||||||
|
let mins = compute_expiry_warning_minutes(ymd(2026, 5, 8), None, None, now, 30);
|
||||||
|
assert_eq!(mins, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn warning_suppressed_when_already_completed_today() {
|
||||||
|
// 23:50 UTC inside threshold, but today is already done.
|
||||||
|
let now = utc_at(2026, 5, 8, 23, 50);
|
||||||
|
let mins = compute_expiry_warning_minutes(
|
||||||
|
ymd(2026, 5, 8),
|
||||||
|
Some(ymd(2026, 5, 8)),
|
||||||
|
None,
|
||||||
|
now,
|
||||||
|
30,
|
||||||
|
);
|
||||||
|
assert_eq!(mins, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn warning_suppressed_when_yesterdays_completion_is_stale() {
|
||||||
|
// Yesterday's completion is irrelevant — we want to warn about today.
|
||||||
|
let now = utc_at(2026, 5, 8, 23, 50);
|
||||||
|
let mins = compute_expiry_warning_minutes(
|
||||||
|
ymd(2026, 5, 8),
|
||||||
|
Some(ymd(2026, 5, 7)),
|
||||||
|
None,
|
||||||
|
now,
|
||||||
|
30,
|
||||||
|
);
|
||||||
|
assert_eq!(mins, Some(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn warning_suppressed_when_already_shown_for_this_date() {
|
||||||
|
let now = utc_at(2026, 5, 8, 23, 50);
|
||||||
|
let mins = compute_expiry_warning_minutes(
|
||||||
|
ymd(2026, 5, 8),
|
||||||
|
None,
|
||||||
|
Some(ymd(2026, 5, 8)),
|
||||||
|
now,
|
||||||
|
30,
|
||||||
|
);
|
||||||
|
assert_eq!(mins, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn warning_fires_when_last_shown_was_yesterday() {
|
||||||
|
// Player kept the app open across a midnight rollover. Stale
|
||||||
|
// "shown" date doesn't suppress today's warning.
|
||||||
|
let now = utc_at(2026, 5, 8, 23, 50);
|
||||||
|
let mins = compute_expiry_warning_minutes(
|
||||||
|
ymd(2026, 5, 8),
|
||||||
|
None,
|
||||||
|
Some(ymd(2026, 5, 7)),
|
||||||
|
now,
|
||||||
|
30,
|
||||||
|
);
|
||||||
|
assert_eq!(mins, Some(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_system_fires_warning_event_only_once_per_day() {
|
||||||
|
// The pure helper is exhaustively tested above. This test verifies
|
||||||
|
// the system that consumes it correctly stores the "shown" date so
|
||||||
|
// the WarningToastEvent fires at most once per `daily.date`, even
|
||||||
|
// when the system runs many frames in a row inside the threshold.
|
||||||
|
//
|
||||||
|
// The system reads `Utc::now()` directly, so we can't pin the clock.
|
||||||
|
// Instead, we simulate the post-warning state by pre-populating
|
||||||
|
// `DailyExpiryWarningShown` with `daily.date` and asserting nothing
|
||||||
|
// fires; then we verify the symmetric "completed today" suppression.
|
||||||
|
let mut app = headless_app();
|
||||||
|
let today = app.world().resource::<DailyChallengeResource>().date;
|
||||||
|
|
||||||
|
// Pre-mark warning as already shown for today.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<DailyExpiryWarningShown>()
|
||||||
|
.0 = Some(today);
|
||||||
|
app.update();
|
||||||
|
let events = app.world().resource::<Messages<WarningToastEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
assert!(
|
||||||
|
cursor.read(events).next().is_none(),
|
||||||
|
"no warning fires when DailyExpiryWarningShown already covers today"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset shown, mark today as completed.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<DailyExpiryWarningShown>()
|
||||||
|
.0 = None;
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ProgressResource>()
|
||||||
|
.0
|
||||||
|
.daily_challenge_last_completed = Some(today);
|
||||||
|
app.update();
|
||||||
|
let events = app.world().resource::<Messages<WarningToastEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
assert!(
|
||||||
|
cursor.read(events).next().is_none(),
|
||||||
|
"no warning fires when today is already completed"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,6 +212,21 @@ pub struct SyncCompleteEvent(pub Result<SyncResponse, String>);
|
|||||||
#[derive(Message, Debug, Clone)]
|
#[derive(Message, Debug, Clone)]
|
||||||
pub struct InfoToastEvent(pub String);
|
pub struct InfoToastEvent(pub String);
|
||||||
|
|
||||||
|
/// Generic warning toast message. Spawns a fire-and-forget
|
||||||
|
/// [`ToastVariant::Warning`](crate::animation_plugin::ToastVariant) toast.
|
||||||
|
///
|
||||||
|
/// Distinct from [`InfoToastEvent`] in two ways:
|
||||||
|
/// 1. **Variant.** Warning carries the design-system warning border accent,
|
||||||
|
/// not the neutral info accent — so the player can distinguish "you might
|
||||||
|
/// want to act" from "here's some neutral information".
|
||||||
|
/// 2. **No queue.** Warnings are alerts, not a stream. Each event spawns its
|
||||||
|
/// own toast immediately rather than waiting for the info queue to drain.
|
||||||
|
///
|
||||||
|
/// First in-engine driver: daily-challenge expiry warning fired by
|
||||||
|
/// `daily_challenge_plugin` when < 30 min from UTC midnight reset.
|
||||||
|
#[derive(Message, Debug, Clone)]
|
||||||
|
pub struct WarningToastEvent(pub String);
|
||||||
|
|
||||||
/// Fired by `ProgressPlugin` immediately after awarding XP for a win so the
|
/// Fired by `ProgressPlugin` immediately after awarding XP for a win so the
|
||||||
/// animation layer can display a "+N XP" toast alongside the win cascade.
|
/// animation layer can display a "+N XP" toast alongside the win cascade.
|
||||||
#[derive(Message, Debug, Clone, Copy)]
|
#[derive(Message, Debug, Clone, Copy)]
|
||||||
|
|||||||
@@ -936,6 +936,11 @@ pub fn record_replay_on_win(
|
|||||||
if recording.moves.is_empty() {
|
if recording.moves.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Recording freezes on win, so the move that triggered the
|
||||||
|
// win condition is the last one in the list. Storing the
|
||||||
|
// index explicitly lets the playback UI read the WIN MOVE
|
||||||
|
// position directly instead of re-deriving it on every render.
|
||||||
|
let win_move_index = recording.moves.len().checked_sub(1);
|
||||||
let replay = Replay::new(
|
let replay = Replay::new(
|
||||||
game.0.seed,
|
game.0.seed,
|
||||||
game.0.draw_mode.clone(),
|
game.0.draw_mode.clone(),
|
||||||
@@ -944,7 +949,8 @@ pub fn record_replay_on_win(
|
|||||||
ev.score,
|
ev.score,
|
||||||
Utc::now().date_naive(),
|
Utc::now().date_naive(),
|
||||||
recording.moves.clone(),
|
recording.moves.clone(),
|
||||||
);
|
)
|
||||||
|
.with_win_move_index(win_move_index);
|
||||||
let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) else {
|
let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) else {
|
||||||
// No persistence path configured (e.g. tests / minimal Linux
|
// No persistence path configured (e.g. tests / minimal Linux
|
||||||
// containers without dirs::data_dir). The in-memory replay
|
// containers without dirs::data_dir). The in-memory replay
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use crate::settings_plugin::SettingsResource;
|
|||||||
use crate::layout::HUD_BAND_HEIGHT;
|
use crate::layout::HUD_BAND_HEIGHT;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
|
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
|
||||||
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, MOTION_SCORE_PULSE_SECS,
|
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
|
||||||
MOTION_STREAK_FLOURISH_SECS, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS,
|
MOTION_STREAK_FLOURISH_SECS, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS,
|
||||||
STATE_WARNING, STREAK_FLOURISH_PEAK_SCALE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
|
STATE_WARNING, STREAK_FLOURISH_PEAK_SCALE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
|
||||||
TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
||||||
@@ -715,6 +715,7 @@ fn spawn_action_button<M: Component>(
|
|||||||
},
|
},
|
||||||
BackgroundColor(ACTION_BTN_IDLE),
|
BackgroundColor(ACTION_BTN_IDLE),
|
||||||
BorderColor::all(BORDER_SUBTLE),
|
BorderColor::all(BORDER_SUBTLE),
|
||||||
|
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||||
))
|
))
|
||||||
.with_children(|b| {
|
.with_children(|b| {
|
||||||
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
|
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ use crate::events::{
|
|||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::game_plugin::{GameOverScreen, GameStatePath};
|
use crate::game_plugin::{GameOverScreen, GameStatePath};
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
|
use crate::replay_playback::ReplayPlaybackState;
|
||||||
use crate::resources::{DragState, GameStateResource};
|
use crate::resources::{DragState, GameStateResource};
|
||||||
use crate::selection_plugin::{SelectionKeySet, SelectionState};
|
use crate::selection_plugin::{SelectionKeySet, SelectionState};
|
||||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
|
||||||
@@ -154,6 +155,7 @@ fn toggle_pause(
|
|||||||
mut drag: Option<ResMut<DragState>>,
|
mut drag: Option<ResMut<DragState>>,
|
||||||
mut changed: MessageWriter<StateChangedEvent>,
|
mut changed: MessageWriter<StateChangedEvent>,
|
||||||
selection: Option<Res<SelectionState>>,
|
selection: Option<Res<SelectionState>>,
|
||||||
|
replay_state: Option<Res<ReplayPlaybackState>>,
|
||||||
) {
|
) {
|
||||||
let PauseModalQueries {
|
let PauseModalQueries {
|
||||||
pause_screens: screens,
|
pause_screens: screens,
|
||||||
@@ -184,6 +186,15 @@ fn toggle_pause(
|
|||||||
if !other_modal_scrims.is_empty() {
|
if !other_modal_scrims.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// If a replay is currently playing, let `replay_overlay::handle_stop_keyboard`
|
||||||
|
// own the Esc press — that handler stops the replay. Without this guard a
|
||||||
|
// single Esc both stops the replay AND opens the pause modal on top of the
|
||||||
|
// (now empty) board, leaving the player on a screen they didn't ask for.
|
||||||
|
// The HUD-button path is gated too; clicking Pause while watching a replay
|
||||||
|
// is almost always an accident.
|
||||||
|
if replay_state.is_some_and(|s| s.is_playing()) {
|
||||||
|
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()) {
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ use crate::events::MoveRequestEvent;
|
|||||||
use crate::layout::{Layout, LayoutResource};
|
use crate::layout::{Layout, LayoutResource};
|
||||||
use crate::pause_plugin::PausedResource;
|
use crate::pause_plugin::PausedResource;
|
||||||
use crate::resources::{DragState, GameStateResource};
|
use crate::resources::{DragState, GameStateResource};
|
||||||
use crate::ui_theme::{ACCENT_PRIMARY, BORDER_STRONG, BORDER_SUBTLE, STATE_SUCCESS};
|
use crate::settings_plugin::SettingsResource;
|
||||||
|
use crate::ui_theme::{ACCENT_PRIMARY, BORDER_STRONG, BORDER_SUBTLE, BORDER_SUBTLE_HC, STATE_SUCCESS};
|
||||||
|
|
||||||
/// Sprite-space `Transform.z` for radial-menu overlay sprites.
|
/// Sprite-space `Transform.z` for radial-menu overlay sprites.
|
||||||
///
|
///
|
||||||
@@ -533,8 +534,17 @@ fn radial_handle_release_or_cancel(
|
|||||||
|
|
||||||
/// Despawns and respawns the radial overlay sprites every frame the
|
/// Despawns and respawns the radial overlay sprites every frame the
|
||||||
/// state is `Active`; despawns them when the state returns to `Idle`.
|
/// state is `Active`; despawns them when the state returns to `Idle`.
|
||||||
|
///
|
||||||
|
/// Reads [`SettingsResource`] so the focused-icon outline can boost to
|
||||||
|
/// [`BORDER_SUBTLE_HC`] under high-contrast mode. Per-frame respawn is
|
||||||
|
/// the simplest place to fold HC in: this is the only system that
|
||||||
|
/// owns the rim sprite, so there's no parallel paint path to fight.
|
||||||
|
/// ([`HighContrastBorder`](crate::ui_theme::HighContrastBorder) doesn't
|
||||||
|
/// apply because the rim is a `Sprite`, not a UI node with
|
||||||
|
/// `BorderColor`, and the entities don't persist across frames.)
|
||||||
fn radial_redraw_overlay(
|
fn radial_redraw_overlay(
|
||||||
state: Res<RightClickRadialState>,
|
state: Res<RightClickRadialState>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
existing_icons: Query<Entity, With<RadialIcon>>,
|
existing_icons: Query<Entity, With<RadialIcon>>,
|
||||||
existing_centres: Query<Entity, With<RadialCentre>>,
|
existing_centres: Query<Entity, With<RadialCentre>>,
|
||||||
@@ -569,13 +579,12 @@ fn radial_redraw_overlay(
|
|||||||
Transform::from_xyz(centre.x, centre.y, Z_RADIAL_MENU + 0.01),
|
Transform::from_xyz(centre.x, centre.y, Z_RADIAL_MENU + 0.01),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
|
||||||
for (i, (_pile, anchor)) in legal_destinations.iter().enumerate() {
|
for (i, (_pile, anchor)) in legal_destinations.iter().enumerate() {
|
||||||
let focused = *hovered_index == Some(i);
|
let focused = *hovered_index == Some(i);
|
||||||
let scale = if focused { RADIAL_HOVER_SCALE } else { 1.0 };
|
let scale = if focused { RADIAL_HOVER_SCALE } else { 1.0 };
|
||||||
let fill = if focused { STATE_SUCCESS } else { ACCENT_PRIMARY };
|
let fill = if focused { STATE_SUCCESS } else { ACCENT_PRIMARY };
|
||||||
// Hovered icon gets a strong yellow rim; resting icons get a
|
let outline = radial_rim_outline(focused, high_contrast);
|
||||||
// muted purple rim so the focused one reads as the obvious target.
|
|
||||||
let outline = if focused { BORDER_STRONG } else { BORDER_SUBTLE };
|
|
||||||
|
|
||||||
commands
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
@@ -606,6 +615,27 @@ fn radial_redraw_overlay(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pure decision logic for the radial-icon rim outline colour.
|
||||||
|
///
|
||||||
|
/// Resting icons always carry [`BORDER_SUBTLE`] so the focused icon
|
||||||
|
/// reads as the obvious target. Under high-contrast mode the focused
|
||||||
|
/// rim boosts to [`BORDER_SUBTLE_HC`] (`#a0a0a0`) instead of
|
||||||
|
/// [`BORDER_STRONG`] (`#505050`) — naive marker substitution via
|
||||||
|
/// [`HighContrastBorder`](crate::ui_theme::HighContrastBorder) would
|
||||||
|
/// invert the hierarchy because the resting colour
|
||||||
|
/// (`#353535`) is darker than `BORDER_STRONG`. This shape keeps the
|
||||||
|
/// focused rim *more* visible under HC, not less.
|
||||||
|
///
|
||||||
|
/// Factored out as a pure function so the truth-table is unit-testable
|
||||||
|
/// without spinning up the per-frame respawn system.
|
||||||
|
fn radial_rim_outline(focused: bool, high_contrast: bool) -> Color {
|
||||||
|
match (focused, high_contrast) {
|
||||||
|
(true, true) => BORDER_SUBTLE_HC,
|
||||||
|
(true, false) => BORDER_STRONG,
|
||||||
|
(false, _) => BORDER_SUBTLE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -940,4 +970,33 @@ mod tests {
|
|||||||
"face-down cards must not open the radial"
|
"face-down cards must not open the radial"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// radial_rim_outline — accessibility / high-contrast truth table
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rim_resting_uses_subtle_outline_without_hc() {
|
||||||
|
assert_eq!(radial_rim_outline(false, false), BORDER_SUBTLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rim_focused_uses_strong_outline_without_hc() {
|
||||||
|
assert_eq!(radial_rim_outline(true, false), BORDER_STRONG);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rim_focused_boosts_to_subtle_hc_under_hc() {
|
||||||
|
assert_eq!(radial_rim_outline(true, true), BORDER_SUBTLE_HC);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rim_resting_stays_subtle_under_hc_to_preserve_hierarchy() {
|
||||||
|
// Naive marker substitution would also flip the resting outline
|
||||||
|
// to BORDER_SUBTLE_HC, which is *lighter* than BORDER_STRONG —
|
||||||
|
// that would invert the focused/resting hierarchy. Holding the
|
||||||
|
// resting colour at BORDER_SUBTLE keeps the focused icon the
|
||||||
|
// obvious target under HC.
|
||||||
|
assert_eq!(radial_rim_outline(false, true), BORDER_SUBTLE);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,7 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_data::{Replay, ReplayMove};
|
use solitaire_data::{Replay, ReplayMove};
|
||||||
|
|
||||||
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent};
|
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
|
||||||
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;
|
use crate::settings_plugin::SettingsResource;
|
||||||
@@ -119,6 +119,15 @@ pub enum ReplayPlaybackState {
|
|||||||
cursor: usize,
|
cursor: usize,
|
||||||
/// Seconds remaining until the next move is dispatched.
|
/// Seconds remaining until the next move is dispatched.
|
||||||
secs_to_next: f32,
|
secs_to_next: f32,
|
||||||
|
/// `true` while playback is paused — `tick_replay_playback`
|
||||||
|
/// skips the `secs_to_next` decrement entirely while this is
|
||||||
|
/// set, so the cursor and the timer freeze together. The
|
||||||
|
/// overlay stays mounted (`is_playing()` still returns
|
||||||
|
/// `true`) so the player can see the paused state and the
|
||||||
|
/// Resume / Step controls. Stepping while paused fires the
|
||||||
|
/// next move directly via [`step_replay_playback`] and
|
||||||
|
/// leaves the paused flag untouched.
|
||||||
|
paused: bool,
|
||||||
},
|
},
|
||||||
/// The replay finished playing back. The overlay swaps the banner
|
/// The replay finished playing back. The overlay swaps the banner
|
||||||
/// label to "Replay complete" until [`auto_clear_completed_replay`]
|
/// label to "Replay complete" until [`auto_clear_completed_replay`]
|
||||||
@@ -194,6 +203,7 @@ pub fn start_replay_playback(
|
|||||||
replay,
|
replay,
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
secs_to_next: REPLAY_MOVE_INTERVAL_SECS,
|
secs_to_next: REPLAY_MOVE_INTERVAL_SECS,
|
||||||
|
paused: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +229,107 @@ pub fn stop_replay_playback(
|
|||||||
**state = ReplayPlaybackState::Inactive;
|
**state = ReplayPlaybackState::Inactive;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Toggle the `paused` flag on the active playback. No-op when not
|
||||||
|
/// `Playing` (i.e. `Inactive` or `Completed`) — pause has no meaning
|
||||||
|
/// in those states. Returns the new paused value, or `None` if the
|
||||||
|
/// state wasn't `Playing`.
|
||||||
|
pub fn toggle_pause_replay_playback(state: &mut ResMut<ReplayPlaybackState>) -> Option<bool> {
|
||||||
|
if let ReplayPlaybackState::Playing { paused, .. } = state.as_mut() {
|
||||||
|
*paused = !*paused;
|
||||||
|
Some(*paused)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance playback by exactly one move. Only meaningful while paused
|
||||||
|
/// — when called on an unpaused playback it would race the
|
||||||
|
/// `tick_replay_playback` loop. Returns `true` when a move was fired,
|
||||||
|
/// `false` when no-op (state isn't `Playing { paused: true }` or the
|
||||||
|
/// cursor is already at the end of the move list).
|
||||||
|
///
|
||||||
|
/// Stepping the last move transitions the state to `Completed` on
|
||||||
|
/// the next `tick_replay_playback` frame — same end-of-list path the
|
||||||
|
/// normal advance loop takes.
|
||||||
|
pub fn step_replay_playback(
|
||||||
|
state: &mut ResMut<ReplayPlaybackState>,
|
||||||
|
moves_writer: &mut MessageWriter<MoveRequestEvent>,
|
||||||
|
draws_writer: &mut MessageWriter<DrawRequestEvent>,
|
||||||
|
) -> bool {
|
||||||
|
let ReplayPlaybackState::Playing {
|
||||||
|
replay,
|
||||||
|
cursor,
|
||||||
|
paused: true,
|
||||||
|
..
|
||||||
|
} = state.as_mut()
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if *cursor >= replay.moves.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match &replay.moves[*cursor] {
|
||||||
|
ReplayMove::Move { from, to, count } => {
|
||||||
|
moves_writer.write(MoveRequestEvent {
|
||||||
|
from: from.clone(),
|
||||||
|
to: to.clone(),
|
||||||
|
count: *count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ReplayMove::StockClick => {
|
||||||
|
draws_writer.write(DrawRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*cursor += 1;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Steps the replay **backwards** by exactly one move while paused.
|
||||||
|
///
|
||||||
|
/// Strategy: the live game's undo system is the source of truth for
|
||||||
|
/// reversing moves. Every move the replay forward-stepped (via
|
||||||
|
/// [`step_replay_playback`] or the auto-advance loop in
|
||||||
|
/// [`tick_replay_playback`]) was dispatched as a canonical
|
||||||
|
/// [`MoveRequestEvent`] / [`DrawRequestEvent`], which the game
|
||||||
|
/// applied and pushed onto its undo stack. So a backwards step here
|
||||||
|
/// is simply: decrement the cursor (so the about-to-apply move
|
||||||
|
/// re-points at the one we're rewinding past) and fire an
|
||||||
|
/// [`UndoRequestEvent`] so the game reverses its most-recent move
|
||||||
|
/// next frame.
|
||||||
|
///
|
||||||
|
/// Hard-gated to the paused state via destructure pattern —
|
||||||
|
/// matches the existing [`step_replay_playback`] gate so the
|
||||||
|
/// player can only scrub one direction at a time and the tick
|
||||||
|
/// loop never races a manual rewind.
|
||||||
|
///
|
||||||
|
/// Returns `false` and is a no-op in three cases:
|
||||||
|
/// - State isn't `Playing` (no replay attached).
|
||||||
|
/// - State is `Playing` but not paused (the tick loop owns the cursor).
|
||||||
|
/// - Cursor is already at 0 (nothing to rewind past).
|
||||||
|
///
|
||||||
|
/// Returns `true` on a successful step; the actual game-state
|
||||||
|
/// reversal happens next frame when `handle_undo` reads the
|
||||||
|
/// `UndoRequestEvent`.
|
||||||
|
pub fn step_backwards_replay_playback(
|
||||||
|
state: &mut ResMut<ReplayPlaybackState>,
|
||||||
|
undo_writer: &mut MessageWriter<UndoRequestEvent>,
|
||||||
|
) -> bool {
|
||||||
|
let ReplayPlaybackState::Playing {
|
||||||
|
cursor,
|
||||||
|
paused: true,
|
||||||
|
..
|
||||||
|
} = state.as_mut()
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if *cursor == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
*cursor -= 1;
|
||||||
|
undo_writer.write(UndoRequestEvent);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Tick system. Runs every frame; only does work when
|
/// Tick system. Runs every frame; only does work when
|
||||||
/// [`ReplayPlaybackState::is_playing`].
|
/// [`ReplayPlaybackState::is_playing`].
|
||||||
///
|
///
|
||||||
@@ -249,28 +360,36 @@ fn tick_replay_playback(
|
|||||||
replay,
|
replay,
|
||||||
cursor,
|
cursor,
|
||||||
secs_to_next,
|
secs_to_next,
|
||||||
|
paused,
|
||||||
} = state.as_mut()
|
} = state.as_mut()
|
||||||
{
|
{
|
||||||
*secs_to_next -= dt;
|
// While paused, the cursor and the timer freeze together —
|
||||||
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
|
// skip the decrement entirely so resuming starts the next
|
||||||
match &replay.moves[*cursor] {
|
// move from a full `secs_to_next` window. Stepping (handled
|
||||||
ReplayMove::Move { from, to, count } => {
|
// separately) fires moves directly without touching this
|
||||||
moves_writer.write(MoveRequestEvent {
|
// path.
|
||||||
from: from.clone(),
|
if !*paused {
|
||||||
to: to.clone(),
|
*secs_to_next -= dt;
|
||||||
count: *count,
|
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
|
||||||
});
|
match &replay.moves[*cursor] {
|
||||||
}
|
ReplayMove::Move { from, to, count } => {
|
||||||
ReplayMove::StockClick => {
|
moves_writer.write(MoveRequestEvent {
|
||||||
draws_writer.write(DrawRequestEvent);
|
from: from.clone(),
|
||||||
|
to: to.clone(),
|
||||||
|
count: *count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ReplayMove::StockClick => {
|
||||||
|
draws_writer.write(DrawRequestEvent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
*cursor += 1;
|
||||||
|
*secs_to_next += interval;
|
||||||
}
|
}
|
||||||
*cursor += 1;
|
|
||||||
*secs_to_next += interval;
|
|
||||||
}
|
|
||||||
|
|
||||||
if *cursor >= replay.moves.len() {
|
if *cursor >= replay.moves.len() {
|
||||||
transition_to_completed = true;
|
transition_to_completed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ use crate::ui_modal::{
|
|||||||
};
|
};
|
||||||
use crate::ui_tooltip::Tooltip;
|
use crate::ui_tooltip::Tooltip;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, BORDER_SUBTLE_HC, HighContrastBorder,
|
BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, BORDER_SUBTLE_HC, HighContrastBackground,
|
||||||
|
HighContrastBorder,
|
||||||
RADIUS_SM, SPACE_2, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
|
RADIUS_SM, SPACE_2, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
|
||||||
TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
|
TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
|
||||||
};
|
};
|
||||||
@@ -365,6 +366,7 @@ impl Plugin for SettingsPlugin {
|
|||||||
update_color_blind_text,
|
update_color_blind_text,
|
||||||
update_high_contrast_text,
|
update_high_contrast_text,
|
||||||
update_high_contrast_borders,
|
update_high_contrast_borders,
|
||||||
|
update_high_contrast_backgrounds,
|
||||||
update_reduce_motion_text,
|
update_reduce_motion_text,
|
||||||
update_tooltip_delay_text,
|
update_tooltip_delay_text,
|
||||||
update_time_bonus_multiplier_text,
|
update_time_bonus_multiplier_text,
|
||||||
@@ -674,6 +676,41 @@ fn update_high_contrast_borders(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Repaints `BackgroundColor` on every entity tagged with
|
||||||
|
/// [`HighContrastBackground`] based on `Settings::high_contrast_mode`.
|
||||||
|
/// Off → the marker's `default_color`; on → `BORDER_SUBTLE_HC`
|
||||||
|
/// (`#a0a0a0`). Compares against the current background and only
|
||||||
|
/// mutates when different so Bevy's change-detection doesn't trigger
|
||||||
|
/// repaints every frame.
|
||||||
|
///
|
||||||
|
/// Parallel to [`update_high_contrast_borders`]. Same on/off rule,
|
||||||
|
/// same change-suppression idiom, different colour channel —
|
||||||
|
/// `BackgroundColor` for tick marks, decorative strips, fine
|
||||||
|
/// separators that paint their shape directly rather than via a
|
||||||
|
/// `BorderColor` on a wider Node.
|
||||||
|
///
|
||||||
|
/// Tagged sites in v0.21.x: the replay overlay's 1 px scrub track
|
||||||
|
/// + 5 quarter-mark notch ticks (`replay_overlay::spawn_overlay`).
|
||||||
|
///
|
||||||
|
/// More sites can be tagged in follow-ups by adding
|
||||||
|
/// `HighContrastBackground::with_default(...)` to their spawn tuple.
|
||||||
|
pub(crate) fn update_high_contrast_backgrounds(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut backgrounds: Query<(&HighContrastBackground, &mut BackgroundColor)>,
|
||||||
|
) {
|
||||||
|
let high_contrast = settings.0.high_contrast_mode;
|
||||||
|
for (marker, mut bg) in backgrounds.iter_mut() {
|
||||||
|
let target = if high_contrast {
|
||||||
|
BORDER_SUBTLE_HC
|
||||||
|
} else {
|
||||||
|
marker.default_color
|
||||||
|
};
|
||||||
|
if bg.0 != target {
|
||||||
|
*bg = BackgroundColor(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn update_reduce_motion_text(
|
fn update_reduce_motion_text(
|
||||||
settings: Res<SettingsResource>,
|
settings: Res<SettingsResource>,
|
||||||
mut text_nodes: Query<&mut Text, With<ReduceMotionText>>,
|
mut text_nodes: Query<&mut Text, With<ReduceMotionText>>,
|
||||||
|
|||||||
@@ -372,6 +372,7 @@ pub fn spawn_modal_button<M: Component>(
|
|||||||
},
|
},
|
||||||
BackgroundColor(idle_bg(variant)),
|
BackgroundColor(idle_bg(variant)),
|
||||||
BorderColor::all(BORDER_SUBTLE),
|
BorderColor::all(BORDER_SUBTLE),
|
||||||
|
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||||
))
|
))
|
||||||
.with_children(|b| {
|
.with_children(|b| {
|
||||||
b.spawn((Text::new(label.into()), font_label, TextColor(label_color)));
|
b.spawn((Text::new(label.into()), font_label, TextColor(label_color)));
|
||||||
|
|||||||
@@ -252,6 +252,35 @@ impl HighContrastBorder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Marker for entities whose [`BackgroundColor`] should swap to
|
||||||
|
/// [`BORDER_SUBTLE_HC`] when `Settings::high_contrast_mode` is on.
|
||||||
|
/// Parallel to [`HighContrastBorder`] but for sites that paint their
|
||||||
|
/// shape via `BackgroundColor` rather than `BorderColor` —
|
||||||
|
/// `bevy::ui` 1 px decorative strips, tick marks, fine separators
|
||||||
|
/// often render as tiny full-bleed `Node`s, not as borders, so the
|
||||||
|
/// border-marker pattern doesn't apply.
|
||||||
|
///
|
||||||
|
/// `default_color` records the off-state colour the entity was
|
||||||
|
/// spawned with so the system can revert when HC is toggled back
|
||||||
|
/// off. The accompanying paint system is
|
||||||
|
/// [`update_high_contrast_backgrounds`](crate::settings_plugin::update_high_contrast_backgrounds).
|
||||||
|
///
|
||||||
|
/// [`BackgroundColor`]: bevy::prelude::BackgroundColor
|
||||||
|
#[derive(bevy::prelude::Component, Debug, Clone, Copy)]
|
||||||
|
pub struct HighContrastBackground {
|
||||||
|
/// Background colour to use when high-contrast mode is *off* —
|
||||||
|
/// the site's normal idle / active-state colour.
|
||||||
|
pub default_color: bevy::prelude::Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HighContrastBackground {
|
||||||
|
/// Convenience constructor —
|
||||||
|
/// `HighContrastBackground::with_default(BORDER_SUBTLE)`.
|
||||||
|
pub const fn with_default(default_color: bevy::prelude::Color) -> Self {
|
||||||
|
Self { default_color }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Strong border — hover outline, focused button, active popover.
|
/// Strong border — hover outline, focused button, active popover.
|
||||||
/// `outline` from the design system. `#505050`.
|
/// `outline` from the design system. `#505050`.
|
||||||
pub const BORDER_STRONG: Color = Color::srgba(0.314, 0.314, 0.314, 1.0);
|
pub const BORDER_STRONG: Color = Color::srgba(0.314, 0.314, 0.314, 1.0);
|
||||||
|
|||||||
Reference in New Issue
Block a user