Compare commits

..

8 Commits

Author SHA1 Message Date
funman300 23ff62c397 docs: cut v0.21.4 — replay-scrubbing accessibility
Patch release for the three post-v0.21.3 commits on the B-2 replay
screen-takeover redesign arc. One through-line: the replay overlay
gains scrubbing affordances. The player can see at a glance where
the winning move sits (WIN MOVE marker on the scrub bar) and stop
on any move to inspect the board (pause / resume / step controls
plus a Space keyboard accelerator).

Also adds the data foundation that makes the marker possible:
`Replay::win_move_index: Option<usize>`, an additive serde-default
field that doesn't bump `REPLAY_SCHEMA_VERSION` because legacy
on-disk replays load with `None` and simply don't get a marker.

Remaining B-2 work — screen-takeover layout, move-log scroller,
mini-tableau preview — shares a layout-reflow prerequisite the
banner-only overlay can't carry, so it's deferred to a future
cycle that can take it as a single multi-session arc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:26:54 -07:00
funman300 0b2ffca016 docs(handoff): record playback controls; B's next step is takeover layout
Captures `fbe48ac` (pause / resume / step + Space accelerator) under
"Since the v0.21.3 cut", marks playback controls closed in the
Visual-identity follow-ups list, identifies the screen-takeover
layout itself (with move-log scroller + mini-tableau preview as its
sub-pieces) as the next finite step on B, and bumps the test count
to 1228.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:21:48 -07:00
funman300 fbe48acef6 feat(replay): playback controls — pause / resume / step + Space accelerator
Third commit on the B-2 replay screen-takeover redesign. Adds the
ability to pause an in-flight replay, step through it one move at
a time while paused, and resume — both via on-screen buttons
(UI-first contract per CLAUDE.md §3.3) and the optional `Space`
keyboard accelerator.

State shape: a new `paused: bool` field on
`ReplayPlaybackState::Playing`. The `tick_replay_playback` system
skips the `secs_to_next` decrement entirely while `paused` is set
so cursor and timer freeze together — resuming starts the next
move from a full interval. Stepping fires the next move directly
via a new `step_replay_playback` API that bypasses the tick path
and is hard-gated to `Playing { paused: true }` so it can't race
the running tick loop.

Public API additions:
- `toggle_pause_replay_playback(state)` — flips the flag, returns
  the new value (or None when not Playing).
- `step_replay_playback(state, moves_writer, draws_writer)` —
  advances exactly one move when paused; returns true on dispatch,
  false on any guard miss.

UI:
- Pause / Resume button next to Stop. Label repaints reactively
  via `update_pause_button_label`, which walks `Children` from
  the marked button to its inner `Text` so the spawn path doesn't
  need a second marker.
- Step button next to Pause. Click fires the next move; while
  unpaused the click is a no-op (guarded inside
  `step_replay_playback`).
- `Space` keyboard handler reads `Option<Res<ButtonInput>>` and
  no-ops when missing — keeps test-app compatibility under
  `MinimalPlugins`.

Test coverage: pause-button label truth table, label repaint on
state change, click-toggles-paused, step advances cursor exactly
one with paused flag preserved, step-while-running is no-op,
Space toggles paused flag. 8 new tests (1220 → 1228).

Side-effect: 25 existing `Playing { ... }` construction sites
across `replay_overlay`, `achievement_plugin`, and
`replay_playback` tests gained `paused: false` to satisfy the new
field requirement. Mechanical edit; no behavioral change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:20:45 -07:00
funman300 cd79877933 docs(handoff): record WIN MOVE marker ship; B's next finite step
Captures `52befa6` (WIN MOVE marker on the scrub bar) under "Since
the v0.21.3 cut", marks the marker piece of B-2 closed in the
Visual-identity follow-ups list, identifies playback controls
(play/pause/step) as the next bounded commit on B, and bumps the
test count to 1220.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:54:44 -07:00
funman300 52befa6199 feat(replay): WIN MOVE marker on the scrub bar
Second commit on the B-2 replay screen-takeover redesign — the UI
that consumes the data field landed in `ab857bb`. Adds a small
green tick on the scrub bar at `replay.win_move_index / total`,
positioned so the playback cursor reaches the marker exactly when
the move it's about to apply IS the winning move.

