Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 23ff62c397 | |||
| 0b2ffca016 | |||
| fbe48acef6 | |||
| cd79877933 | |||
| 52befa6199 | |||
| e63046700c | |||
| ab857bbb6e | |||
| 886e0cf8a1 |
+122
-1
@@ -6,9 +6,130 @@ project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
No threads in flight. v0.21.3 cut on 2026-05-08; CHANGELOG accumulates
|
||||
No threads in flight. v0.21.4 cut on 2026-05-08; CHANGELOG accumulates
|
||||
the next cycle here.
|
||||
|
||||
## [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:
|
||||
|
||||
+99
-73
@@ -1,73 +1,91 @@
|
||||
# Solitaire Quest — Session Handoff
|
||||
|
||||
**Last updated:** 2026-05-08 — v0.21.2 cut and tagged at `f23df3b`;
|
||||
post-cut work shipped: Toast Warning (`279e23d`) and the HC
|
||||
dynamic-paint rollout (`c153363`). Working tree clean, all
|
||||
post-tag work pushed to origin.
|
||||
**Last updated:** 2026-05-08 — **v0.21.3 cut and tagged at
|
||||
`3d92a91`**, working tree clean, all post-tag work pushed to
|
||||
origin.
|
||||
|
||||
v0.21.2 is a patch release for the post-v0.21.1 polish work:
|
||||
extends accessibility (full HC chrome rollout across 8 surfaces;
|
||||
splash reduce-motion gating on scanline + cursor pulse), adds a
|
||||
floating MOVE chip above the destination card during replay
|
||||
playback, and lights up the first real consumer of
|
||||
`ToastVariant::Error` (a "Invalid move" toast as the third leg
|
||||
of the existing audio + visual rejection-feedback stool).
|
||||
v0.21.3 is a patch release with 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).
|
||||
|
||||
Full v0.21.2 detail lives in `CHANGELOG.md` § [0.21.2]. This
|
||||
Full v0.21.3 detail lives in `CHANGELOG.md` § [0.21.3]. This
|
||||
file from here on focuses on what's *open* post-cut and how to
|
||||
resume.
|
||||
|
||||
## Status at pause
|
||||
|
||||
- **HEAD locally:** see `git rev-parse HEAD`. The cut commit is
|
||||
`f23df3b`; post-cut work (`279e23d` Toast Warning, `c153363`
|
||||
HC dynamic-paint rollout) rides on top of that.
|
||||
- **HEAD on origin:** matches local. v0.21.2 is fully on origin.
|
||||
`3d92a91`; post-cut work on B-2 (`ab857bb` data field +
|
||||
`52befa6` WIN MOVE marker UI + `fbe48ac` playback controls)
|
||||
rides on top of that.
|
||||
- **HEAD on origin:** matches local. v0.21.3 is fully on origin.
|
||||
- **Working tree:** clean. No WIP outstanding.
|
||||
- **`artwork/` directory:** still untracked. Intentional.
|
||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
|
||||
clean.
|
||||
- **Tests:** **1207 passing / 0 failing** across the workspace
|
||||
(net +12 from the v0.21.2 cut: 8 from Toast Warning wiring;
|
||||
4 from the radial-rim HC truth-table).
|
||||
- **Tags on origin:** `v0.9.0` through `v0.21.2`. v0.21.2 is on
|
||||
`f23df3b`; v0.21.1 stays on `daa655a`; v0.21.0 stays on
|
||||
`04f9bf9`; v0.20.0 stays on `41a009a`.
|
||||
- **Tests:** **1228 passing / 0 failing** across the workspace
|
||||
(1207 from v0.21.3's stats + 5 from `ab857bb`'s
|
||||
`win_move_index` coverage + 8 from `52befa6`'s WIN MOVE marker
|
||||
pure-helper truth-table + spawn lifecycle + 8 from `fbe48ac`'s
|
||||
pause / step / keyboard accelerator coverage).
|
||||
- **Tags on origin:** `v0.9.0` through `v0.21.3`. v0.21.3 is on
|
||||
`3d92a91`; v0.21.2 stays on `f23df3b`; v0.21.1 stays on
|
||||
`daa655a`; v0.21.0 stays on `04f9bf9`; v0.20.0 stays on
|
||||
`41a009a`.
|
||||
|
||||
## Since the v0.21.2 cut
|
||||
## Since the v0.21.3 cut
|
||||
|
||||
- **`279e23d` — Toast Warning variant wired.** First in-engine
|
||||
consumer of `ToastVariant::Warning`: a 4 s amber-bordered
|
||||
toast that fires once per daily-challenge date when the
|
||||
player is within 30 min of UTC midnight reset and hasn't yet
|
||||
completed today's challenge. Mirrors the v0.21.2 Toast Error
|
||||
pattern — a domain message (`WarningToastEvent(String)`) is
|
||||
the contract between the daily plugin and the animation
|
||||
plugin's spawn handler. Suppression decided by a pure helper
|
||||
(`compute_expiry_warning_minutes`) that's exhaustively tested
|
||||
without an `App`. After this commit every `ToastVariant`
|
||||
(Info / Warning / Error / Celebration) has at least one real
|
||||
driver — the variant enum is fully load-bearing.
|
||||
- **`c153363` — HC rollout to the dynamic-paint sites.** Closes
|
||||
the v0.21.2 carve-out. Re-reading the code revealed only one
|
||||
of three "dynamic-paint" sites was actually a border-paint
|
||||
cycle — HUD action buttons and modal buttons paint
|
||||
*backgrounds* dynamically with static borders, so they take
|
||||
the existing `HighContrastBorder` marker pattern cleanly. The
|
||||
radial menu rim is the only true dynamic-painter (full
|
||||
per-frame respawn of `Sprite` entities); HC is folded into
|
||||
the spawn there with a pure helper (`radial_rim_outline`)
|
||||
that boosts the *focused* rim to `BORDER_SUBTLE_HC` under HC
|
||||
rather than `BORDER_STRONG` — naive marker substitution would
|
||||
invert the focused-vs-resting hierarchy because
|
||||
`BORDER_SUBTLE_HC` (#a0a0a0) is lighter than `BORDER_STRONG`
|
||||
(#505050). After this commit, every UI surface in the v0.21.x
|
||||
accessibility arc either carries the marker or has HC folded
|
||||
into its own spawn cycle. No "un-tagged because race-risk"
|
||||
surfaces remain.
|
||||
|
||||
For the v0.21.2 contents themselves, see `CHANGELOG.md` §
|
||||
[0.21.2].
|
||||
- **`ab857bb` — `Replay::win_move_index` data field landed.**
|
||||
First finite step toward the B-2 replay screen-takeover
|
||||
redesign. Additive optional `Option<usize>` on `Replay` with
|
||||
`#[serde(default)]` so older `latest_replay.json` /
|
||||
`replays.json` files load unchanged (no schema bump). Populated
|
||||
at the live recording site via a new `with_win_move_index`
|
||||
builder; 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. 5 new
|
||||
tests (1207 → 1212): default, builder set / set-None, on-disk
|
||||
round-trip, legacy-JSON-loads-with-None backward-compat.
|
||||
- **`52befa6` — WIN MOVE marker on the scrub bar.** Second
|
||||
commit on B-2 — the UI that consumes the data field. New
|
||||
`ReplayOverlayWinMoveMarker` component spawned as a sibling
|
||||
to `ReplayOverlayScrubFill` under the 1px scrub track,
|
||||
absolute-positioned at `replay.win_move_index / total` along
|
||||
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. Lifecycle is spawn-time only — the marker is
|
||||
immutable during a single playback because the underlying
|
||||
`Replay` doesn't change while `Playing`. Despawned with the
|
||||
overlay tree on transition back to `Inactive`. 8 new tests
|
||||
(1212 → 1220): pure-helper truth table + spawn-presence /
|
||||
spawn-absence / despawn-lifecycle observables.
|
||||
- **`fbe48ac` — playback controls (pause / resume / step).**
|
||||
Third commit on B-2. 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. New public API:
|
||||
`toggle_pause_replay_playback` and `step_replay_playback`
|
||||
(the latter hard-gated to `Playing { paused: true }` so
|
||||
manual stepping can't race the tick loop). UI: Pause /
|
||||
Resume button (label repaints reactively via
|
||||
`update_pause_button_label` which walks `Children` from
|
||||
marker to inner `Text`) + Step button + Space keyboard
|
||||
accelerator. Existing 25 `Playing { ... }` construction
|
||||
sites across tests gained `paused: false` mechanically.
|
||||
8 new tests (1220 → 1228): label truth table, label repaint
|
||||
on state change, click-toggles-paused, step advances exactly
|
||||
one cursor with paused preserved, step-while-running no-op,
|
||||
Space toggles paused.
|
||||
|
||||
## Open punch list
|
||||
|
||||
@@ -105,11 +123,14 @@ palette refresh all shipped in v0.20.0 + v0.21.0. What stays open:
|
||||
a WIN MOVE marker on the scrub bar. Banner-local pieces all
|
||||
shipped in v0.21.0 (`c84d9f4` + `6204db8` + `54005d5` +
|
||||
`e080b49`); the floating MOVE chip above the focused card
|
||||
shipped in v0.21.2 (`2fb2d63`). The screen-takeover is a
|
||||
multi-session redesign with data-layer impact — needs a new
|
||||
`win_move_index: Option<usize>` field on `Replay` (currently
|
||||
unimplemented), a move-log scroller, and a mini-tableau
|
||||
preview.
|
||||
shipped in v0.21.2 (`2fb2d63`). The WIN MOVE scrub-bar marker
|
||||
shipped post-v0.21.3 in `ab857bb` (data field) + `52befa6`
|
||||
(UI). Playback controls (pause / resume / step + Space
|
||||
accelerator) shipped post-v0.21.3 in `fbe48ac`. What still
|
||||
needs to land: a move-log scroller and a mini-tableau
|
||||
preview — both screen-takeover-only pieces that need a
|
||||
larger layout reflow than the existing banner can carry.
|
||||
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
|
||||
@@ -252,22 +273,20 @@ into a v0.21.1 / v0.22.0 cut.
|
||||
```
|
||||
You are a senior Rust + Bevy developer working on Solitaire Quest.
|
||||
Working directory: <Rusty_Solitaire clone path on this machine>.
|
||||
Branch: master. v0.21.2 is tagged at f23df3b (cut 2026-05-08, a
|
||||
patch release rolling up accessibility extensions, replay polish,
|
||||
and the first real `ToastVariant::Error` consumer). v0.21.1 stays
|
||||
at daa655a, v0.21.0 at 04f9bf9. Working tree clean. Post-cut
|
||||
work shipped: Toast Warning variant (`279e23d`) and the HC
|
||||
dynamic-paint rollout (`c153363`) — accessibility arc is fully
|
||||
closed, every `ToastVariant` has at least one real driver. See
|
||||
CHANGELOG.md § [0.21.2] + the "Since the v0.21.2 cut" section
|
||||
above for full detail.
|
||||
Branch: master. v0.21.3 is tagged at 3d92a91 (cut 2026-05-08, a
|
||||
patch release rolling up the accessibility-arc closure: HC reaches
|
||||
the previously-carved-out dynamic-paint sites, and the first real
|
||||
consumer of `ToastVariant::Warning` lands as the daily-challenge
|
||||
expiry toast). v0.21.2 stays at f23df3b, v0.21.1 at daa655a,
|
||||
v0.21.0 at 04f9bf9. Working tree clean. See CHANGELOG.md §
|
||||
[0.21.3] for full detail.
|
||||
|
||||
State: HEAD locally — see `git rev-parse HEAD`. All workspace tests
|
||||
pass (1207+; check with `cargo test --workspace`), clippy clean.
|
||||
|
||||
READ FIRST (in order, before doing anything):
|
||||
1. SESSION_HANDOFF.md — this file
|
||||
2. CHANGELOG.md — [0.21.2] section is the most recent cut
|
||||
2. CHANGELOG.md — [0.21.3] section is the most recent cut
|
||||
3. CLAUDE.md — unified-3.0 rule set
|
||||
4. CLAUDE_SPEC.md — formal architecture spec
|
||||
5. ARCHITECTURE.md — crate responsibilities + data flow
|
||||
@@ -288,10 +307,17 @@ DECISION TO ASK THE PLAYER FIRST:
|
||||
and Android Keystore stubs that need real bridges. Larger
|
||||
scope; needs an Android device or emulator running.
|
||||
B. Replay-overlay screen-takeover redesign — multi-session
|
||||
work: move-log scroller, mini-tableau preview, WIN MOVE
|
||||
marker on the scrub bar (needs new `Replay::win_move_index`
|
||||
field), playback controls. The smaller floating-MOVE-chip
|
||||
piece of B already shipped in v0.21.2 (`2fb2d63`).
|
||||
work. Three sub-pieces shipped post-v0.21.3: WIN MOVE
|
||||
marker (`ab857bb` data field + `52befa6` UI), playback
|
||||
controls (`fbe48ac` pause/resume/step + Space). What
|
||||
still needs to land: a move-log scroller and a
|
||||
mini-tableau preview — both layout-heavy pieces that need
|
||||
more vertical real estate than the current banner-only
|
||||
overlay carries, so the natural next finite step is the
|
||||
screen-takeover layout itself (mockup at
|
||||
`docs/ui-mockups/replay-overlay-mobile.html`). The
|
||||
smaller floating-MOVE-chip piece shipped in v0.21.2
|
||||
(`2fb2d63`).
|
||||
C. Phase 8 (sync) — local storage scaffolding, self-hosted
|
||||
Axum server, `SolitaireServerClient` impl, GPGS stub
|
||||
wired into Settings. The biggest open arc by scope; rolls
|
||||
|
||||
@@ -147,12 +147,38 @@ pub struct Replay {
|
||||
/// [`REPLAY_SCHEMA_VERSION`].
|
||||
#[serde(default)]
|
||||
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 {
|
||||
/// Construct a fresh replay with the current schema version. The
|
||||
/// caller fills in the recorded fields; this is the canonical
|
||||
/// 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(
|
||||
seed: u64,
|
||||
draw_mode: DrawMode,
|
||||
@@ -172,8 +198,24 @@ impl Replay {
|
||||
recorded_at,
|
||||
moves,
|
||||
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.
|
||||
@@ -737,4 +779,71 @@ mod tests {
|
||||
|
||||
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(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
app.update();
|
||||
assert!(
|
||||
@@ -1480,6 +1481,7 @@ mod tests {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
app.update();
|
||||
|
||||
@@ -1512,6 +1514,7 @@ mod tests {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
@@ -1534,6 +1537,7 @@ mod tests {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
@@ -1559,6 +1563,7 @@ mod tests {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
|
||||
@@ -936,6 +936,11 @@ pub fn record_replay_on_win(
|
||||
if recording.moves.is_empty() {
|
||||
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(
|
||||
game.0.seed,
|
||||
game.0.draw_mode.clone(),
|
||||
@@ -944,7 +949,8 @@ pub fn record_replay_on_win(
|
||||
ev.score,
|
||||
Utc::now().date_naive(),
|
||||
recording.moves.clone(),
|
||||
);
|
||||
)
|
||||
.with_win_move_index(win_move_index);
|
||||
let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) else {
|
||||
// No persistence path configured (e.g. tests / minimal Linux
|
||||
// containers without dirs::data_dir). The in-memory replay
|
||||
|
||||
@@ -28,12 +28,15 @@ use chrono::Datelike;
|
||||
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::layout::LayoutResource;
|
||||
use crate::replay_playback::{stop_replay_playback, ReplayPlaybackState};
|
||||
use crate::events::{DrawRequestEvent, MoveRequestEvent};
|
||||
use crate::replay_playback::{
|
||||
step_replay_playback, stop_replay_playback, toggle_pause_replay_playback, ReplayPlaybackState,
|
||||
};
|
||||
use solitaire_data::ReplayMove;
|
||||
use crate::ui_modal::{spawn_modal_button, ButtonVariant};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
|
||||
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_4, Z_DROP_OVERLAY,
|
||||
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY,
|
||||
TYPE_BODY, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_4, Z_DROP_OVERLAY,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -111,6 +114,24 @@ pub struct ReplayFloatingProgressChip;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayStopButton;
|
||||
|
||||
/// Marker on the Pause / Resume button. Click handler queries for this
|
||||
/// and calls [`toggle_pause_replay_playback`] on each press. The
|
||||
/// button's label text is repainted in lockstep by
|
||||
/// `update_pause_button_label` so it always reflects the action the
|
||||
/// next click will perform ("Pause" while running, "Resume" while
|
||||
/// paused).
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayPauseButton;
|
||||
|
||||
/// Marker on the Step button. Click handler queries for this and
|
||||
/// calls [`step_replay_playback`] — only meaningful when paused
|
||||
/// (clicks while running are no-ops because the tick loop would race
|
||||
/// the manual advance). The button stays visually present but
|
||||
/// unresponsive while the playback is running so the player has a
|
||||
/// stable layout to scan.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayStepButton;
|
||||
|
||||
/// Marker on the small caption sitting below the "▌ replay"
|
||||
/// headline. Carries `GAME #YYYY-DDD` (year + chrono ordinal) while a
|
||||
/// replay is playing — a compact, monotonically-increasing identifier
|
||||
@@ -135,6 +156,23 @@ pub struct ReplayOverlayGameCaption;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayOverlayScrubFill;
|
||||
|
||||
/// Marker for the WIN MOVE tick on the scrub bar — a small absolute-
|
||||
/// positioned `Node` anchored at `replay.win_move_index / total` along
|
||||
/// the track. Painted in [`STATE_SUCCESS`] so the player can see at a
|
||||
/// glance where the winning move sits relative to the playback cursor.
|
||||
///
|
||||
/// Static — the position is set at spawn time and never changes during
|
||||
/// playback (the underlying replay's `win_move_index` is immutable
|
||||
/// while `Playing`). Despawned with the rest of the overlay tree when
|
||||
/// the replay state transitions back to `Inactive`.
|
||||
///
|
||||
/// Spawned only when the active replay carries
|
||||
/// [`Replay::win_move_index`](solitaire_data::Replay::win_move_index)
|
||||
/// `= Some(_)` — older replays loaded from disk pre-date the field
|
||||
/// and have no win index to surface.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayOverlayWinMoveMarker;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -160,18 +198,30 @@ impl Plugin for ReplayOverlayPlugin {
|
||||
// Putting Stop last means a click in frame N is observed by
|
||||
// `react_to_state_change` in frame N+1, which then despawns the
|
||||
// overlay in response — a clean state-driven loop.
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
react_to_state_change,
|
||||
update_banner_label,
|
||||
update_progress_text,
|
||||
update_floating_progress_chip,
|
||||
update_scrub_fill,
|
||||
handle_stop_button,
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
// Step-button handler dispatches into the same canonical move
|
||||
// / draw events that the tick loop fires. Register them
|
||||
// defensively here so this plugin can run under
|
||||
// `MinimalPlugins` without the playback plugin attached;
|
||||
// `add_message` is idempotent so the duplicate registration
|
||||
// in production (alongside `replay_playback`) is harmless.
|
||||
app.add_message::<MoveRequestEvent>()
|
||||
.add_message::<DrawRequestEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
react_to_state_change,
|
||||
update_banner_label,
|
||||
update_progress_text,
|
||||
update_floating_progress_chip,
|
||||
update_scrub_fill,
|
||||
update_pause_button_label,
|
||||
handle_pause_button,
|
||||
handle_step_button,
|
||||
handle_pause_keyboard,
|
||||
handle_stop_button,
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,6 +407,27 @@ fn spawn_overlay(
|
||||
..default()
|
||||
})
|
||||
.with_children(|wrap| {
|
||||
// Pause / Resume label is set from the current
|
||||
// state so a freshly-spawned overlay (which
|
||||
// currently always starts unpaused) reads
|
||||
// "Pause". `update_pause_button_label`
|
||||
// repaints it whenever the state changes.
|
||||
spawn_modal_button(
|
||||
wrap,
|
||||
ReplayPauseButton,
|
||||
pause_button_label(state),
|
||||
None,
|
||||
ButtonVariant::Tertiary,
|
||||
font_res,
|
||||
);
|
||||
spawn_modal_button(
|
||||
wrap,
|
||||
ReplayStepButton,
|
||||
"Step",
|
||||
None,
|
||||
ButtonVariant::Tertiary,
|
||||
font_res,
|
||||
);
|
||||
spawn_modal_button(
|
||||
wrap,
|
||||
ReplayStopButton,
|
||||
@@ -375,6 +446,7 @@ fn spawn_overlay(
|
||||
// first-frame paint already reflects state instead of
|
||||
// popping from 0 → cursor on the first tick.
|
||||
let initial_scrub_pct = scrub_pct(state);
|
||||
let win_pct = win_move_marker_pct(state);
|
||||
banner
|
||||
.spawn((
|
||||
Node {
|
||||
@@ -394,6 +466,27 @@ fn spawn_overlay(
|
||||
},
|
||||
BackgroundColor(ACCENT_PRIMARY),
|
||||
));
|
||||
// WIN MOVE marker — small green tick anchored at
|
||||
// `win_move_index / total`. Spawned only when the
|
||||
// active replay carries the field; older replays
|
||||
// pre-dating `win_move_index` simply don't get a
|
||||
// marker. Centered vertically on the 1px track via
|
||||
// a 3px-tall node offset 1px above the track top so
|
||||
// 1px sits above and 1px below the track line.
|
||||
if let Some(pct) = win_pct {
|
||||
track.spawn((
|
||||
ReplayOverlayWinMoveMarker,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Percent(pct),
|
||||
top: Val::Px(-1.0),
|
||||
width: Val::Px(2.0),
|
||||
height: Val::Px(3.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(STATE_SUCCESS),
|
||||
));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -438,6 +531,33 @@ fn scrub_pct(state: &ReplayPlaybackState) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure helper — returns the WIN MOVE marker's left-edge position as
|
||||
/// a percentage of the scrub track, or `None` when no marker should
|
||||
/// be drawn.
|
||||
///
|
||||
/// `None` is returned in any of these cases:
|
||||
/// - The state isn't `Playing` (no replay attached).
|
||||
/// - The replay's `win_move_index` is `None` (older replay loaded
|
||||
/// from disk pre-dating the field).
|
||||
/// - The replay's move list is empty (shouldn't happen for real wins,
|
||||
/// but guards the divide-by-zero).
|
||||
///
|
||||
/// The percentage clamps to `[0, 100]` so a malformed
|
||||
/// `win_move_index >= total` (defensive — shouldn't happen) doesn't
|
||||
/// position the marker outside the track.
|
||||
fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option<f32> {
|
||||
let ReplayPlaybackState::Playing { replay, .. } = state else {
|
||||
return None;
|
||||
};
|
||||
let idx = replay.win_move_index?;
|
||||
let total = replay.moves.len();
|
||||
if total == 0 {
|
||||
return None;
|
||||
}
|
||||
let frac = (idx as f32 / total as f32).clamp(0.0, 1.0);
|
||||
Some(frac * 100.0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-frame text updates
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -604,9 +724,22 @@ fn format_progress(state: &ReplayPlaybackState) -> String {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stop button handler
|
||||
// Playback-control button handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pure helper — returns the label the Pause / Resume button should
|
||||
/// carry for the given state. "Pause" while running, "Resume" while
|
||||
/// paused, empty otherwise (the button is despawned with the rest of
|
||||
/// the overlay tree on transitions to `Inactive` / `Completed`, so
|
||||
/// the empty branch only fires for one frame around state changes).
|
||||
fn pause_button_label(state: &ReplayPlaybackState) -> &'static str {
|
||||
match state {
|
||||
ReplayPlaybackState::Playing { paused: true, .. } => "Resume",
|
||||
ReplayPlaybackState::Playing { paused: false, .. } => "Pause",
|
||||
ReplayPlaybackState::Inactive | ReplayPlaybackState::Completed => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// Watches the Stop button for `Interaction::Pressed` transitions. On a
|
||||
/// click, calls [`stop_replay_playback`] which resets the state to
|
||||
/// `Inactive`; the next frame's `react_to_state_change` then despawns
|
||||
@@ -622,6 +755,82 @@ fn handle_stop_button(
|
||||
stop_replay_playback(&mut commands, &mut state);
|
||||
}
|
||||
|
||||
/// Watches the Pause / Resume button for `Interaction::Pressed`
|
||||
/// transitions. On a click, toggles the `paused` flag via
|
||||
/// [`toggle_pause_replay_playback`]. The label repaint happens in
|
||||
/// [`update_pause_button_label`] on the same frame the state mutation
|
||||
/// flushes.
|
||||
fn handle_pause_button(
|
||||
mut state: ResMut<ReplayPlaybackState>,
|
||||
buttons: Query<&Interaction, (With<ReplayPauseButton>, Changed<Interaction>)>,
|
||||
) {
|
||||
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||
return;
|
||||
}
|
||||
toggle_pause_replay_playback(&mut state);
|
||||
}
|
||||
|
||||
/// Watches the Step button for `Interaction::Pressed` transitions. On
|
||||
/// a click, advances exactly one move via [`step_replay_playback`].
|
||||
/// No-op while playback is unpaused (would race the tick loop) — the
|
||||
/// guard lives inside `step_replay_playback`.
|
||||
fn handle_step_button(
|
||||
mut state: ResMut<ReplayPlaybackState>,
|
||||
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
||||
mut draws_writer: MessageWriter<DrawRequestEvent>,
|
||||
buttons: Query<&Interaction, (With<ReplayStepButton>, Changed<Interaction>)>,
|
||||
) {
|
||||
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||
return;
|
||||
}
|
||||
step_replay_playback(&mut state, &mut moves_writer, &mut draws_writer);
|
||||
}
|
||||
|
||||
/// Repaints the Pause / Resume button's label whenever
|
||||
/// [`ReplayPlaybackState`] changes. Walks from the marked button
|
||||
/// entity to its single child [`Text`] so the spawn path doesn't need
|
||||
/// a second marker on the inner node.
|
||||
fn update_pause_button_label(
|
||||
state: Res<ReplayPlaybackState>,
|
||||
buttons: Query<&Children, With<ReplayPauseButton>>,
|
||||
mut texts: Query<&mut Text>,
|
||||
) {
|
||||
if !state.is_changed() {
|
||||
return;
|
||||
}
|
||||
let label = pause_button_label(&state);
|
||||
if label.is_empty() {
|
||||
// Overlay is mid-teardown; the button entity will despawn
|
||||
// this frame anyway. Skip the repaint to avoid touching a
|
||||
// doomed entity.
|
||||
return;
|
||||
}
|
||||
for children in &buttons {
|
||||
for child in children.iter() {
|
||||
if let Ok(mut text) = texts.get_mut(child) {
|
||||
text.0 = label.to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Watches `Space` for the keyboard pause / resume accelerator.
|
||||
/// UI-first contract from CLAUDE.md §3.3 is satisfied by the on-
|
||||
/// screen Pause / Resume button; this is the optional accelerator.
|
||||
/// No-op when the playback isn't `Playing` (e.g. while a modal is
|
||||
/// open and the player is using `Space` for something else).
|
||||
fn handle_pause_keyboard(
|
||||
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||
mut state: ResMut<ReplayPlaybackState>,
|
||||
) {
|
||||
let Some(keys) = keys else { return };
|
||||
if !keys.just_pressed(KeyCode::Space) {
|
||||
return;
|
||||
}
|
||||
toggle_pause_replay_playback(&mut state);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -722,6 +931,7 @@ mod tests {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
);
|
||||
app.update();
|
||||
@@ -745,6 +955,7 @@ mod tests {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 5,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
);
|
||||
app.update();
|
||||
@@ -765,6 +976,7 @@ mod tests {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
);
|
||||
app.update();
|
||||
@@ -821,6 +1033,7 @@ mod tests {
|
||||
replay: synthetic_replay(5),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
);
|
||||
app.update();
|
||||
@@ -857,6 +1070,7 @@ mod tests {
|
||||
replay: synthetic_replay(3),
|
||||
cursor: 1,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
);
|
||||
app.update();
|
||||
@@ -884,6 +1098,7 @@ mod tests {
|
||||
replay: synthetic_replay(7),
|
||||
cursor: 7,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
},
|
||||
);
|
||||
app.update();
|
||||
@@ -937,6 +1152,7 @@ mod tests {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
}),
|
||||
0.0,
|
||||
);
|
||||
@@ -945,6 +1161,7 @@ mod tests {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 5,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
}),
|
||||
50.0,
|
||||
);
|
||||
@@ -953,6 +1170,7 @@ mod tests {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 10,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
}),
|
||||
100.0,
|
||||
);
|
||||
@@ -987,6 +1205,7 @@ mod tests {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 5,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
}),
|
||||
Some("GAME #2026-122".to_string()),
|
||||
);
|
||||
@@ -1000,6 +1219,7 @@ mod tests {
|
||||
replay: early_january,
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
}),
|
||||
Some("GAME #2026-005".to_string()),
|
||||
);
|
||||
@@ -1017,6 +1237,7 @@ mod tests {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
);
|
||||
app.update();
|
||||
@@ -1048,6 +1269,7 @@ mod tests {
|
||||
replay: synthetic_replay(8),
|
||||
cursor: 2,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
);
|
||||
app.update();
|
||||
@@ -1063,6 +1285,7 @@ mod tests {
|
||||
replay: synthetic_replay(8),
|
||||
cursor: 6,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
);
|
||||
app.update();
|
||||
@@ -1080,4 +1303,295 @@ mod tests {
|
||||
"Completed state must read as a fully-filled track",
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// win_move_marker_pct + ReplayOverlayWinMoveMarker spawn behaviour
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn win_marker_count(app: &mut App) -> usize {
|
||||
app.world_mut()
|
||||
.query::<&ReplayOverlayWinMoveMarker>()
|
||||
.iter(app.world())
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win_move_marker_pct_is_none_for_inactive() {
|
||||
assert_eq!(win_move_marker_pct(&ReplayPlaybackState::Inactive), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win_move_marker_pct_is_none_for_completed() {
|
||||
// `Completed` carries no replay so the marker has no data to
|
||||
// anchor against — the overlay treats this as "no marker".
|
||||
assert_eq!(win_move_marker_pct(&ReplayPlaybackState::Completed), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win_move_marker_pct_is_none_when_replay_lacks_field() {
|
||||
// Synthetic replay constructor leaves win_move_index as None
|
||||
// (legacy / pre-`ab857bb` path).
|
||||
let state = ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
};
|
||||
assert_eq!(win_move_marker_pct(&state), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win_move_marker_pct_is_some_at_correct_position() {
|
||||
// 10 moves, win at index 9 → marker sits at 90 % of the track.
|
||||
// Matches the recording semantic: cursor reaches the marker
|
||||
// exactly when the about-to-apply move IS the win move.
|
||||
let state = ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10).with_win_move_index(Some(9)),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
};
|
||||
assert_eq!(win_move_marker_pct(&state), Some(90.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win_move_marker_pct_clamps_to_track_bounds() {
|
||||
// Defensive: if a malformed replay carried `win_move_index >=
|
||||
// total`, the marker must still sit on the track, not past it.
|
||||
let state = ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(5).with_win_move_index(Some(99)),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
};
|
||||
assert_eq!(win_move_marker_pct(&state), Some(100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_spawned_when_replay_has_win_move_index() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(8).with_win_move_index(Some(7)),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
win_marker_count(&mut app),
|
||||
1,
|
||||
"marker entity must spawn when replay carries Some(win_move_index)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_not_spawned_when_replay_lacks_win_move_index() {
|
||||
let mut app = headless_app();
|
||||
// Default constructor → win_move_index: None (legacy replay).
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(8),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
win_marker_count(&mut app),
|
||||
0,
|
||||
"no marker should spawn for a replay pre-dating the field"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_despawns_when_replay_state_returns_to_inactive() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(8).with_win_move_index(Some(7)),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(win_marker_count(&mut app), 1);
|
||||
|
||||
set_state(&mut app, ReplayPlaybackState::Inactive);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
win_marker_count(&mut app),
|
||||
0,
|
||||
"marker must despawn with the rest of the overlay tree"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// pause_button_label + pause / step click handlers + keyboard accelerator
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Read the current text content of the unique pause / resume button.
|
||||
fn pause_button_text(app: &mut App) -> String {
|
||||
let world = app.world_mut();
|
||||
let mut button_q = world.query_filtered::<&Children, With<ReplayPauseButton>>();
|
||||
let children: Vec<Entity> = button_q
|
||||
.iter(world)
|
||||
.next()
|
||||
.map(|c| c.iter().collect())
|
||||
.unwrap_or_default();
|
||||
let mut text_q = world.query::<&Text>();
|
||||
for child in children {
|
||||
if let Ok(text) = text_q.get(world, child) {
|
||||
return text.0.clone();
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Find the unique entity carrying the given button marker.
|
||||
fn unique_button<M: Component>(app: &mut App) -> Entity {
|
||||
let world = app.world_mut();
|
||||
let mut q = world.query_filtered::<Entity, With<M>>();
|
||||
q.iter(world).next().expect("button entity must exist")
|
||||
}
|
||||
|
||||
fn pressed_paused_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState {
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(replay_len),
|
||||
cursor,
|
||||
secs_to_next: 0.5,
|
||||
paused: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn running_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState {
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(replay_len),
|
||||
cursor,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_button_label_reads_pause_when_running() {
|
||||
assert_eq!(pause_button_label(&running_state(5, 0)), "Pause");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_button_label_reads_resume_when_paused() {
|
||||
assert_eq!(pause_button_label(&pressed_paused_state(5, 0)), "Resume");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_button_label_is_empty_off_state() {
|
||||
assert_eq!(pause_button_label(&ReplayPlaybackState::Inactive), "");
|
||||
assert_eq!(pause_button_label(&ReplayPlaybackState::Completed), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_button_text_swaps_when_state_pauses() {
|
||||
let mut app = headless_app();
|
||||
set_state(&mut app, running_state(5, 0));
|
||||
app.update();
|
||||
assert_eq!(pause_button_text(&mut app), "Pause");
|
||||
|
||||
set_state(&mut app, pressed_paused_state(5, 0));
|
||||
app.update();
|
||||
assert_eq!(
|
||||
pause_button_text(&mut app),
|
||||
"Resume",
|
||||
"label must repaint to Resume on the frame the state pauses"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_button_click_toggles_paused_flag() {
|
||||
let mut app = headless_app();
|
||||
set_state(&mut app, running_state(5, 0));
|
||||
app.update();
|
||||
|
||||
let button = unique_button::<ReplayPauseButton>(&mut app);
|
||||
app.world_mut()
|
||||
.entity_mut(button)
|
||||
.insert(Interaction::Pressed);
|
||||
app.update();
|
||||
|
||||
match app.world().resource::<ReplayPlaybackState>() {
|
||||
ReplayPlaybackState::Playing { paused, .. } => {
|
||||
assert!(*paused, "click must flip running → paused");
|
||||
}
|
||||
other => panic!("expected Playing, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_button_click_advances_cursor_while_paused() {
|
||||
let mut app = headless_app();
|
||||
set_state(&mut app, pressed_paused_state(5, 0));
|
||||
app.update();
|
||||
|
||||
let button = unique_button::<ReplayStepButton>(&mut app);
|
||||
app.world_mut()
|
||||
.entity_mut(button)
|
||||
.insert(Interaction::Pressed);
|
||||
app.update();
|
||||
|
||||
match app.world().resource::<ReplayPlaybackState>() {
|
||||
ReplayPlaybackState::Playing { cursor, paused, .. } => {
|
||||
assert_eq!(*cursor, 1, "step must advance the cursor by exactly one");
|
||||
assert!(*paused, "step must leave the paused flag untouched");
|
||||
}
|
||||
other => panic!("expected Playing, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_button_click_is_noop_while_running() {
|
||||
let mut app = headless_app();
|
||||
set_state(&mut app, running_state(5, 0));
|
||||
app.update();
|
||||
|
||||
let button = unique_button::<ReplayStepButton>(&mut app);
|
||||
app.world_mut()
|
||||
.entity_mut(button)
|
||||
.insert(Interaction::Pressed);
|
||||
app.update();
|
||||
|
||||
match app.world().resource::<ReplayPlaybackState>() {
|
||||
ReplayPlaybackState::Playing { cursor, paused, .. } => {
|
||||
assert_eq!(*cursor, 0, "running-step must not race the tick loop");
|
||||
assert!(!*paused);
|
||||
}
|
||||
other => panic!("expected Playing, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_keyboard_toggles_paused_flag() {
|
||||
let mut app = headless_app();
|
||||
// The keyboard handler reads `Option<Res<ButtonInput<KeyCode>>>`
|
||||
// and no-ops when missing — provide it for this test.
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
set_state(&mut app, running_state(5, 0));
|
||||
app.update();
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::Space);
|
||||
app.update();
|
||||
|
||||
match app.world().resource::<ReplayPlaybackState>() {
|
||||
ReplayPlaybackState::Playing { paused, .. } => {
|
||||
assert!(*paused, "Space must toggle running → paused");
|
||||
}
|
||||
other => panic!("expected Playing, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,15 @@ pub enum ReplayPlaybackState {
|
||||
cursor: usize,
|
||||
/// Seconds remaining until the next move is dispatched.
|
||||
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
|
||||
/// label to "Replay complete" until [`auto_clear_completed_replay`]
|
||||
@@ -194,6 +203,7 @@ pub fn start_replay_playback(
|
||||
replay,
|
||||
cursor: 0,
|
||||
secs_to_next: REPLAY_MOVE_INTERVAL_SECS,
|
||||
paused: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -219,6 +229,61 @@ pub fn stop_replay_playback(
|
||||
**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
|
||||
}
|
||||
|
||||
/// Tick system. Runs every frame; only does work when
|
||||
/// [`ReplayPlaybackState::is_playing`].
|
||||
///
|
||||
@@ -249,28 +314,36 @@ fn tick_replay_playback(
|
||||
replay,
|
||||
cursor,
|
||||
secs_to_next,
|
||||
paused,
|
||||
} = state.as_mut()
|
||||
{
|
||||
*secs_to_next -= dt;
|
||||
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
|
||||
match &replay.moves[*cursor] {
|
||||
ReplayMove::Move { from, to, count } => {
|
||||
moves_writer.write(MoveRequestEvent {
|
||||
from: from.clone(),
|
||||
to: to.clone(),
|
||||
count: *count,
|
||||
});
|
||||
}
|
||||
ReplayMove::StockClick => {
|
||||
draws_writer.write(DrawRequestEvent);
|
||||
// While paused, the cursor and the timer freeze together —
|
||||
// skip the decrement entirely so resuming starts the next
|
||||
// move from a full `secs_to_next` window. Stepping (handled
|
||||
// separately) fires moves directly without touching this
|
||||
// path.
|
||||
if !*paused {
|
||||
*secs_to_next -= dt;
|
||||
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
|
||||
match &replay.moves[*cursor] {
|
||||
ReplayMove::Move { from, to, count } => {
|
||||
moves_writer.write(MoveRequestEvent {
|
||||
from: from.clone(),
|
||||
to: to.clone(),
|
||||
count: *count,
|
||||
});
|
||||
}
|
||||
ReplayMove::StockClick => {
|
||||
draws_writer.write(DrawRequestEvent);
|
||||
}
|
||||
}
|
||||
*cursor += 1;
|
||||
*secs_to_next += interval;
|
||||
}
|
||||
*cursor += 1;
|
||||
*secs_to_next += interval;
|
||||
}
|
||||
|
||||
if *cursor >= replay.moves.len() {
|
||||
transition_to_completed = true;
|
||||
if *cursor >= replay.moves.len() {
|
||||
transition_to_completed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user