Implementation: a new `ReplayOverlayWinMoveMarker` component
spawned alongside `ReplayOverlayScrubFill` as a sibling under the
1px scrub track. Position computed by a pure helper
`win_move_marker_pct` that returns `None` for any of: state not
`Playing`, replay's `win_move_index` is `None` (older replay
loaded from disk pre-dating the field), or empty move list. The
percentage is clamped to `[0, 100]` defensively. Marker is
absolute-positioned with `top: -1px` so the 3px-tall tick is
centered on the 1px track line — 1px above and 1px below.

Lifecycle is "spawn-time only" — the marker position never changes
during a single playback because the underlying replay is
immutable while `Playing`. Despawned with the rest of the overlay
tree when the state returns to `Inactive`.

8 new tests cover: pure helper for Inactive / Completed / no-field /
correct-position / clamp; spawn presence with field; spawn absence
without field; despawn-with-overlay lifecycle.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:53:40 -07:00
funman300 e63046700c docs(handoff): record win_move_index data field; B's next finite step
Captures `ab857bb` (Replay::win_move_index data field) under "Since
the v0.21.3 cut". Updates the Visual-identity follow-up entry for
B-2 to flag the data-layer prerequisite as landed and identifies
the WIN MOVE scrub-bar marker UI as the natural next finite commit.
Bumps test count to 1212.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:45:59 -07:00
funman300 ab857bbb6e feat(data): add Replay::win_move_index for the WIN MOVE scrub marker
First finite step toward the B-2 replay screen-takeover redesign:
the data foundation. Adds an additive optional `win_move_index:
Option<usize>` field on `Replay`, defaulting to `None` via
`#[serde(default)]` so older `latest_replay.json` /
`replays.json` files load unchanged — no `REPLAY_SCHEMA_VERSION`
bump needed since the field is purely additive and nullable.

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 the index
explicitly lets the playback UI read the WIN MOVE position
directly without re-deriving it on every render — and leaves
room for future recording semantics that capture post-win state.

UI consumption (the WIN MOVE marker on the scrub bar, plus the
broader screen-takeover redesign — move-log scroller, mini-
tableau preview, playback controls) lands in subsequent commits.

Test coverage: default value, builder set / set-None, on-disk
round-trip, and the legacy-JSON-loads-with-None backward-compat
contract (the test that pins the no-schema-bump claim).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:45:02 -07:00
funman300 886e0cf8a1 docs(handoff): refresh post-v0.21.3 — anchor to new tag, reset menu state
Anchors handoff to v0.21.3 at `3d92a91`, resets the "Since the cut"
section to placeholder, updates the READ FIRST CHANGELOG pointer,
and bumps the Resume-prompt summary to reflect the accessibility
arc closure as the v0.21.3 through-line. Resume menu stays at
A/B/C since v0.21.3 closes only post-v0.21.2 carve-outs (the
remaining options were already heavy / multi-session).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:41:02 -07:00
7 changed files with 962 additions and 108 deletions
+122 -1
View File
@@ -6,9 +6,130 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [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. 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 ## [0.21.3] — 2026-05-08
Patch release for the post-v0.21.2 work. One through-line: Patch release for the post-v0.21.2 work. One through-line:
+99 -73
View File
@@ -1,73 +1,91 @@
# Solitaire Quest — Session Handoff # Solitaire Quest — Session Handoff
**Last updated:** 2026-05-08 — v0.21.2 cut and tagged at `f23df3b`; **Last updated:** 2026-05-08 — **v0.21.3 cut and tagged at
post-cut work shipped: Toast Warning (`279e23d`) and the HC `3d92a91`**, working tree clean, all post-tag work pushed to
dynamic-paint rollout (`c153363`). Working tree clean, all origin.
post-tag work pushed to origin.
v0.21.2 is a patch release for the post-v0.21.1 polish work: v0.21.3 is a patch release with one through-line: **accessibility
extends accessibility (full HC chrome rollout across 8 surfaces; arc closure**. v0.21.2 explicitly carved out "dynamic-paint sites"
splash reduce-motion gating on scanline + cursor pulse), adds a (HUD action buttons, modal buttons, radial menu rim) on the
floating MOVE chip above the destination card during replay assumption that their existing paint cycles would race the
playback, and lights up the first real consumer of central `update_high_contrast_borders` system. v0.21.3 walks the
`ToastVariant::Error` (a "Invalid move" toast as the third leg actual code, finds the carve-out was over-cautious, and closes
of the existing audio + visual rejection-feedback stool). 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 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
`f23df3b`; post-cut work (`279e23d` Toast Warning, `c153363` `3d92a91`; post-cut work on B-2 (`ab857bb` data field +
HC dynamic-paint rollout) rides on top of that. `52befa6` WIN MOVE marker UI + `fbe48ac` playback controls)
- **HEAD on origin:** matches local. v0.21.2 is fully on origin. rides on top of that.
- **HEAD on origin:** matches local. v0.21.3 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:** **1207 passing / 0 failing** across the workspace - **Tests:** **1228 passing / 0 failing** across the workspace
(net +12 from the v0.21.2 cut: 8 from Toast Warning wiring; (1207 from v0.21.3's stats + 5 from `ab857bb`'s
4 from the radial-rim HC truth-table). `win_move_index` coverage + 8 from `52befa6`'s WIN MOVE marker
- **Tags on origin:** `v0.9.0` through `v0.21.2`. v0.21.2 is on pure-helper truth-table + spawn lifecycle + 8 from `fbe48ac`'s
`f23df3b`; v0.21.1 stays on `daa655a`; v0.21.0 stays on pause / step / keyboard accelerator coverage).
`04f9bf9`; v0.20.0 stays on `41a009a`. - **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 - **`ab857bb``Replay::win_move_index` data field landed.**
consumer of `ToastVariant::Warning`: a 4 s amber-bordered First finite step toward the B-2 replay screen-takeover
toast that fires once per daily-challenge date when the redesign. Additive optional `Option<usize>` on `Replay` with
player is within 30 min of UTC midnight reset and hasn't yet `#[serde(default)]` so older `latest_replay.json` /
completed today's challenge. Mirrors the v0.21.2 Toast Error `replays.json` files load unchanged (no schema bump). Populated
pattern — a domain message (`WarningToastEvent(String)`) is at the live recording site via a new `with_win_move_index`
the contract between the daily plugin and the animation builder; for fresh recordings the value is always
plugin's spawn handler. Suppression decided by a pure helper `Some(moves.len() - 1)` because recording freezes on win, but
(`compute_expiry_warning_minutes`) that's exhaustively tested storing it explicitly lets the playback UI read the WIN MOVE
without an `App`. After this commit every `ToastVariant` position directly without re-deriving on every render. 5 new
(Info / Warning / Error / Celebration) has at least one real tests (1207 → 1212): default, builder set / set-None, on-disk
driver — the variant enum is fully load-bearing. round-trip, legacy-JSON-loads-with-None backward-compat.
- **`c153363` — HC rollout to the dynamic-paint sites.** Closes - **`52befa6` — WIN MOVE marker on the scrub bar.** Second
the v0.21.2 carve-out. Re-reading the code revealed only one commit on B-2 — the UI that consumes the data field. New
of three "dynamic-paint" sites was actually a border-paint `ReplayOverlayWinMoveMarker` component spawned as a sibling
cycle — HUD action buttons and modal buttons paint to `ReplayOverlayScrubFill` under the 1px scrub track,
*backgrounds* dynamically with static borders, so they take absolute-positioned at `replay.win_move_index / total` along
the existing `HighContrastBorder` marker pattern cleanly. The the bar. Painted in `STATE_SUCCESS` (green) so the marker
radial menu rim is the only true dynamic-painter (full reads as "this is where the win lives." Pure helper
per-frame respawn of `Sprite` entities); HC is folded into `win_move_marker_pct` returns `None` for any state where the
the spawn there with a pure helper (`radial_rim_outline`) marker shouldn't draw (Inactive, Completed, replay missing
that boosts the *focused* rim to `BORDER_SUBTLE_HC` under HC the field, empty move list); percentage clamps to `[0, 100]`
rather than `BORDER_STRONG` — naive marker substitution would defensively. Lifecycle is spawn-time only — the marker is
invert the focused-vs-resting hierarchy because immutable during a single playback because the underlying
`BORDER_SUBTLE_HC` (#a0a0a0) is lighter than `BORDER_STRONG` `Replay` doesn't change while `Playing`. Despawned with the
(#505050). After this commit, every UI surface in the v0.21.x overlay tree on transition back to `Inactive`. 8 new tests
accessibility arc either carries the marker or has HC folded (1212 → 1220): pure-helper truth table + spawn-presence /
into its own spawn cycle. No "un-tagged because race-risk" spawn-absence / despawn-lifecycle observables.
surfaces remain. - **`fbe48ac` — playback controls (pause / resume / step).**
Third commit on B-2. New `paused: bool` field on
For the v0.21.2 contents themselves, see `CHANGELOG.md` § `ReplayPlaybackState::Playing`; `tick_replay_playback` skips
[0.21.2]. 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 ## 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 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 floating MOVE chip above the focused card `e080b49`); the floating MOVE chip above the focused card
shipped in v0.21.2 (`2fb2d63`). The screen-takeover is a shipped in v0.21.2 (`2fb2d63`). The WIN MOVE scrub-bar marker
multi-session redesign with data-layer impact — needs a new shipped post-v0.21.3 in `ab857bb` (data field) + `52befa6`
`win_move_index: Option<usize>` field on `Replay` (currently (UI). Playback controls (pause / resume / step + Space
unimplemented), a move-log scroller, and a mini-tableau accelerator) shipped post-v0.21.3 in `fbe48ac`. What still
preview. 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 - *Floating `MOVE N/M` chip above the focused card during
playback — closed 2026-05-08 by `2fb2d63`.* World-space playback — closed 2026-05-08 by `2fb2d63`.* World-space
`Text2d` entity sibling to the banner overlay; uses the same `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. 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.2 is tagged at f23df3b (cut 2026-05-08, a Branch: master. v0.21.3 is tagged at 3d92a91 (cut 2026-05-08, a
patch release rolling up accessibility extensions, replay polish, patch release rolling up the accessibility-arc closure: HC reaches
and the first real `ToastVariant::Error` consumer). v0.21.1 stays the previously-carved-out dynamic-paint sites, and the first real
at daa655a, v0.21.0 at 04f9bf9. Working tree clean. Post-cut consumer of `ToastVariant::Warning` lands as the daily-challenge
work shipped: Toast Warning variant (`279e23d`) and the HC expiry toast). v0.21.2 stays at f23df3b, v0.21.1 at daa655a,
dynamic-paint rollout (`c153363`) — accessibility arc is fully v0.21.0 at 04f9bf9. Working tree clean. See CHANGELOG.md §
closed, every `ToastVariant` has at least one real driver. See [0.21.3] for full detail.
CHANGELOG.md § [0.21.2] + the "Since the v0.21.2 cut" section
above for full detail.
State: HEAD locally — see `git rev-parse HEAD`. All workspace tests State: HEAD locally — see `git rev-parse HEAD`. All workspace tests
pass (1207+; check with `cargo test --workspace`), clippy clean. pass (1207+; check with `cargo test --workspace`), 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.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 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
@@ -288,10 +307,17 @@ DECISION TO ASK THE PLAYER FIRST:
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.
B. Replay-overlay screen-takeover redesign — multi-session B. Replay-overlay screen-takeover redesign — multi-session
work: move-log scroller, mini-tableau preview, WIN MOVE work. Three sub-pieces shipped post-v0.21.3: WIN MOVE
marker on the scrub bar (needs new `Replay::win_move_index` marker (`ab857bb` data field + `52befa6` UI), playback
field), playback controls. The smaller floating-MOVE-chip controls (`fbe48ac` pause/resume/step + Space). What
piece of B already shipped in v0.21.2 (`2fb2d63`). 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 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
+109
View File
@@ -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>() =
+7 -1
View File
@@ -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
+519 -5
View File
@@ -28,12 +28,15 @@ use chrono::Datelike;
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::layout::LayoutResource; 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 solitaire_data::ReplayMove;
use crate::ui_modal::{spawn_modal_button, ButtonVariant}; use crate::ui_modal::{spawn_modal_button, ButtonVariant};
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY,
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_4, Z_DROP_OVERLAY, 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)] #[derive(Component, Debug)]
pub struct ReplayStopButton; 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" /// Marker on the small caption sitting below the "▌ replay"
/// headline. Carries `GAME #YYYY-DDD` (year + chrono ordinal) while a /// headline. Carries `GAME #YYYY-DDD` (year + chrono ordinal) while a
/// replay is playing — a compact, monotonically-increasing identifier /// replay is playing — a compact, monotonically-increasing identifier
@@ -135,6 +156,23 @@ pub struct ReplayOverlayGameCaption;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct ReplayOverlayScrubFill; 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 // Plugin
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -160,7 +198,15 @@ impl Plugin for ReplayOverlayPlugin {
// Putting Stop last means a click in frame N is observed by // Putting Stop last means a click in frame N is observed by
// `react_to_state_change` in frame N+1, which then despawns the // `react_to_state_change` in frame N+1, which then despawns the
// overlay in response — a clean state-driven loop. // overlay in response — a clean state-driven loop.
app.add_systems( // 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, Update,
( (
react_to_state_change, react_to_state_change,
@@ -168,6 +214,10 @@ impl Plugin for ReplayOverlayPlugin {
update_progress_text, update_progress_text,
update_floating_progress_chip, update_floating_progress_chip,
update_scrub_fill, update_scrub_fill,
update_pause_button_label,
handle_pause_button,
handle_step_button,
handle_pause_keyboard,
handle_stop_button, handle_stop_button,
) )
.chain(), .chain(),
@@ -357,6 +407,27 @@ fn spawn_overlay(
..default() ..default()
}) })
.with_children(|wrap| { .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( spawn_modal_button(
wrap, wrap,
ReplayStopButton, ReplayStopButton,
@@ -375,6 +446,7 @@ fn spawn_overlay(
// first-frame paint already reflects state instead of // first-frame paint already reflects state instead of
// popping from 0 → cursor on the first tick. // popping from 0 → cursor on the first tick.
let initial_scrub_pct = scrub_pct(state); let initial_scrub_pct = scrub_pct(state);
let win_pct = win_move_marker_pct(state);
banner banner
.spawn(( .spawn((
Node { Node {
@@ -394,6 +466,27 @@ fn spawn_overlay(
}, },
BackgroundColor(ACCENT_PRIMARY), 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 // 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 /// Watches the Stop button for `Interaction::Pressed` transitions. On a
/// click, calls [`stop_replay_playback`] which resets the state to /// click, calls [`stop_replay_playback`] which resets the state to
/// `Inactive`; the next frame's `react_to_state_change` then despawns /// `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); 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 // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -722,6 +931,7 @@ mod tests {
replay: synthetic_replay(10), replay: synthetic_replay(10),
cursor: 0, cursor: 0,
secs_to_next: 0.5, secs_to_next: 0.5,
paused: false,
}, },
); );
app.update(); app.update();
@@ -745,6 +955,7 @@ mod tests {
replay: synthetic_replay(10), replay: synthetic_replay(10),
cursor: 5, cursor: 5,
secs_to_next: 0.5, secs_to_next: 0.5,
paused: false,
}, },
); );
app.update(); app.update();
@@ -765,6 +976,7 @@ mod tests {
replay: synthetic_replay(10), replay: synthetic_replay(10),
cursor: 0, cursor: 0,
secs_to_next: 0.5, secs_to_next: 0.5,
paused: false,
}, },
); );
app.update(); app.update();
@@ -821,6 +1033,7 @@ mod tests {
replay: synthetic_replay(5), replay: synthetic_replay(5),
cursor: 0, cursor: 0,
secs_to_next: 0.5, secs_to_next: 0.5,
paused: false,
}, },
); );
app.update(); app.update();
@@ -857,6 +1070,7 @@ mod tests {
replay: synthetic_replay(3), replay: synthetic_replay(3),
cursor: 1, cursor: 1,
secs_to_next: 0.5, secs_to_next: 0.5,
paused: false,
}, },
); );
app.update(); app.update();
@@ -884,6 +1098,7 @@ mod tests {
replay: synthetic_replay(7), replay: synthetic_replay(7),
cursor: 7, cursor: 7,
secs_to_next: 0.0, secs_to_next: 0.0,
paused: false,
}, },
); );
app.update(); app.update();
@@ -937,6 +1152,7 @@ mod tests {
replay: synthetic_replay(10), replay: synthetic_replay(10),
cursor: 0, cursor: 0,
secs_to_next: 0.5, secs_to_next: 0.5,
paused: false,
}), }),
0.0, 0.0,
); );
@@ -945,6 +1161,7 @@ mod tests {
replay: synthetic_replay(10), replay: synthetic_replay(10),
cursor: 5, cursor: 5,
secs_to_next: 0.5, secs_to_next: 0.5,
paused: false,
}), }),
50.0, 50.0,
); );
@@ -953,6 +1170,7 @@ mod tests {
replay: synthetic_replay(10), replay: synthetic_replay(10),
cursor: 10, cursor: 10,
secs_to_next: 0.5, secs_to_next: 0.5,
paused: false,
}), }),
100.0, 100.0,
); );
@@ -987,6 +1205,7 @@ mod tests {
replay: synthetic_replay(10), replay: synthetic_replay(10),
cursor: 5, cursor: 5,
secs_to_next: 0.5, secs_to_next: 0.5,
paused: false,
}), }),
Some("GAME #2026-122".to_string()), Some("GAME #2026-122".to_string()),
); );
@@ -1000,6 +1219,7 @@ mod tests {
replay: early_january, replay: early_january,
cursor: 0, cursor: 0,
secs_to_next: 0.5, secs_to_next: 0.5,
paused: false,
}), }),
Some("GAME #2026-005".to_string()), Some("GAME #2026-005".to_string()),
); );
@@ -1017,6 +1237,7 @@ mod tests {
replay: synthetic_replay(10), replay: synthetic_replay(10),
cursor: 0, cursor: 0,
secs_to_next: 0.5, secs_to_next: 0.5,
paused: false,
}, },
); );
app.update(); app.update();
@@ -1048,6 +1269,7 @@ mod tests {
replay: synthetic_replay(8), replay: synthetic_replay(8),
cursor: 2, cursor: 2,
secs_to_next: 0.5, secs_to_next: 0.5,
paused: false,
}, },
); );
app.update(); app.update();
@@ -1063,6 +1285,7 @@ mod tests {
replay: synthetic_replay(8), replay: synthetic_replay(8),
cursor: 6, cursor: 6,
secs_to_next: 0.5, secs_to_next: 0.5,
paused: false,
}, },
); );
app.update(); app.update();
@@ -1080,4 +1303,295 @@ mod tests {
"Completed state must read as a fully-filled track", "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:?}"),
}
}
} }
+73
View File
@@ -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,61 @@ 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
}
/// 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,8 +314,15 @@ fn tick_replay_playback(
replay, replay,
cursor, cursor,
secs_to_next, secs_to_next,
paused,
} = state.as_mut() } = state.as_mut()
{ {
// 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; *secs_to_next -= dt;
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() { while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
match &replay.moves[*cursor] { match &replay.moves[*cursor] {
@@ -273,6 +345,7 @@ fn tick_replay_playback(
transition_to_completed = true; transition_to_completed = true;
} }
} }
}
if transition_to_completed { if transition_to_completed {
*state = ReplayPlaybackState::Completed; *state = ReplayPlaybackState::Completed;