Compare commits

..

1 Commits

Author SHA1 Message Date
funman300 5323c26a0d fix(engine): poll decor-view size to catch fold resizes winit misses
Test / test (pull_request) Failing after 15m5s
winit's Android backend does not forward content-rect changes that happen
while backgrounded (open TODOs in winit 0.30 logged on every resume), so a
fold/unfold cycle can leave Bevy rendering and laying out for the previous
screen: tableau clipped off the left edge, bottom third empty (#130).

Add a continuous decor-view size poller (JNI, every 30 frames) that, on
mismatch with the cached Window resolution: writes the real physical size
into window.resolution, emits a synthetic WindowResized in logical pixels,
and re-arms the safe-area inset poller — covering both the fold-size and
inset-staleness cases in one mechanism, without relying on
AppLifecycle::WillResume being delivered (it may never be; a new evidence
log in rearm_on_resumed settles that question on-device).

Closes #130

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:31:56 -07:00
136 changed files with 6975 additions and 14765 deletions
+7 -9
View File
@@ -1,4 +1,4 @@
# Build and deploy the solitaire server Docker image. (retriggered 2026-07-16) # Build and deploy the solitaire server Docker image.
name: Build and Deploy name: Build and Deploy
on: on:
@@ -10,14 +10,9 @@ on:
- 'solitaire_web/**' - 'solitaire_web/**'
- 'solitaire_sync/**' - 'solitaire_sync/**'
- 'solitaire_core/**' - 'solitaire_core/**'
- 'solitaire_data/**'
- 'solitaire_engine/**' - 'solitaire_engine/**'
- 'Cargo.toml' - 'Cargo.toml'
- 'Cargo.lock' - 'Cargo.lock'
- 'build_wasm.sh'
# The engine embeds CHANGELOG.md (What's-new card) — changelog cuts
# must redeploy the web so the card content stays fresh.
- 'CHANGELOG.md'
- 'solitaire_server/Dockerfile' - 'solitaire_server/Dockerfile'
- '.gitea/workflows/docker-build.yml' - '.gitea/workflows/docker-build.yml'
@@ -41,9 +36,12 @@ jobs:
id: meta id: meta
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT" run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
# The wasm bundles (solitaire_server/web/pkg/) are not in the repo — # WASM artifact freshness is owned by the `web-wasm-rebuild` workflow,
# the Dockerfile's wasm-builder stage builds them from source inside # which rebuilds pkg/ in CI on every master change to a wasm-feeding crate
# this image build, so the deployed image always ships fresh wasm. # and commits it back (CI is the single source of truth — the artifacts
# aren't byte-reproducible on contributor machines). That pkg/ commit then
# triggers this workflow, so the deployed image always ships fresh wasm.
# No drift check is needed here.
- name: Log in to Gitea registry - name: Log in to Gitea registry
uses: docker/login-action@v3 uses: docker/login-action@v3
+4 -70
View File
@@ -1,20 +1,7 @@
# Workspace gate: the same clippy + test commands CLAUDE.md §6 requires # Workspace gate: the same clippy + test commands CLAUDE.md §6 requires
# locally, run on every master push and pull request. Until this workflow # locally, run on every master push and pull request. Until this workflow
# existed, nothing in CI ran the test suite at all — a direct push to # existed, nothing in CI ran the test suite at all — a direct push to
# master was entirely unguarded. # master (or the web-wasm-rebuild bot commit) was entirely unguarded.
#
# Build caching (2026-07-13): the Gitea actions cache never restored on
# this instance — every run back through run 597 logged "No cache found"
# even for exact keys saved successfully ("Cache saved successfully") by
# a run an hour earlier, including master→master restores. Until the
# cache server on the runner host is fixed, Swatinem/rust-cache is pure
# overhead here. `rust-host` is a HOST executor (its filesystem persists
# between runs — ~/.cargo and the rustup toolchain already carry over),
# so we get warm builds by pointing CARGO_TARGET_DIR at a persistent
# path on the runner instead of tarring gigabytes through a cache API
# that never returns them. Concurrent runs are safe: cargo serialises
# on the target-dir lock.
name: Test name: Test
on: on:
@@ -41,50 +28,8 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
# Seconds-long formatting gate in its own job so a rustfmt slip fails
# here instead of after a 35-minute cold build (run 600 spent its
# whole build budget to report an unformatted file).
fmt:
runs-on: rust-host
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust 1.95.0
uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.95.0
components: rustfmt
- name: Format check
run: cargo fmt --check
test: test:
runs-on: rust-host runs-on: ubuntu-latest
needs: fmt
# Full debuginfo made the solitaire_engine test-binary link peak past the
# runner's memory — ld was OOM-killed (signal 9) on runs 447 and 486.
# line-tables-only keeps file:line in panic backtraces while cutting the
# link's memory footprint enough to fit the runner.
#
# CARGO_BUILD_JOBS=2: with one job per core, cargo links several large
# test binaries concurrently; as the workspace grew (runs 514/516/519)
# two+ simultaneous ld processes OOM-killed the runner again even at
# line-tables-only. Two jobs keeps at most two links in flight — the
# compile-throughput cost is small next to the warm build.
#
# CARGO_INCREMENTAL=0: incremental artifacts bloat the persistent
# target dir for little benefit in CI (rust-cache used to set this
# for the same reason).
#
# CARGO_TARGET_DIR: persistent on the runner host — see the header
# comment. The prune step below keeps it from growing unbounded.
env:
CARGO_PROFILE_DEV_DEBUG: line-tables-only
CARGO_BUILD_JOBS: '2'
CARGO_INCREMENTAL: '0'
CARGO_TARGET_DIR: /home/runner/.cache/ferrous-solitaire/target
steps: steps:
- name: Checkout - name: Checkout
@@ -96,19 +41,8 @@ jobs:
toolchain: 1.95.0 toolchain: 1.95.0
components: clippy components: clippy
# Toolchain or lockfile bumps strand stale artifacts nothing will - name: Cache cargo build
# ever reuse; reset the dir when it crosses 40 GiB rather than uses: Swatinem/rust-cache@v2
# curating it (a cold rebuild every few weeks is cheaper than the
# bookkeeping).
- name: Prune persistent target dir when oversized
run: |
limit_kb=$((40 * 1024 * 1024))
used_kb=$(du -sk "$CARGO_TARGET_DIR" 2>/dev/null | cut -f1 || echo 0)
echo "persistent target dir: $((used_kb / 1024)) MiB (limit $((limit_kb / 1024)) MiB)"
if [ "${used_kb:-0}" -gt "$limit_kb" ]; then
echo "over limit — clearing for a fresh cold build"
rm -rf "$CARGO_TARGET_DIR"
fi
# Native link deps for the Bevy crates (engine/app/web) on a bare # Native link deps for the Bevy crates (engine/app/web) on a bare
# ubuntu runner: ALSA + udev for input/audio, X11 + Wayland for winit. # ubuntu runner: ALSA + udev for input/audio, X11 + Wayland for winit.
-25
View File
@@ -8,13 +8,9 @@ on:
- 'solitaire_server/src/**' - 'solitaire_server/src/**'
- 'solitaire_server/e2e/**' - 'solitaire_server/e2e/**'
- 'solitaire_wasm/**' - 'solitaire_wasm/**'
- 'solitaire_web/**'
- 'solitaire_engine/**'
- 'solitaire_data/**'
- 'solitaire_core/**' - 'solitaire_core/**'
- 'Cargo.toml' - 'Cargo.toml'
- 'Cargo.lock' - 'Cargo.lock'
- 'build_wasm.sh'
- '.gitea/workflows/web-e2e.yml' - '.gitea/workflows/web-e2e.yml'
workflow_dispatch: workflow_dispatch:
@@ -28,31 +24,10 @@ jobs:
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- name: Cache cargo build - name: Cache cargo build
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
# The wasm bundles (solitaire_server/web/pkg/) are not in the repo —
# build them here so the served pages have real wasm to load. Tool
# versions are pinned; keep in sync with solitaire_server/Dockerfile.
- name: Install wasm-bindgen-cli + wasm-pack (pinned)
uses: taiki-e/install-action@v2
with:
tool: wasm-bindgen-cli@0.2.120,wasm-pack@0.14.0
- name: Install binaryen 130 (wasm-opt, pinned)
run: |
set -euo pipefail
curl -sSL \
https://github.com/WebAssembly/binaryen/releases/download/version_130/binaryen-version_130-x86_64-linux.tar.gz \
| tar xz
echo "$PWD/binaryen-version_130/bin" >> "$GITHUB_PATH"
- name: Build WASM artifacts
run: ./build_wasm.sh
# Prebuild the server so Playwright's `webServer` (which runs # Prebuild the server so Playwright's `webServer` (which runs
# `cargo run -p solitaire_server`) starts from a compiled binary instead # `cargo run -p solitaire_server`) starts from a compiled binary instead
# of cold-compiling the whole dependency graph (axum/sqlx/reqwest) inside # of cold-compiling the whole dependency graph (axum/sqlx/reqwest) inside
+89
View File
@@ -0,0 +1,89 @@
name: Web WASM Rebuild
# CI is the single source of truth for solitaire_server/web/pkg/.
#
# The wasm artifacts cannot be reproduced byte-for-byte on an arbitrary
# contributor machine: even with identical rustc 1.95.0 / LLVM 22.1.2, the same
# flags, the same Cargo.lock and remapped source paths, the output still differs
# by host environment. So rather than police freshness with a rebuild-and-diff
# gate (which false-failed for exactly that reason), CI rebuilds the artifacts
# itself on every master change to a wasm-feeding crate and commits them back.
#
# Result: the deployed pkg/ can't silently rot, and contributors never need to
# run build_wasm.sh by hand. The commit touches only pkg/, which is not in this
# workflow's trigger paths (so it does not re-trigger here) but does match
# docker-build's, so the refreshed wasm deploys.
on:
push:
branches: [master]
paths:
- 'solitaire_core/**'
- 'solitaire_engine/**'
- 'solitaire_data/**'
- 'solitaire_sync/**'
- 'solitaire_wasm/**'
- 'solitaire_web/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'build_wasm.sh'
- '.gitea/workflows/web-wasm-rebuild.yml'
workflow_dispatch:
concurrency:
group: web-wasm-rebuild
cancel-in-progress: false
jobs:
rebuild:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.CI_TOKEN }}
- name: Install Rust 1.95.0
uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.95.0
targets: wasm32-unknown-unknown
- name: Cache cargo build
uses: Swatinem/rust-cache@v2
- name: Install wasm-bindgen-cli + wasm-pack (pinned)
uses: taiki-e/install-action@v2
with:
tool: wasm-bindgen-cli@0.2.120,wasm-pack@0.14.0
- name: Install binaryen 130 (wasm-opt, pinned)
run: |
set -euo pipefail
curl -sSL \
https://github.com/WebAssembly/binaryen/releases/download/version_130/binaryen-version_130-x86_64-linux.tar.gz \
| tar xz
echo "$PWD/binaryen-version_130/bin" >> "$GITHUB_PATH"
- name: Rebuild WASM artifacts
run: ./build_wasm.sh
- name: Commit refreshed artifacts if changed
run: |
set -euo pipefail
if git diff --quiet -- solitaire_server/web/pkg/; then
echo "pkg/ already up to date — nothing to commit."
exit 0
fi
git config user.email "ci@gitea.local"
git config user.name "Gitea CI"
git add solitaire_server/web/pkg/
git commit -m "chore(web): regenerate wasm artifacts"
# master is unprotected; retry once if the tip moved under us.
git push origin HEAD:master || {
git fetch origin master
git rebase origin/master
git push origin HEAD:master
}
-4
View File
@@ -41,7 +41,3 @@ deploy/*-auth-secret.yaml
# Local token-saving helper scripts (peek/cargoclip/testfail/diffclip/etc.) — # Local token-saving helper scripts (peek/cargoclip/testfail/diffclip/etc.) —
# inspection-only Go tools, not committed. Tracked scripts/*.sh and *.md stay. # inspection-only Go tools, not committed. Tracked scripts/*.sh and *.md stay.
scripts/*.go scripts/*.go
# WASM bundles — built by build_wasm.sh locally and by the Docker wasm-builder
# stage / web-e2e workflow in CI; never committed (issue #156)
solitaire_server/web/pkg/
+7 -9
View File
@@ -145,6 +145,7 @@ Shared API contract types imported by both the game client (`solitaire_data`) an
Owns: Owns:
- `SyncPayload`, `SyncResponse`, `ConflictReport` - `SyncPayload`, `SyncResponse`, `ConflictReport`
- `ChallengeGoal`, `LeaderboardEntry` - `ChallengeGoal`, `LeaderboardEntry`
- `ApiError` enum
- Merge logic (pure functions, no I/O) - Merge logic (pure functions, no I/O)
### `solitaire_data` ### `solitaire_data`
@@ -192,7 +193,7 @@ Owns:
### `solitaire_wasm` ### `solitaire_wasm`
**Dependencies:** `solitaire_core`, `serde`, `serde_json`, `chrono`, `wasm-bindgen`, `serde-wasm-bindgen`. **Dependencies:** `solitaire_core`, `serde`, `serde_json`, `chrono`, `wasm-bindgen`, `serde-wasm-bindgen`.
WebAssembly bindings for browser-side replay playback. Compiled to `cdylib` via `wasm-pack build` (`build_wasm.sh`); the output lands in `solitaire_server/web/pkg/` — gitignored, built in CI (Docker `wasm-builder` stage, web-e2e workflow) — and is served statically by the server. WebAssembly bindings for browser-side replay playback. Compiled to `cdylib` via `wasm-pack build`; the output lives in `solitaire_server/web/pkg/` and is served statically by the server.
Intentionally **does not** depend on `solitaire_data` (which pulls in `dirs`, `keyring`, `reqwest`, and other non-WASM crates). Instead it defines a minimal `Replay` mirror with the same serde shape as `solitaire_data::Replay` — the JSON wire format is the compatibility contract. Intentionally **does not** depend on `solitaire_data` (which pulls in `dirs`, `keyring`, `reqwest`, and other non-WASM crates). Instead it defines a minimal `Replay` mirror with the same serde shape as `solitaire_data::Replay` — the JSON wire format is the compatibility contract.
@@ -256,7 +257,7 @@ solitaire_sync::merge(local, remote)
Write merged result to disk Write merged result to disk
fires SyncCompleteEvent
Bevy main thread reads updated StatsResource Bevy main thread reads updated StatsResource
``` ```
@@ -376,6 +377,7 @@ struct StateChangedEvent;
struct CardFlippedEvent(u32); struct CardFlippedEvent(u32);
struct GameWonEvent { score: i32, time_seconds: u64 } struct GameWonEvent { score: i32, time_seconds: u64 }
struct AchievementUnlockedEvent(AchievementRecord); struct AchievementUnlockedEvent(AchievementRecord);
struct SyncCompleteEvent(Result<SyncResponse, String>);
``` ```
### Layout System ### Layout System
@@ -590,15 +592,11 @@ pub struct Session<G> { /* replayable instruction log + derived stats */ }
// From `klondike` (upstream — never edit): // From `klondike` (upstream — never edit):
pub enum KlondikePile { pub enum KlondikePile {
Stock, // NB: no Waste variant — see below Stock,
Waste,
Foundation(Foundation), // 4 slots, any suit may claim any slot Foundation(Foundation), // 4 slots, any suit may claim any slot
Tableau(Tableau), // 7 columns Tableau(Tableau), // 7 columns
} }
// Pile-coordinate convention: upstream has no `Waste` variant. In
// pile-coordinate space `KlondikePile::Stock` denotes the face-up
// *waste* pile (the only stock-side pile cards move out of); use
// `GameState::stock_cards()` / `waste_cards()` when the face-down
// draw stack must be distinguished. Documented on `GameState::pile`.
pub enum DrawStockConfig { DrawOne, DrawThree } pub enum DrawStockConfig { DrawOne, DrawThree }
pub enum KlondikeInstruction { /* RotateStock, DstFoundation, ... — the pub enum KlondikeInstruction { /* RotateStock, DstFoundation, ... — the
serialized move format (schema v4+) */ } serialized move format (schema v4+) */ }
@@ -743,7 +741,7 @@ All endpoints are under the base URL configured by the user (e.g., `https://soli
| Method | Path | Auth | Notes | | Method | Path | Auth | Notes |
|---|---|---|---| |---|---|---|---|
| GET | `/replays/:id` | None | Serves `web/index.html`; JS fetches `/api/replays/:id` and steps through via the `solitaire_wasm` WASM module | | GET | `/replays/:id` | None | Serves `web/index.html`; JS fetches `/api/replays/:id` and steps through via the `solitaire_wasm` WASM module |
| GET | `/web/*` | None | Static assets served via `ServeDir` from `solitaire_server/web/` (includes `web/pkg/` with wasm-bindgen output — gitignored, produced by `build_wasm.sh` / CI) | | GET | `/web/*` | None | Static assets served via `ServeDir` from `solitaire_server/web/` (includes `web/pkg/` with wasm-bindgen output) |
### Account Management ### Account Management
-172
View File
@@ -6,178 +6,6 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
## [0.49.0] — 2026-07-16
### Added
- **See what sync did.** After every sync, the Account tab shows exactly
what the merge decided — a "no conflicts" note, or one line per
reconciled field with both values ("this device: 3 / server: 5"). When
a merge wasn't clean, a toast summarises it: "Synced — 2 conflicts,
kept newer values". (#189)
- **One-file backup.** Settings → Account → Local data can now export
every save (settings, stats, achievements, progress) into a single
backup file next to your other saves — and restore from it later or on
another machine. Restores never silently reset anything: a missing or
incompatible backup shows an error and leaves your data untouched.
(#189)
### Changed
- Under the hood, the scheduler-ambiguity gate now covers the full
plugin cluster (22 plugins) — 585 undefined-order system pairs were
pinned or proven independent, eliminating a whole class of
"works-on-my-frame" bugs in modes, replays, input, and chrome. (#188)
## [0.48.1] — 2026-07-15
### Added
- **Reduce Transparency.** Settings → Accessibility gains a toggle that
replaces the see-through glass toolbar with a solid panel, so cards
behind it can never reduce readability. High Contrast mode now also
makes the glass markedly more opaque with a brighter outline. (#187)
### Changed
- **The toolbar gets out of your way.** While you're dragging a card,
the floating toolbar slides off the bottom edge and returns when you
drop — bottom-row piles are never hidden behind it. Instant (no
animation) when Reduce Motion is on. (#187)
## [0.48.0] — 2026-07-15
### Changed
- **Floating glass tab bar** (touch). The bottom action bar is now a
floating translucent pill — Undo, Draw, Hint, and Pause — with a
separate round Menu button beside it, hovering above the bottom edge
instead of docking to it. Draw stays expanded as the highlighted
action; the other buttons reveal their label while held (instantly,
with reduce-motion on). Same actions, same behaviour — new clothes.
(#186)
- **Consistent empty, loading, and error screens.** The theme store,
leaderboard, and replay list now share one visual language for
"nothing here yet", "still loading", and "something went wrong" —
each with a next step instead of a dead end. (#184)
### Fixed
- **Hotkeys during drags.** Pressing Undo, New Game, Draw, or a mode
key mid-drag no longer fights the card you're holding — game-mutating
hotkeys are ignored until the card is dropped. (#185)
## [0.47.0] — 2026-07-13
### Added
- **"What's new" on update.** The first launch after an update shows a
one-time card summarising what changed — like this one, right now.
Dismiss it and it never repeats; fresh installs never see it. (#180)
- **Situational tips.** Two one-time teaches that fire from play, not a
tour: staring at a stuck board for a while points you at Hint, and an
engaged game quietly mentions the long-press quick-action menu —
unless you've already found it yourself. (#181)
- **UI scale.** Settings → Accessibility now has a UI Scale control
(90% / 100% / 115% / 130%) that resizes every menu, button, and HUD
element live. The table always fits your screen regardless. (#182)
- **Hold `/` for shortcuts** (desktop). A reference card of every
keyboard binding appears while `/` is held. The onboarding tour and
this card now share one binding table, so they can never disagree —
the tour's copy had drifted from reality. (#183)
## [0.46.0] — 2026-07-13
### Added
- **Theme-store previews.** The store modal now shows each theme's
preview image next to its name — the server has advertised them since
the store launched; the client finally fetches them. Thumbnails load
in the background, pop in as they arrive, and are remembered for the
session. (#179)
- **Hint ghost preview.** Asking for a hint now also plays a translucent
copy of the suggested card gliding to its destination (twice, then it
fades) alongside the usual highlights — you see the move, not just the
pieces. Automatically disabled when reduce-motion is on. (#179)
### Changed
- **One toast style, one place.** Queued info banners and instant
celebration/warning/error toasts now share a single bottom-anchored
stack that clears the touch action bar; simultaneous toasts stack
upward instead of overlapping. (#178)
### Fixed
- **Touch onboarding copy.** The how-to-play slide no longer tells touch
players to left/right-click; it now describes drag, double-tap, and
the bottom-bar Hint button. (#178)
- **First launch shows one screen at a time.** On a fresh install the
Home screen no longer spawns underneath the welcome tour; the order is
now onboarding → Home → table. (#178)
## [0.45.0] — 2026-07-13
### Added
- **Thumb-reach action bar on touch (menu redesign Phase F).** The bottom
bar slims to five buttons: an enlarged **Undo · Draw · Hint** trio
(96×64 px targets) between compact Menu and Pause. **Draw is new** — it
draws from the stock exactly like tapping the deck, without the reach to
the top of a tall folded screen. Help, Modes, and New Game moved off the
touch bar (Menu → System, the Home grid, and Home's hero button cover
them). Desktop keeps its seven-button bar unchanged.
- **Hold-to-repeat Undo.** Press and hold Undo to step back repeatedly
(~5.5 undos/s after a short delay) instead of tap-tap-tap. Every step
goes through the normal undo path, so scoring penalties apply as usual.
## [0.44.0] — 2026-07-13
### Added
- **Home is now a real home (menu redesign Phase B).** The mode picker became
a hierarchy: a **Continue** card (mode · elapsed · score) appears while a
game is in progress and returns to the table; a hero **New Game** button
replays your last mode with the current deal options in one tap; deal
options (draw 1/3, a new **winnable-only** toggle, difficulty tiers) moved
into a disclosure under the hero; the six modes sit in a compact symmetric
2×3 grid with descriptions on wide screens; and the stats strip moved to
the bottom. Wide viewports (desktop, unfolded foldables) get a two-pane
layout — launch surfaces left, Continue/daily/stats right. Cancel is now
**Back to table** and only appears while a live game exists. (#175)
### Fixed
- **Save files are self-contained (schema v6).** Like replays in 0.43.3, a
saved game now stores the dealt board via the upstream session serializers
instead of re-dealing from the seed on load; v4/v5 saves still load through
the legacy path and rewrite as v6 on next save. (#171)
### Internal
- **CI is ~9× faster.** Root-caused why the actions cache never restored on
the host-executor runner (per-run workdir paths poisoned the cache version
hash); the test workflow now uses a persistent on-runner target dir, and a
seconds-long `fmt` gate fails formatting mistakes before the build. Warm
full-gate runs: 4m29s, down from ~40 min. (#176)
## [0.43.3] — 2026-07-10
### Fixed
- **Replays are now self-contained (schema v4).** A replay stores the dealt
board itself via the upstream `card_game` session serializers instead of
re-dealing from the seed at playback time, so replays survive RNG and
upstream upgrades that change the seed→deal mapping — the failure that had
silently broken every stored replay. The web player and web game now
exchange the full payload through the wasm layer (the old JS path hardcoded
`schema_version: 2`, uploaded empty move lists, and corrupted u64 seeds via
`Math.round`), and the replay viewer reports unplayable old-format replays
in the caption instead of dying silently. Pre-v4 replays are rejected by a
version gate; local histories repopulate with new wins. (#170)
- **Difficulty-mode wins can upload.** The server's replay `mode` validation
now accepts data-carrying `GameMode` variants (previously a 400). (#170)
## [0.42.0] — 2026-07-06 ## [0.42.0] — 2026-07-06
### Added ### Added
Generated
-10
View File
@@ -7331,9 +7331,7 @@ dependencies = [
"card_game", "card_game",
"klondike", "klondike",
"proptest", "proptest",
"rand 0.10.1",
"serde", "serde",
"serde_json",
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
@@ -7353,16 +7351,13 @@ dependencies = [
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"solitaire_core", "solitaire_core",
"solitaire_server", "solitaire_server",
"solitaire_sync", "solitaire_sync",
"sqlx", "sqlx",
"tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"uuid", "uuid",
"zip",
] ]
[[package]] [[package]]
@@ -7407,13 +7402,10 @@ dependencies = [
"chrono", "chrono",
"dotenvy", "dotenvy",
"jsonwebtoken", "jsonwebtoken",
"ron",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"solitaire_sync", "solitaire_sync",
"sqlx", "sqlx",
"tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tower", "tower",
@@ -7422,7 +7414,6 @@ dependencies = [
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"uuid", "uuid",
"zip",
] ]
[[package]] [[package]]
@@ -7431,7 +7422,6 @@ version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"serde", "serde",
"serde_json",
"thiserror 2.0.18", "thiserror 2.0.18",
"uuid", "uuid",
] ]
-1
View File
@@ -47,7 +47,6 @@ dirs = "6"
keyring = "4" keyring = "4"
keyring-core = "1" keyring-core = "1"
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false } reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
sha2 = "0.10"
arboard = { version = "3", default-features = false } arboard = { version = "3", default-features = false }
jni = { version = "0.21", default-features = false } jni = { version = "0.21", default-features = false }
-20
View File
@@ -44,26 +44,6 @@ docker compose up -d
``` ```
## Theme store
The server can offer card-art themes for in-game download. Drop theme
`.zip` archives (the same format the game's Settings → Import accepts:
a `theme.ron` manifest plus 53 SVGs) into the directory named by
`THEME_STORE_DIR` (default: `theme_store/` next to the binary), then
restart the server — the catalog is scanned once at startup. An
optional `<theme-id>.png` in the same directory becomes the theme's
store preview.
Endpoints (public, no auth):
- `GET /api/themes` — catalog JSON (id, name, author, size, sha256)
- `GET /api/themes/<id>/download` — the archive
- `GET /api/themes/<id>/preview` — the preview PNG, if present
Archives that are oversized (> 20 MiB), unreadable, or have a
malformed `theme.ron` are skipped with a warning in the server log;
they never fail startup.
## Admin — Password Reset ## Admin — Password Reset
If a player loses access to their account, the server binary includes a If a player loses access to their account, the server binary includes a
+4 -5
View File
@@ -13,10 +13,9 @@
# Run from the repo root: # Run from the repo root:
# ./build_wasm.sh # ./build_wasm.sh
# #
# The generated pkg/ files are NOT committed to git (issue #156). CI builds # The generated pkg/ files are committed to git so self-hosters who don't
# them where needed: the Docker image's wasm-builder stage for deployment, # touch the WASM crates can skip this step. Regenerate after any change to
# and the web-e2e workflow for browser tests. Run this script locally before # solitaire_wasm/, solitaire_web/, solitaire_engine/, or solitaire_core/.
# serving /web or /play from a source checkout.
set -euo pipefail set -euo pipefail
@@ -78,7 +77,7 @@ wasm-bindgen \
--out-name canvas \ --out-name canvas \
--target web \ --target web \
--no-typescript \ --no-typescript \
"${CARGO_TARGET_DIR:-$REPO_ROOT/target}/wasm32-unknown-unknown/wasm-release/solitaire_web.wasm" "$REPO_ROOT/target/wasm32-unknown-unknown/wasm-release/solitaire_web.wasm"
# Optional size optimisation — Bevy bundles are large (~5-15 MB uncompressed). # Optional size optimisation — Bevy bundles are large (~5-15 MB uncompressed).
# wasm-opt passes are skipped silently when the tool is not installed. # wasm-opt passes are skipped silently when the tool is not installed.
-5
View File
@@ -34,11 +34,6 @@ spec:
key: jwt-secret key: jwt-secret
- name: SERVER_PORT - name: SERVER_PORT
value: "8080" value: "8080"
# Theme-store catalog directory on the persistent volume.
# Scanned once at startup — after dropping new theme zips
# into /data/theme_store, restart the deployment.
- name: THEME_STORE_DIR
value: /data/theme_store
volumeMounts: volumeMounts:
- name: db-data - name: db-data
mountPath: /data mountPath: /data
-5
View File
@@ -6,13 +6,8 @@ services:
# Override DATABASE_URL so the DB always lands in the persistent volume, # Override DATABASE_URL so the DB always lands in the persistent volume,
# regardless of what .env contains. # regardless of what .env contains.
DATABASE_URL: sqlite:///data/solitaire.db DATABASE_URL: sqlite:///data/solitaire.db
# Theme-store catalog directory (scanned once at startup; the
# host ./theme_store folder is where the operator drops theme
# zips + preview PNGs).
THEME_STORE_DIR: /theme_store
volumes: volumes:
- ./data:/data - ./data:/data
- ./theme_store:/theme_store:ro
restart: unless-stopped restart: unless-stopped
expose: expose:
- "${SERVER_PORT:-8080}" - "${SERVER_PORT:-8080}"
-211
View File
@@ -1,211 +0,0 @@
# Menu UX Redesign — July 2026
Status: IN PROGRESS. A / E / C / G shipped in v0.43.0; B implemented on
`feat/home-hierarchy` (2026-07-13); F / H and the IM backlog remain open.
Visual identity (Terminal / base16-eighties) is settled and out of
scope — this is about **structure and interaction**, not colors or type.
## Diagnosis (from code survey, 2026-07-07)
| Surface | Today | Pain |
| --- | --- | --- |
| Settings | One scrolling modal, 5 section labels, ~37 control rows, single Done | Scroll-hunting for any toggle; endless on the folded (cover) screen; every new feature (theme store, import) stretches it further |
| Home | Modal launcher: stats strip, draw-mode row, difficulty rows, 6 equal mode cards, Cancel | No "Continue" primacy; deal options (draw/difficulty/winnable) crowd the mode choice; six cards weighted identically though Classic dominates play |
| HUD menu | Popover with 7 destinations (Help, Modes, Stats, Achievements, Profile, Settings, Leaderboard) | "Modes" duplicates Home's job; flat list, no grouping; each item opens another modal — modal-on-modal navigation |
| Global | Everything is a modal over the felt | No hierarchy; near-square unfolded Fold renders one narrow wasted column |
## Phase A — Settings tabs (highest pain, lowest risk)
- Replace the single scroll with **tabs**: `Audio · Gameplay · Appearance ·
Accessibility · Account`.
- *Appearance* = today's Cosmetic (card back, background, card theme,
theme store, import).
- *Accessibility* = extracted from Gameplay/Cosmetic: color-blind,
high-contrast, reduce-motion, touch input mode, tooltip delay.
- *Account* = Sync + Privacy merged.
- Narrow screens: tab chips in a row under the header. Wide (aspect > ~1.2 —
unfolded Fold, desktop): left rail, rows in two columns.
- Only the active tab's rows are spawned → each tab is ≤ 8 rows and fits the
cover screen without scrolling.
- **Implementation constraint:** pure `ui.rs` re-layout + one `SettingsTab`
resource. The `SettingsButton` enum, input handlers, and persistence are
untouched, so the 25 settings tests and the ambiguity gate stay green.
## Phase B — Home becomes a real home
- **Hierarchy, top to bottom:**
1. `Continue` card (only when a game is in progress) — mode, elapsed, score.
2. Hero `New Game` — one tap, reuses last mode + deal options.
3. Compact 2×3 mode grid (Classic, Daily, Zen, Challenge, Time Attack,
Seed) — smaller cards, glyph + name + one-line description on wide only.
4. Stats strip moves to the bottom (or right pane when unfolded).
- **Deal options** (draw 1/3, difficulty, winnable-only) move off the top
level into a disclosure on the Classic card / New Game hero — they are
Classic-mode concerns, not global ones.
- Cancel becomes `Back to table` and only renders when a game exists.
- Unfolded Fold / desktop: two panes — modes left, Continue + stats +
daily/weekly right.
## Phase C — HUD menu consolidation
- Drop `Modes` from the popover (Home owns mode selection; HUD's New-Game
path opens Home).
- Group the remaining six: **Play** (Home) · **You** (Profile, Stats,
Achievements) · **Community** (Leaderboard) · **System** (Settings, Help) —
section dividers in the existing popover, not a new widget.
- Dismissal audit: Esc / scrim-tap / Done must behave identically on every
modal (most already do via `ScrimDismissible`; sweep the stragglers).
## Phase D — Validation gates
- Optional Stitch mockups (Terminal design-system asset
`assets/0f1652274fea460585d0b331a9ddbb06`, project 593811793268308763) for
Home + Settings before implementing Phase B. Phase A is safe to build
straight in-engine.
- Per-phase gates: full test suite, clippy `-D warnings`, ambiguity gate at 0,
Android checklist (§15.3), on-device pass on the Fold 7 in BOTH postures.
## Sequencing & risk
| Phase | Size | Risk | Depends on |
| --- | --- | --- | --- |
| A — Settings tabs | ~1 session | Low (layout-only) | — |
| B — Home hierarchy | 12 sessions | Medium (touches launch flow) | D mockups if wanted |
| C — Menu grouping | small | Low | B (Modes removal) |
## Phase E — "You" hub (reuses the Phase A tab component)
Profile, Stats, Achievements, and the replay browser are four separate
modals today, reached through the HUD popover one at a time. Fold them
into ONE tabbed hub — `Profile · Stats · Achievements · Replays` — using
the same tab widget Phase A builds for Settings. Cuts the HUD popover to
four destinations and makes the tab component pay for itself twice.
Existing per-screen systems keep their markers/handlers; only the outer
shell changes (same trick as Phase A).
## Phase F — Mobile ergonomics (Fold-first)
- **Bottom action bar** on touch: Undo, Hint, Draw within thumb reach at
the bottom safe-area edge (via the existing `SafeAreaAnchoredBottom`),
score-only band stays on top. Desktop keeps the current top band.
Biggest one-handed-play win available; folded posture is tall and
top-heavy today.
- **Hold-to-repeat Undo** — press-and-hold steps back repeatedly
(respecting the existing undo scoring penalty), instead of tap-tap-tap.
- Both are additive; game stays fully playable with the top band alone
(UI-first rule §3.3 satisfied).
## Phase G — Post-win flow
Win summary today is a stats dump with a close. Give it an action
hierarchy: **Play again** (same mode/options, primary) · **Share replay**
(the upload already returns a share URL; surface it here with copy
feedback) · **Watch replay** · quiet stats below. Rematch loop drops from
4 taps to 1.
## Phase H — Feedback polish (small, bundling candidates)
- Unify queued vs. immediate toast styling (two subtly different styles
exist today) and anchor both to one position; consider a 5-item toast
history on the stats screen.
- Theme-store modal: render the preview PNGs the server already serves
(v1 is text-only rows).
- Hint: optional ghost-motion preview of the suggested move instead of a
static highlight (auto-disabled under reduce-motion).
## Phase I — Onboarding & discoverability
The first-run onboarding exists, but everything learned after it is
invisible: the radial menu (long-press/right-click), hint cycling,
tap-to-toggle HUD chrome, hotkeys. Add **contextual one-time tips** —
fired by the situation, not a tour (first stall → hint tip; first
long-press-able stack → radial tip; each shows once, stored in
Settings like `shown_achievement_onboarding` already is). Plus a
one-shot **"What's new"** card on first launch after a version bump —
ObtainX updates are silent today, so shipped features go unnoticed
(nobody will find the theme store on their own).
## Phase J — Keyboard & focus completeness (desktop + web)
`ui_focus` already gives deterministic Esc order and tab-walk
(`FocusRow`/`Focusable`), and `KeyboardDragState` exists — but full
keyboard-only play has never been audited end to end. Deliverables: every
modal reachable/dismissable without a mouse, a visible focus ring styled
to the Terminal system (current focus state is subtle), and a hotkey
cheat-sheet overlay (hold `?`) generated from the actual bindings instead
of the static Help text. Web build (Rhys) benefits most.
## Phase K — UI scale & touch-target accessibility
All type sizes flow through `TYPE_*` tokens and layout through
`compute_layout` — which makes a **UI scale setting** (90/100/115/130 %)
cheap to wire and genuinely useful on the Fold's dense cover screen.
Pair with: respect Android system font scale on first run (seed the
setting from it), and a one-time audit that every interactive element
meets the 44 px logical minimum in BOTH Fold postures (the pill buttons
and picker swatches are the suspects). Lands in the Accessibility tab
that Phase A creates.
## Phase L — Empty & loading states, standardized
Async/empty surfaces each improvise today: leaderboard with no entries,
stats with no games, replay browser with no replays, theme store
loading/error (just added), sync status text. Define ONE pattern —
glyph + one-line explanation + a single next-step action — as a
`spawn_empty_state` helper in `ui_modal`, and sweep all five surfaces
onto it. Small, mechanical, big perceived-quality win.
## Phase M — Sync transparency & data stewardship
The sync layer returns `ConflictReport`s ("data is never silently
discarded") — but no UI ever shows them; players can't tell what a merge
did. Add: a post-sync summary line ("merged, 2 conflicts kept newer
values" → tap for detail in the Account tab), a visible last-synced
timestamp (exists as `SyncStatus::LastSynced`, barely surfaced), and
local **data export/import** (zip of the JSON saves) for device
migrations without a server. Closes the trust loop the server work
opened.
## Roadmap tie-ins (already in flight elsewhere)
- **Spider mode** (feat/spider-core, PR #157): Home's mode grid gains a
7th card once engine work lands — the 2×3 grid in Phase B should be
designed as N-card flow from day one. Spider UI also needs positional
card→entity keys (documented in spider.rs module docs).
- **Show solution** (feat/solution-line, in flight): lands in the pause
menu; Phase C's grouping should leave room for it under a "Game"
cluster if the pause menu grows.
## Sequencing (updated)
| Phase | Size | Risk | Depends on |
| --- | --- | --- | --- |
| A — Settings tabs | ~1 session | Low | — (DECIDED: tabs) |
| E — "You" hub | ~1 session | Low | A's tab component |
| C — Menu grouping | small | Low | E (popover shrinks) |
| B — Home hierarchy | 12 sessions | Medium | D mockups if wanted |
| F — Bottom action bar | ~1 session | Medium (input paths) | — |
| G — Post-win flow | small | Low | — |
| H — Feedback polish | small each | Low | — |
| I — Onboarding & discoverability | ~1 session | Low | ships best after B/F land |
| J — Keyboard & focus completeness | ~1 session | Low | A/E (fewer modals to audit) |
| K — UI scale & touch targets | ~1 session | Medium (layout-wide) | A (Accessibility tab) |
| L — Empty-state standardization | small | Low | — |
| M — Sync transparency & export | ~1 session | LowMedium | A (Account tab) |
Suggested order: **A → E → C** (one arc: the tab component and the menu
slimming), then **B**, then **F/G/H** as independent follow-ups.
## Open decisions
1. ~~Settings: tabs vs. sub-pages~~**DECIDED 2026-07-07: tabs.**
2. ~~Deal options: disclosure on Classic card vs. keep global row~~
**DECIDED 2026-07-09: disclosure on the Classic card / New Game hero.**
3. ~~Time Attack + Seed: top-level cards vs. "More" card~~
**DECIDED 2026-07-09: top-level cards, symmetric 2×3 grid.**
4. ~~Stitch mockups for Phase B, or iterate directly in-engine~~
**DECIDED 2026-07-09: directly in-engine.**
5. ~~Phase F bottom bar: touch-only vs. also desktop~~
**DECIDED 2026-07-13: touch-only.** Desktop keeps the current top
band; the bar is additive per §3.3 so a later desktop rollout stays
a settings flag away.
+6 -5
View File
@@ -2,8 +2,8 @@
# Live-watch the Gitea Actions deploy pipeline for Ferrous Solitaire. # Live-watch the Gitea Actions deploy pipeline for Ferrous Solitaire.
# #
# Polls recent workflow runs and prints a compact status block each cycle. # Polls recent workflow runs and prints a compact status block each cycle.
# Stops when the newest docker-build (the deploy) has completed — the wasm # Stops when the newest docker-build (the deploy) has completed and no
# is built inside that image build, so no other workflow gates the deploy. # web-wasm-rebuild is still pending — i.e. the full fix is live.
# #
# Usage: ./scripts/watch_deploy.sh [interval_seconds] # Usage: ./scripts/watch_deploy.sh [interval_seconds]
# token is read from ~/.config/tea/config.yml (never printed). # token is read from ~/.config/tea/config.yml (never printed).
@@ -49,15 +49,16 @@ for r in runs:
r.get("id"), str(r.get("head_sha"))[:7], wf[:18], r.get("id"), str(r.get("head_sha"))[:7], wf[:18],
r.get("status"), r.get("conclusion"))) r.get("status"), r.get("conclusion")))
db = [r for r in runs if "docker-build" in str(r.get("path"))] db = [r for r in runs if "docker-build" in str(r.get("path"))]
wr_pending = any(r.get("status") != "completed" for r in runs if "web-wasm-rebuild" in str(r.get("path")))
top = db[0] if db else None top = db[0] if db else None
live = bool(top and top.get("status")=="completed" and top.get("conclusion")=="success") live = bool(top and top.get("status")=="completed" and top.get("conclusion")=="success" and not wr_pending)
print("STATE=%s" % ("LIVE" if live else "DEPLOYING")) print("STATE=%s" % ("LIVE" if live else ("WAIT_WASM" if wr_pending else "DEPLOYING")))
PY PY
)" )"
echo "$out" | grep -v '^STATE=' echo "$out" | grep -v '^STATE='
if echo "$out" | grep -q '^STATE=LIVE'; then if echo "$out" | grep -q '^STATE=LIVE'; then
echo "" echo ""
echo "DEPLOY LIVE — newest docker-build succeeded." echo "DEPLOY LIVE — newest docker-build succeeded, no wasm rebuild pending."
echo " Test: https://klondike.aleshym.co/play?v=${RANDOM}" echo " Test: https://klondike.aleshym.co/play?v=${RANDOM}"
break break
fi fi
+5
View File
@@ -37,6 +37,11 @@ fn load_settings() -> Settings {
.unwrap_or_default() .unwrap_or_default()
} }
/// Build the Bevy app without entering the event loop.
pub fn build_app(sync_provider: Box<dyn SyncProvider + Send + Sync>) -> App {
build_app_with_settings(load_settings(), sync_provider)
}
/// App entry point — configures runtime services, builds, and runs the app. /// App entry point — configures runtime services, builds, and runs the app.
/// ///
/// Called from both the desktop `bin` target's `main` shim and (on /// Called from both the desktop `bin` target's `main` shim and (on
+1 -7
View File
@@ -9,19 +9,13 @@ default = []
test-support = [] test-support = []
[dev-dependencies] [dev-dependencies]
proptest = "1" proptest = "1"
serde_json = { workspace = true }
[dependencies] [dependencies]
serde = { workspace = true } serde = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
klondike = { workspace = true } klondike = { workspace = true }
card_game = { workspace = true } card_game = { workspace = true }
# Deliberately NOT the workspace rand (0.9): this pins the exact dep the
# upstream `klondike` crate uses, so `SeedableRng`/`SliceRandom` resolve
# against the same crate version as `klondike::Rng` and Spider deals go
# through the identical shuffle stack as Klondike deals.
rand = { version = "0.10.1", default-features = false, features = ["std_rng"] }
[lints] [lints]
workspace = true workspace = true
+64 -535
View File
@@ -22,17 +22,11 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// indices for enum variants. No longer loadable — v3 files are discarded. /// indices for enum variants. No longer loadable — v3 files are discarded.
/// - v4: `saved_moves` uses upstream `KlondikeInstruction` serde with named enum /// - v4: `saved_moves` uses upstream `KlondikeInstruction` serde with named enum
/// variants (e.g. `"Foundation1"` instead of `0`). /// variants (e.g. `"Foundation1"` instead of `0`).
/// - v5: `score`, `undo_count`, and `recycle_count` are no longer /// - v5 (current): `score`, `undo_count`, and `recycle_count` are no longer
/// persisted. They are derived from the upstream `card_game`/`klondike` session /// persisted. They are derived from the upstream `card_game`/`klondike` session
/// stats, which are rebuilt by replaying `saved_moves` on load. Older files that /// stats, which are rebuilt by replaying `saved_moves` on load. Older files that
/// still carry those keys load fine — the extra fields are ignored. /// still carry those keys load fine — the extra fields are ignored.
/// - v6 (current): `saved_moves` replaced by `recording`, the upstream pub const GAME_STATE_SCHEMA_VERSION: u32 = 5;
/// `card_game` session serialisation carrying the dealt board explicitly.
/// Loading no longer re-deals from `seed`, so in-progress saves survive
/// RNG/upstream upgrades that change the seed→deal mapping (the failure
/// class that PR #170 fixed for replays). v4/v5 files still load through
/// the legacy seed path and are rewritten as v6 on next save.
pub const GAME_STATE_SCHEMA_VERSION: u32 = 6;
/// Default move budget for a solvability check. Matches the winnable-deal retry /// Default move budget for a solvability check. Matches the winnable-deal retry
/// loop in the engine. /// loop in the engine.
@@ -101,8 +95,8 @@ pub enum GameMode {
Difficulty(DifficultyLevel), Difficulty(DifficultyLevel),
} }
/// Output struct for schema v6 serialisation. `recording` is the upstream /// Output struct for schema v4 serialisation. `saved_moves` uses upstream
/// session serialisation (config + dealt board + instruction list). /// `KlondikeInstruction` serde, which produces named enum variants.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
struct PersistedGameState { struct PersistedGameState {
pub draw_mode: DrawStockConfig, pub draw_mode: DrawStockConfig,
@@ -111,16 +105,15 @@ struct PersistedGameState {
pub seed: u64, pub seed: u64,
pub take_from_foundation: bool, pub take_from_foundation: bool,
pub schema_version: u32, pub schema_version: u32,
pub recording: SessionRecording, pub saved_moves: Vec<KlondikeInstruction>,
} }
/// Input struct that accepts schema v4, v5, and v6 save formats. /// Input struct that accepts schema v4 and v5 `saved_moves` formats.
/// ///
/// v6 files carry `recording` (the upstream session serialisation, deal /// `saved_moves` is deserialised directly as upstream `KlondikeInstruction`
/// included); v4/v5 files carry `saved_moves` and rebuild the deal from /// (named-variant serde). `score`, `undo_count`, and `recycle_count` are
/// `seed`. `score`, `undo_count`, and `recycle_count` are intentionally /// intentionally absent: all three are rebuilt by replaying the instruction
/// absent: all three are rebuilt by replaying the instruction history /// history through the upstream session stats. Older v4 save files still carry
/// through the upstream session stats. Older v4 save files still carry
/// those keys; serde ignores them. /// those keys; serde ignores them.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
struct PersistedGameStateIn { struct PersistedGameStateIn {
@@ -133,12 +126,7 @@ struct PersistedGameStateIn {
pub take_from_foundation: bool, pub take_from_foundation: bool,
#[serde(default = "schema_v1")] #[serde(default = "schema_v1")]
pub schema_version: u32, pub schema_version: u32,
/// v4/v5 only. Replayed against a seed-dealt board.
#[serde(default)]
pub saved_moves: Vec<KlondikeInstruction>, pub saved_moves: Vec<KlondikeInstruction>,
/// v6 only. Carries the dealt board explicitly.
#[serde(default)]
pub recording: Option<SessionRecording>,
} }
#[cfg(feature = "test-support")] #[cfg(feature = "test-support")]
@@ -234,7 +222,7 @@ impl Serialize for GameState {
seed: self.seed, seed: self.seed,
take_from_foundation: self.take_from_foundation, take_from_foundation: self.take_from_foundation,
schema_version: GAME_STATE_SCHEMA_VERSION, schema_version: GAME_STATE_SCHEMA_VERSION,
recording: self.recording(), saved_moves: self.saved_moves(),
} }
.serialize(serializer) .serialize(serializer)
} }
@@ -244,51 +232,34 @@ impl<'de> Deserialize<'de> for GameState {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let persisted = PersistedGameStateIn::deserialize(deserializer)?; let persisted = PersistedGameStateIn::deserialize(deserializer)?;
// Accept v6 (current, recording-based), plus v4/v5 (legacy seed-dealt // Accept v4 (upstream named-variant serde) and v5 (current, derived
// saves — loadable while the seed→deal mapping they were written // stats). v3 (legacy u8-index format) and all others are rejected.
// under still holds; rewritten as v6 on the next save). v3 (legacy match persisted.schema_version {
// u8-index format) and all others are rejected. 4 | 5 => {}
let (mut game, instructions) = match persisted.schema_version {
6 => {
let Some(recording) = persisted.recording else {
return Err(serde::de::Error::custom(
"v6 save file is missing the session recording",
));
};
// The dealt board and session config come from the recording
// itself; `seed` is presentation metadata only.
Self::from_recording(&recording, persisted.seed, persisted.mode)
}
4 | 5 => (
Self {
mode: persisted.mode,
elapsed_seconds: 0,
seed: persisted.seed,
take_from_foundation: true,
session: Self::new_session(persisted.seed, persisted.draw_mode),
#[cfg(feature = "test-support")]
test_pile_state: None,
},
persisted.saved_moves,
),
v => { v => {
return Err(serde::de::Error::custom(format!( return Err(serde::de::Error::custom(format!(
"unsupported GameState schema version {v}" "unsupported GameState schema version {v}"
))); )));
} }
}; }
game.mode = persisted.mode;
game.elapsed_seconds = persisted.elapsed_seconds;
game.take_from_foundation = persisted.take_from_foundation;
// Replay the saved instruction history with validation — a tampered let mut game = Self {
// or corrupt file must surface an error, not a silently wrong board. mode: persisted.mode,
// The upstream session tracks score components and recycle_count as elapsed_seconds: persisted.elapsed_seconds,
// it processes each move, so the derived stats are correct once seed: persisted.seed,
// replay completes. `undo_count()` resets to 0 across save/load take_from_foundation: persisted.take_from_foundation,
// because undone moves are not part of the saved forward history. session: Self::new_session(persisted.seed, persisted.draw_mode),
#[cfg(feature = "test-support")]
test_pile_state: None,
};
// Replay the saved instruction history. The upstream session tracks
// score components and recycle_count as it processes each move, so the
// derived stats are correct once replay completes. `undo_count()` resets
// to 0 across save/load because undone moves are not part of the saved
// forward history.
let replay_config = Self::replay_config(persisted.draw_mode); let replay_config = Self::replay_config(persisted.draw_mode);
for instruction in instructions { for instruction in persisted.saved_moves {
if !game if !game
.session .session
.state() .state()
@@ -306,90 +277,6 @@ impl<'de> Deserialize<'de> for GameState {
} }
} }
/// Self-contained recording of one deal plus every instruction applied to it.
///
/// Serialises via the upstream `card_game` [`Session`] serde, whose wire
/// format is `{config, initial_state, instructions}` — the dealt board is
/// stored **explicitly**, so playback never depends on the seed→deal mapping
/// staying stable across RNG or upstream-crate upgrades. This is the payload
/// replays must persist; a bare seed is only sufficient for the exact build
/// that recorded it.
#[derive(Debug, Clone)]
pub struct SessionRecording(Session<Klondike>);
impl SessionRecording {
/// Builds a recording by dealing a fresh board from `seed` and
/// force-applying `instructions` **without validation**.
///
/// Fixture/test aid only — production recordings come from
/// [`GameState::recording`], whose history is valid by construction.
/// Invalid instructions are absorbed by the upstream session's
/// `Option`-based pile pops rather than rejected, so a recording built
/// here may not replay cleanly through
/// [`GameState::apply_instruction`]'s validation.
pub fn from_instructions_unchecked(
seed: u64,
draw_mode: DrawStockConfig,
instructions: impl IntoIterator<Item = KlondikeInstruction>,
) -> Self {
let mut session = GameState::new_session(seed, draw_mode);
for instruction in instructions {
session.process_instruction(instruction);
}
Self(session)
}
/// The dealt board the recording starts from (before any instruction).
fn initial_state(&self) -> &Klondike {
self.0
.history()
.first()
.map(|snapshot| snapshot.state())
.unwrap_or_else(|| self.0.state().state())
}
/// Ordered instruction list, replayable via
/// [`GameState::apply_instruction`] against the game returned by
/// [`GameState::from_recording`].
pub fn instructions(&self) -> Vec<KlondikeInstruction> {
self.0
.history()
.iter()
.map(|snapshot| *snapshot.instruction())
.collect()
}
/// Number of recorded instructions.
pub fn len(&self) -> usize {
self.0.history().len()
}
/// `true` when no instructions have been recorded.
pub fn is_empty(&self) -> bool {
self.0.history().is_empty()
}
}
impl PartialEq for SessionRecording {
fn eq(&self, other: &Self) -> bool {
self.initial_state() == other.initial_state() && self.instructions() == other.instructions()
}
}
impl Eq for SessionRecording {}
impl Serialize for SessionRecording {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.0.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for SessionRecording {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
Session::deserialize(deserializer).map(Self)
}
}
impl GameState { impl GameState {
/// Creates a new Classic-mode game dealt from the given seed and draw mode. /// Creates a new Classic-mode game dealt from the given seed and draw mode.
pub fn new(seed: u64, draw_mode: DrawStockConfig) -> Self { pub fn new(seed: u64, draw_mode: DrawStockConfig) -> Self {
@@ -409,44 +296,6 @@ impl GameState {
} }
} }
/// Snapshot of the live session for replay persistence: the dealt board
/// plus the forward instruction history (undone moves are absent — the
/// session pops them). Serialise the returned [`SessionRecording`] with
/// the upstream `card_game` serializers; rebuild playback with
/// [`Self::from_recording`].
pub fn recording(&self) -> SessionRecording {
SessionRecording(self.session.clone())
}
/// Rebuilds the initial-deal game plus the ordered instruction list from
/// a [`SessionRecording`].
///
/// The board and the session config (including draw mode) come from the
/// recording itself, so playback is independent of the current build's
/// seed→deal mapping. `seed` and `mode` are presentation metadata carried
/// alongside the recording by the replay file. Step through the returned
/// instructions with [`Self::apply_instruction`], which re-validates each
/// one and fails gracefully on corrupt input.
pub fn from_recording(
recording: &SessionRecording,
seed: u64,
mode: GameMode,
) -> (Self, Vec<KlondikeInstruction>) {
let game = Self {
mode,
elapsed_seconds: 0,
seed,
take_from_foundation: true,
session: Session::new(
recording.initial_state().clone(),
recording.0.config().clone(),
),
#[cfg(feature = "test-support")]
test_pile_state: None,
};
(game, recording.instructions())
}
/// Whether the player draws one or three cards from the stock per turn. /// Whether the player draws one or three cards from the stock per turn.
/// Derived from the underlying session config (set once at deal time). /// Derived from the underlying session config (set once at deal time).
pub fn draw_mode(&self) -> DrawStockConfig { pub fn draw_mode(&self) -> DrawStockConfig {
@@ -490,32 +339,6 @@ impl GameState {
self.session.stats().stats().recycle_count() self.session.stats().stats().recycle_count()
} }
/// Number of cards moved onto foundations this game, read from the
/// upstream session stats. Cumulative like [`Self::recycle_count`] —
/// not rolled back on undo.
pub fn move_to_foundation_count(&self) -> u32 {
self.session.stats().stats().move_to_foundation_count()
}
/// Number of stacks moved onto tableaus (from stock or another tableau)
/// this game, read from the upstream session stats. Cumulative — not
/// rolled back on undo.
pub fn move_to_tableau_count(&self) -> u32 {
self.session.stats().stats().move_to_tableau_count()
}
/// Number of cards taken back off a foundation this game, read from the
/// upstream session stats. Cumulative — not rolled back on undo.
pub fn move_from_foundation_count(&self) -> u32 {
self.session.stats().stats().move_from_foundation_count()
}
/// Number of face-down cards revealed (flipped up) this game, read from
/// the upstream session stats. Cumulative — not rolled back on undo.
pub fn flip_up_count(&self) -> u32 {
self.session.stats().stats().flip_up_bonus_count()
}
/// Total moves made this game (draws, recycles, and card moves), derived /// Total moves made this game (draws, recycles, and card moves), derived
/// from the session's instruction history length. /// from the session's instruction history length.
pub fn move_count(&self) -> u32 { pub fn move_count(&self) -> u32 {
@@ -579,6 +402,16 @@ impl GameState {
KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation) KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation)
} }
/// Collects the session instruction history as upstream types for schema v4
/// serialisation.
fn saved_moves(&self) -> Vec<KlondikeInstruction> {
self.session
.history()
.iter()
.map(|snapshot| *snapshot.instruction())
.collect()
}
/// Returns the deterministic instruction history for the current deal as /// Returns the deterministic instruction history for the current deal as
/// upstream [`KlondikeInstruction`] values. /// upstream [`KlondikeInstruction`] values.
/// ///
@@ -606,7 +439,10 @@ impl GameState {
self.session.history().len() self.session.history().len()
} }
fn cards_with_face(cards: impl IntoIterator<Item = Card>, face_up: bool) -> Vec<(Card, bool)> { fn cards_with_face(
cards: impl IntoIterator<Item = Card>,
face_up: bool,
) -> Vec<(Card, bool)> {
cards.into_iter().map(|card| (card, face_up)).collect() cards.into_iter().map(|card| (card, face_up)).collect()
} }
@@ -671,10 +507,8 @@ impl GameState {
Self::cards_with_face(cards.iter().cloned(), true) Self::cards_with_face(cards.iter().cloned(), true)
} }
KlondikePile::Tableau(tableau) => { KlondikePile::Tableau(tableau) => {
let mut cards = Self::cards_with_face( let mut cards =
state.tableau_face_down_cards(tableau).iter().cloned(), Self::cards_with_face(state.tableau_face_down_cards(tableau).iter().cloned(), false);
false,
);
cards.extend(Self::cards_with_face( cards.extend(Self::cards_with_face(
state.tableau_face_up_cards(tableau).iter().cloned(), state.tableau_face_up_cards(tableau).iter().cloned(),
true, true,
@@ -989,7 +823,9 @@ impl GameState {
) -> Option<(KlondikePile, KlondikePile, usize)> { ) -> Option<(KlondikePile, KlondikePile, usize)> {
let state = self.session.state().state().state(); let state = self.session.state().state().state();
match instruction { match instruction {
KlondikeInstruction::RotateStock => Some((KlondikePile::Stock, KlondikePile::Stock, 1)), KlondikeInstruction::RotateStock => {
Some((KlondikePile::Stock, KlondikePile::Stock, 1))
}
KlondikeInstruction::DstFoundation(dst_foundation) => { KlondikeInstruction::DstFoundation(dst_foundation) => {
if matches!(dst_foundation.src, KlondikePile::Foundation(_)) { if matches!(dst_foundation.src, KlondikePile::Foundation(_)) {
return None; return None;
@@ -1092,7 +928,10 @@ impl GameState {
/// ///
/// Returns [`MoveError::RuleViolation`] if the instruction is illegal in the /// Returns [`MoveError::RuleViolation`] if the instruction is illegal in the
/// current position, or [`MoveError::GameAlreadyWon`] once the game is over. /// current position, or [`MoveError::GameAlreadyWon`] once the game is over.
pub fn apply_instruction(&mut self, instruction: KlondikeInstruction) -> Result<(), MoveError> { pub fn apply_instruction(
&mut self,
instruction: KlondikeInstruction,
) -> Result<(), MoveError> {
if self.is_won() { if self.is_won() {
return Err(MoveError::GameAlreadyWon); return Err(MoveError::GameAlreadyWon);
} }
@@ -1276,66 +1115,6 @@ impl GameState {
}) })
} }
/// Full winning line from the current position:
///
/// * `Ok(Some(line))` — winnable; applying every instruction in order via
/// [`GameState::apply_instruction`] reaches a won game.
/// * `Ok(None)` — provably unwinnable, or already won (no moves to show).
/// * `Err(SolveError)` — inconclusive; budget exhausted before a verdict.
///
/// Delegates to upstream [`card_game::Session::solve`] on a solve-budgeted
/// copy of the board like [`GameState::solve_first_move`], but returns the
/// whole path instead of the first move, compacted with
/// [`card_game::Solution::clean_solution`] (drops move ranges that loop
/// back to an already-seen state — the raw DFS trace is full of them).
///
/// Foundation→foundation shuffles ([`KlondikeInstruction::is_useless`])
/// are additionally stripped when the remaining sequence still replays to
/// a win; if stripping one would break a later move's preconditions the
/// unstripped cleaned line is returned instead, so the replay contract
/// above always holds. `clean_solution` is quadratic-ish in line length —
/// callers should run this off the UI thread with modest budgets.
pub fn winning_line(
&self,
moves_budget: u64,
states_budget: u64,
) -> Result<Option<Vec<KlondikeInstruction>>, SolveError> {
if self.is_won() {
return Ok(None);
}
let inner = KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation);
let config = SessionConfig {
inner: inner.clone(),
undo_penalty: 0,
solve_moves_budget: moves_budget,
solve_states_budget: states_budget,
};
let start = self.session.state().state().clone();
let session = Session::new(start.clone(), config);
let Some(solution) = session.solve()? else {
return Ok(None);
};
let cleaned: Vec<KlondikeInstruction> = solution
.clean_solution()
.iter()
.map(|snapshot| *snapshot.instruction())
.collect();
let filtered: Vec<KlondikeInstruction> = cleaned
.iter()
.copied()
.filter(|instruction| !instruction.is_useless())
.collect();
if line_replays_to_win(start, &inner, &filtered) {
Ok(Some(filtered))
} else {
Ok(Some(cleaned))
}
}
/// Solvability of a fresh Classic-mode deal from `seed` + `draw_mode`. /// Solvability of a fresh Classic-mode deal from `seed` + `draw_mode`.
/// ///
/// Fresh-deal solving models standard Klondike rules, so the non-standard /// Fresh-deal solving models standard Klondike rules, so the non-standard
@@ -1353,25 +1132,6 @@ impl GameState {
} }
} }
/// `true` when applying `line` in order from `start` is legal at every step
/// and ends in a won game. Pure replay check backing
/// [`GameState::winning_line`]'s "the returned sequence always replays to a
/// win" contract.
fn line_replays_to_win(
mut state: Klondike,
config: &KlondikeConfig,
line: &[KlondikeInstruction],
) -> bool {
let mut stats = <Klondike as card_game::Game>::Stats::default();
for &instruction in line {
if !state.is_instruction_valid(config, instruction) {
return false;
}
state.process_instruction(&mut stats, config, instruction);
}
state.is_win()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1536,13 +1296,12 @@ mod tests {
game.take_from_foundation = false; game.take_from_foundation = false;
assert!(!game.can_move_cards(&from, &to, 1)); assert!(!game.can_move_cards(&from, &to, 1));
assert!(legal_pile_moves(&game).iter().all(|(f, t, _)| !matches!( assert!(
f, legal_pile_moves(&game)
KlondikePile::Foundation(_) .iter()
) || !matches!( .all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_))
t, || !matches!(t, KlondikePile::Tableau(_)))
KlondikePile::Tableau(_) );
)));
assert!(game.move_cards(from, to, 1).is_err()); assert!(game.move_cards(from, to, 1).is_err());
} }
@@ -1597,244 +1356,14 @@ mod tests {
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded))); assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
} }
// ── Full winning line (winning_line) ──────────────────────────────────
#[test]
fn winning_line_replays_to_a_won_game() {
// Seed 0xD1FF_0000_0000_0012 / DrawOne is proven Winnable at 5k
// budgets by `budget_is_passed_through_not_clamped`. Standard rules
// (take-from-foundation off) keep the search space identical to
// that baseline. The returned line must apply cleanly through the
// normal instruction pipeline and end in a win — the whole point
// of the API contract.
let mut game = GameState::new(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne);
game.take_from_foundation = false;
let line = game
.winning_line(5_000, 5_000)
.expect("this seed must not exhaust a 5k budget")
.expect("this seed must be winnable");
assert!(!line.is_empty(), "a winnable unfinished game needs moves");
for (i, instruction) in line.iter().enumerate() {
game.apply_instruction(*instruction)
.unwrap_or_else(|e| panic!("move {i} of the line must be legal: {e}"));
}
assert!(game.is_won(), "line must end in a won game");
}
#[test]
fn winning_line_contains_no_useless_moves() {
// The foundation→foundation strip must survive the replay check on
// this seed; a line shown to the player should never shuffle
// between foundations. Same proven-winnable seed and standard
// rules as `winning_line_replays_to_a_won_game`.
let mut game = GameState::new(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne);
game.take_from_foundation = false;
let line = game
.winning_line(5_000, 5_000)
.expect("budget")
.expect("winnable");
assert!(
!line.iter().any(KlondikeInstruction::is_useless),
"filtered line must not contain foundation→foundation moves"
);
}
#[test]
fn winning_line_is_inconclusive_when_budget_exhausted() {
let game = GameState::new(7, DrawStockConfig::DrawOne);
let outcome = game.winning_line(5_000, 0);
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
}
#[test]
fn winning_line_matches_first_move_verdict() {
// The two solver entry points must agree on winnability for the
// same position and budgets (both are deterministic DFS).
let game = GameState::new(42, DrawStockConfig::DrawOne);
let first = game.solve_first_move(5_000, 5_000);
let line = game.winning_line(5_000, 5_000);
assert_eq!(
matches!(first, Ok(Some(_))),
matches!(line, Ok(Some(_))),
"solve_first_move and winning_line disagree on winnability"
);
}
#[test] #[test]
fn budget_is_passed_through_not_clamped() { fn budget_is_passed_through_not_clamped() {
// This seed is Inconclusive at 1k states but Winnable at 5k — proving the // This seed is Inconclusive at 1k states but Winnable at 5k — proving the
// budget reaches the solver unchanged. // budget reaches the solver unchanged.
let easy = GameState::solve_fresh_deal( let easy = GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 1_000, 1_000);
0xD1FF_0000_0000_0012, let medium =
DrawStockConfig::DrawOne, GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 5_000, 5_000);
1_000,
1_000,
);
let medium = GameState::solve_fresh_deal(
0xD1FF_0000_0000_0012,
DrawStockConfig::DrawOne,
5_000,
5_000,
);
assert!(easy.is_err()); assert!(easy.is_err());
assert!(matches!(medium, Ok(Some(_)))); assert!(matches!(medium, Ok(Some(_))));
} }
/// Play a few real moves on a fresh deal and return the game.
fn game_with_some_moves(seed: u64) -> GameState {
let mut game = GameState::new(seed, DrawStockConfig::DrawOne);
for _ in 0..40 {
let instructions = game.possible_instructions();
let applied = instructions
.into_iter()
.find(|i| game.clone().apply_instruction(*i).is_ok())
.and_then(|i| game.apply_instruction(i).ok());
if applied.is_none() && game.draw().is_err() {
break;
}
}
assert!(
!game.instruction_history().is_empty(),
"test needs at least one recorded move"
);
game
}
#[test]
fn recording_round_trips_through_upstream_serde() {
let game = game_with_some_moves(51);
let recording = game.recording();
let json = serde_json::to_string(&recording).expect("serialize recording");
let restored: SessionRecording = serde_json::from_str(&json).expect("parse recording");
assert_eq!(recording, restored);
assert_eq!(recording.instructions(), game.instruction_history());
}
#[test]
fn from_recording_replays_to_identical_board_without_seed_dealing() {
let game = game_with_some_moves(145);
let recording = game.recording();
let json = serde_json::to_string(&recording).expect("serialize recording");
let restored: SessionRecording = serde_json::from_str(&json).expect("parse recording");
// Deliberately pass a DIFFERENT seed: the board must come from the
// recording, proving playback no longer depends on seed→deal mapping.
let (mut replayed, instructions) =
GameState::from_recording(&restored, 0xDEAD_BEEF, game.mode);
assert_eq!(replayed.draw_mode(), game.draw_mode());
for instruction in instructions {
replayed
.apply_instruction(instruction)
.expect("recorded instruction must replay cleanly");
}
for pile in [KlondikePile::Stock]
.into_iter()
.chain(crate::TABLEAUS.map(KlondikePile::Tableau))
.chain(crate::FOUNDATIONS.map(KlondikePile::Foundation))
{
assert_eq!(
replayed.pile(pile),
game.pile(pile),
"pile {pile:?} differs"
);
}
assert_eq!(replayed.waste_cards(), game.waste_cards());
assert_eq!(replayed.move_count(), game.move_count());
}
/// v6 save files carry the deal in the recording; the `seed` field is
/// presentation metadata. Corrupting it must not change the loaded board.
#[test]
fn v6_save_load_ignores_seed_for_dealing() {
let game = game_with_some_moves(51);
let mut value = serde_json::to_value(&game).expect("serialise");
assert_eq!(value["schema_version"], 6);
value["seed"] = serde_json::Value::from(0xDEAD_BEEF_u64);
let loaded: GameState =
serde_json::from_value(value).expect("v6 save with wrong seed must still load");
assert_eq!(loaded.stock_cards(), game.stock_cards());
assert_eq!(loaded.waste_cards(), game.waste_cards());
for index in 0..7 {
let t = GameState::tableau_from_index(index).expect("tableau index");
assert_eq!(
loaded.pile(KlondikePile::Tableau(t)),
game.pile(KlondikePile::Tableau(t)),
"tableau {index} differs"
);
}
assert_eq!(loaded.move_count(), game.move_count());
assert_eq!(loaded.score(), game.score());
}
/// Legacy v5 files (seed + saved_moves, no recording) must still load
/// through the seed-dealt path until players resave as v6.
#[test]
fn v5_legacy_save_still_loads() {
let game = game_with_some_moves(145);
let v5 = serde_json::json!({
"draw_mode": game.draw_mode(),
"mode": game.mode,
"elapsed_seconds": 12,
"seed": game.seed,
"take_from_foundation": true,
"schema_version": 5,
"saved_moves": game.instruction_history(),
});
let loaded: GameState =
serde_json::from_value(v5).expect("v5 save must load via the legacy seed path");
assert_eq!(loaded.move_count(), game.move_count());
assert_eq!(loaded.stock_cards(), game.stock_cards());
assert_eq!(loaded.elapsed_seconds, 12);
}
/// A v6 file without the recording payload is malformed and must be
/// rejected with an error rather than silently re-dealt from the seed.
#[test]
fn v6_save_without_recording_is_rejected() {
let game = GameState::new(7, DrawStockConfig::DrawOne);
let mut value = serde_json::to_value(&game).expect("serialise");
value.as_object_mut().expect("object").remove("recording");
let err = serde_json::from_value::<GameState>(value)
.expect_err("v6 save without recording must fail");
assert!(err.to_string().contains("recording"), "got: {err}");
}
/// A recording whose instruction list is invalid for its own deal must
/// be rejected on load — tampered or corrupt files surface an error,
/// never a silently wrong board.
#[test]
fn v6_save_with_invalid_history_is_rejected() {
use klondike::{DstFoundation, Foundation, KlondikePile};
// A foundation move from an empty tableau is never legal on a fresh
// deal's first instruction slot for this source shape.
let bogus = KlondikeInstruction::DstFoundation(DstFoundation {
src: KlondikePile::Foundation(Foundation::Foundation1),
foundation: Foundation::Foundation2,
});
let recording =
SessionRecording::from_instructions_unchecked(7, DrawStockConfig::DrawOne, [bogus]);
let v6 = serde_json::json!({
"draw_mode": DrawStockConfig::DrawOne,
"mode": GameMode::Classic,
"elapsed_seconds": 0,
"seed": 7,
"take_from_foundation": true,
"schema_version": 6,
"recording": recording,
});
let err =
serde_json::from_value::<GameState>(v6).expect_err("invalid history must be rejected");
assert!(err.to_string().contains("invalid"), "got: {err}");
}
#[test]
fn from_recording_of_fresh_deal_returns_empty_instructions() {
let game = GameState::new(7, DrawStockConfig::DrawThree);
let recording = game.recording();
assert!(recording.is_empty());
let (replayed, instructions) = GameState::from_recording(&recording, 7, game.mode);
assert!(instructions.is_empty());
assert_eq!(replayed.stock_cards(), game.stock_cards());
assert_eq!(replayed.draw_mode(), DrawStockConfig::DrawThree);
}
} }
+3 -15
View File
@@ -3,7 +3,6 @@ pub mod error;
pub mod game_state; pub mod game_state;
pub mod klondike_adapter; pub mod klondike_adapter;
pub mod scoring; pub mod scoring;
pub mod spider;
// Re-export the upstream types that cross the solitaire_core API boundary so // Re-export the upstream types that cross the solitaire_core API boundary so
// downstream crates (engine, wasm) can import from one place without a direct // downstream crates (engine, wasm) can import from one place without a direct
@@ -13,23 +12,12 @@ pub mod spider;
// re-exported — they are only used internally (in `klondike_adapter.rs` and // re-exported — they are only used internally (in `klondike_adapter.rs` and
// when decoding instructions to piles in `instruction_to_piles`) and do not // when decoding instructions to piles in `instruction_to_piles`) and do not
// appear in any public method signature. // appear in any public method signature.
pub use card_game::{Card, Deck, Rank, SolveError, Suit}; pub use card_game::{Card, Deck, Rank, Session, SolveError, Suit};
pub use klondike::{ pub use klondike::{DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau};
DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau,
};
// Solvability check API (delegates to `card_game::Session::solve`); replaces the // Solvability check API (delegates to `card_game::Session::solve`); replaces the
// former `solitaire_data::solver` wrapper module. // former `solitaire_data::solver` wrapper module.
pub use game_state::{ pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SessionRecording, SolveOutcome,
};
// Spider rules (second `card_game::Game` implementation; engine UI is a
// later phase — nothing outside solitaire_core consumes these yet).
pub use spider::{
RunLength, Spider, SpiderConfig, SpiderGameState, SpiderInstruction, SpiderIter, SpiderMove,
SpiderScoring, SpiderStats, SpiderSuits, SpiderTableau,
};
/// All four foundation slots, in slot order. /// All four foundation slots, in slot order.
/// ///
+2 -9
View File
@@ -41,20 +41,13 @@ fn all_cards(game: &GameState) -> Vec<Card> {
); );
} }
for t in &tableaux { for t in &tableaux {
cards.extend( cards.extend(game.pile(KlondikePile::Tableau(*t)).iter().map(|(c, _)| c.clone()));
game.pile(KlondikePile::Tableau(*t))
.iter()
.map(|(c, _)| c.clone()),
);
} }
cards cards
} }
fn draw_mode_strategy() -> impl Strategy<Value = DrawStockConfig> { fn draw_mode_strategy() -> impl Strategy<Value = DrawStockConfig> {
prop_oneof![ prop_oneof![Just(DrawStockConfig::DrawOne), Just(DrawStockConfig::DrawThree)]
Just(DrawStockConfig::DrawOne),
Just(DrawStockConfig::DrawThree)
]
} }
/// Apply a sequence of random actions to a game, silently ignoring errors. /// Apply a sequence of random actions to a game, silently ignoring errors.
File diff suppressed because it is too large Load Diff
-7
View File
@@ -24,9 +24,6 @@ uuid = { workspace = true }
dirs = { workspace = true } dirs = { workspace = true }
reqwest = { workspace = true } reqwest = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
# Theme-store downloads are verified against the catalog's SHA-256
# before the archive is handed to the engine's theme importer.
sha2 = { workspace = true }
# `keyring-core` is the typed Entry/Error API used by # `keyring-core` is the typed Entry/Error API used by
# `auth_tokens`. The crate's own dependency tree pulls in # `auth_tokens`. The crate's own dependency tree pulls in
@@ -50,10 +47,6 @@ sqlx = { workspace = true }
jsonwebtoken = { workspace = true } jsonwebtoken = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
# theme_store_round_trip builds a theme zip in a tempdir for the
# in-process store server.
zip = { workspace = true }
tempfile = { workspace = true }
[lints] [lints]
workspace = true workspace = true
+12 -18
View File
@@ -105,9 +105,10 @@ pub use stats::{StatsExt, StatsSnapshot};
pub mod storage; pub mod storage;
pub use storage::{ pub use storage::{
TimeAttackSession, cleanup_orphaned_tmp_files, delete_game_state_at, TimeAttackSession, cleanup_orphaned_tmp_files, delete_game_state_at,
delete_time_attack_session_at, game_state_file_path, load_game_state_from, load_stats_from, delete_time_attack_session_at, game_state_file_path, load_game_state_from, load_stats,
load_time_attack_session_from, save_game_state_to, save_stats_to, save_time_attack_session_to, load_stats_from, load_time_attack_session_from, load_time_attack_session_from_at,
stats_file_path, time_attack_session_path, save_game_state_to, save_stats, save_stats_to, save_time_attack_session_to, stats_file_path,
time_attack_session_path, time_attack_session_with_now,
}; };
pub mod achievements; pub mod achievements;
@@ -133,17 +134,13 @@ pub use challenge::{CHALLENGE_SEEDS, challenge_count, challenge_seed_for};
pub mod difficulty_seeds; pub mod difficulty_seeds;
pub use difficulty_seeds::{DifficultySeeds, seeds_for}; pub use difficulty_seeds::{DifficultySeeds, seeds_for};
pub mod transfer;
pub use transfer::{
BundleError, DATA_BUNDLE_SCHEMA_VERSION, DataBundle, data_bundle_path, load_bundle_from,
save_bundle_to,
};
pub mod settings; pub mod settings;
pub use settings::{ pub use settings::{
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend, AnimSpeed, REPLAY_MOVE_INTERVAL_MAX_SECS, REPLAY_MOVE_INTERVAL_MIN_SECS,
TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_STEP_SECS, Theme, WindowGeometry, load_settings_from, REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend,
save_settings_to, settings_file_path, TIME_BONUS_MULTIPLIER_MAX, TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP,
TOOLTIP_DELAY_MAX_SECS, TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS, Theme, WindowGeometry,
load_settings_from, save_settings_to, settings_file_path,
}; };
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
@@ -155,18 +152,15 @@ mod android_keystore;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub mod auth_tokens; pub mod auth_tokens;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub use auth_tokens::{TokenError, delete_tokens, store_tokens}; pub use auth_tokens::{
TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens,
};
pub mod sync_client; pub mod sync_client;
pub use sync_client::LocalOnlyProvider; pub use sync_client::LocalOnlyProvider;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub use sync_client::{SolitaireServerClient, provider_for_backend}; pub use sync_client::{SolitaireServerClient, provider_for_backend};
#[cfg(not(target_arch = "wasm32"))]
pub mod theme_store_client;
#[cfg(not(target_arch = "wasm32"))]
pub use theme_store_client::{ThemeStoreClient, ThemeStoreError};
pub mod replay; pub mod replay;
pub use replay::{ pub use replay::{
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay, REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay,
+80 -88
View File
@@ -12,24 +12,22 @@
//! carries any other version so older replays are silently dropped instead //! carries any other version so older replays are silently dropped instead
//! of crashing the loader. //! of crashing the loader.
//! //!
//! The payload is a [`SessionRecording`](solitaire_core::SessionRecording): //! The recording is intentionally minimal — only the
//! the upstream `card_game` session serialisation, which stores the dealt //! [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) inputs that
//! board **explicitly** plus the ordered instruction list. `Undo` is not //! successfully advanced the game. `Undo` is **not** recorded: a replay
//! recorded: a replay represents the canonical path the player ultimately //! represents the canonical path the player ultimately took to win, so
//! took to win, so backed-out missteps simply do not appear (the session //! backed-out missteps simply do not appear in the move list. The starting
//! pops them from its history). //! deal is not stored either — the [`seed`](Replay::seed) +
//! //! [`draw_mode`](Replay::draw_mode) + [`mode`](Replay::mode) are sufficient
//! Storing the deal (rather than re-dealing from [`seed`](Replay::seed) at //! for `GameState::new_with_mode` to rebuild the identical layout.
//! playback time, as schemas ≤ v3 did) makes replays immune to seed→deal
//! mapping drift across RNG or upstream-crate upgrades — the exact failure
//! that silently broke every pre-upgrade replay. `seed`, `draw_mode`, and
//! `mode` remain as presentation/indexing metadata only.
//! //!
//! Each recorded move is the player's atomic *input*, not its outcome. //! Each recorded move is the player's atomic *input*, not its outcome.
//! `KlondikeInstruction::RotateStock` covers every click on the stock pile; //! `KlondikeInstruction::RotateStock` covers every click on the stock pile;
//! the engine resolves draw-vs-recycle deterministically from the current //! the engine resolves draw-vs-recycle deterministically from the current
//! stock state during playback, so the same input always produces the same //! stock state during playback, so the same input always produces the same
//! effect on the same starting deal. //! effect on the same starting deal. Runtime-only pile-position types are
//! never serialised — the instruction itself serialises via its compact
//! upstream serde representation.
use std::fs; use std::fs;
use std::io; use std::io;
@@ -37,7 +35,7 @@ use std::path::{Path, PathBuf};
use chrono::NaiveDate; use chrono::NaiveDate;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use solitaire_core::{DrawStockConfig, SessionRecording, game_state::GameMode}; use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json"; const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json"; const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
@@ -79,19 +77,13 @@ fn history_schema_v0() -> u32 {
/// variants which carried the *outcome* of a stock interaction rather /// variants which carried the *outcome* of a stock interaction rather
/// than the player's atomic input. /// than the player's atomic input.
/// - v2: `Draw` + `Recycle` collapsed into a single `StockClick` variant. /// - v2: `Draw` + `Recycle` collapsed into a single `StockClick` variant.
/// - v3: the bespoke `ReplayMove` serde mirror was dropped. Moves /// - v3 (current): the bespoke `ReplayMove` serde mirror was dropped. Moves
/// were stored directly as upstream `KlondikeInstruction` (compact /// are now stored directly as upstream
/// int serde); `StockClick` became `RotateStock`. Pile-position types are /// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) (compact
/// runtime-only and are never serialised. The starting deal was still /// int serde); `StockClick` is now `RotateStock`. Pile-position types are
/// rebuilt from the seed at playback time. /// runtime-only and are never serialised. v1/v2 files fail to deserialise
/// - v4 (current): the bare `moves` list was replaced by a /// and are discarded by the loader.
/// [`SessionRecording`](solitaire_core::SessionRecording) — the upstream pub const REPLAY_SCHEMA_VERSION: u32 = 3;
/// `card_game` session serialisation carrying the dealt board explicitly
/// plus the instruction list. Playback no longer re-deals from the seed,
/// so replays survive RNG/upstream upgrades that change the seed→deal
/// mapping (which invalidated every v3 replay). v1v3 files fail the
/// version gate and are discarded by the loader.
pub const REPLAY_SCHEMA_VERSION: u32 = 4;
/// Default value for [`Replay::schema_version`] when deserialising files /// Default value for [`Replay::schema_version`] when deserialising files
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`] /// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`]
@@ -102,10 +94,9 @@ fn schema_v0() -> u32 {
/// A complete recording of a single winning game. /// A complete recording of a single winning game.
/// ///
/// Replays are reconstructed via /// Replays are reconstructed by rebuilding a fresh
/// `GameState::from_recording(&replay.recording, replay.seed, replay.mode)`, /// `GameState::new_with_mode(seed, draw_mode, mode)` and applying the
/// which rebuilds the recorded deal directly and returns the instruction /// [`moves`](Self::moves) in order. The presentation fields
/// list to step through. The presentation fields
/// ([`time_seconds`](Self::time_seconds), [`final_score`](Self::final_score), /// ([`time_seconds`](Self::time_seconds), [`final_score`](Self::final_score),
/// [`recorded_at`](Self::recorded_at)) drive the Stats UI caption such as /// [`recorded_at`](Self::recorded_at)) drive the Stats UI caption such as
/// "Replay (2:14 win on 2026-05-02)". /// "Replay (2:14 win on 2026-05-02)".
@@ -114,9 +105,8 @@ pub struct Replay {
/// Schema version. See [`REPLAY_SCHEMA_VERSION`]. /// Schema version. See [`REPLAY_SCHEMA_VERSION`].
#[serde(default = "schema_v0")] #[serde(default = "schema_v0")]
pub schema_version: u32, pub schema_version: u32,
/// Seed the recorded game was originally dealt from. Presentation / /// Seed used for the deal — replay rasterises the deck via
/// indexing metadata only — playback rebuilds the board from /// `GameState::new_with_mode(seed, draw_mode, mode)`.
/// [`recording`](Self::recording), never by re-dealing this seed.
pub seed: u64, pub seed: u64,
/// Draw mode the recorded game was played in. /// Draw mode the recorded game was played in.
pub draw_mode: DrawStockConfig, pub draw_mode: DrawStockConfig,
@@ -129,10 +119,11 @@ pub struct Replay {
pub final_score: i32, pub final_score: i32,
/// ISO-8601 date the win was recorded. /// ISO-8601 date the win was recorded.
pub recorded_at: NaiveDate, pub recorded_at: NaiveDate,
/// The dealt board plus the ordered instruction list, serialised via the /// Ordered move list. Each entry is the atomic
/// upstream `card_game` session serializers. Self-contained: playback /// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) the player
/// needs nothing else to reproduce the game move-for-move. /// issued, replayable against a fresh `GameState` constructed from the
pub recording: SessionRecording, /// seed via `GameState::apply_instruction`.
pub moves: Vec<KlondikeInstruction>,
/// Public share URL for this replay on the active sync backend, set /// Public share URL for this replay on the active sync backend, set
/// by `sync_plugin::poll_replay_upload_result` when the upload /// by `sync_plugin::poll_replay_upload_result` when the upload
/// task resolves. `None` when the player won on a local-only /// task resolves. `None` when the player won on a local-only
@@ -142,12 +133,11 @@ 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 the [`recording`](Self::recording)'s instruction list /// Index into [`moves`](Self::moves) of the move that triggered
/// of the move that triggered the win condition (i.e. completed the /// the win condition (i.e. completed the last foundation pile).
/// last foundation pile).
/// ///
/// For replays recorded by the live engine this is always /// For replays recorded by the live engine this is always
/// `Some(recording.len() - 1)` because recording freezes on win — but /// `Some(moves.len() - 1)` because recording freezes on win — but
/// the field is stored explicitly so the playback UI can read it /// the field is stored explicitly so the playback UI can read it
/// directly without re-deriving "the last move was the win" each /// directly without re-deriving "the last move was the win" each
/// time, and to leave room for future recording semantics that /// time, and to leave room for future recording semantics that
@@ -182,7 +172,7 @@ impl Replay {
time_seconds: u64, time_seconds: u64,
final_score: i32, final_score: i32,
recorded_at: NaiveDate, recorded_at: NaiveDate,
recording: SessionRecording, moves: Vec<KlondikeInstruction>,
) -> Self { ) -> Self {
Self { Self {
schema_version: REPLAY_SCHEMA_VERSION, schema_version: REPLAY_SCHEMA_VERSION,
@@ -192,7 +182,7 @@ impl Replay {
time_seconds, time_seconds,
final_score, final_score,
recorded_at, recorded_at,
recording, moves,
share_url: None, share_url: None,
win_move_index: None, win_move_index: None,
} }
@@ -203,7 +193,7 @@ impl Replay {
/// [`Replay::new`]: /// [`Replay::new`]:
/// ///
/// ```ignore /// ```ignore
/// let replay = Replay::new(...).with_win_move_index(recording.len().checked_sub(1)); /// 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 /// `None` is a valid input — useful for tests that don't care about
@@ -440,8 +430,7 @@ pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
mod tests { mod tests {
use super::*; use super::*;
use klondike::{ use klondike::{
DstFoundation, DstTableau, Foundation, KlondikeInstruction, KlondikePile, DstFoundation, DstTableau, Foundation, KlondikePile, KlondikePileStack, Tableau,
KlondikePileStack, Tableau,
}; };
use std::env; use std::env;
@@ -458,22 +447,18 @@ mod tests {
134, 134,
5_120, 5_120,
date, date,
SessionRecording::from_instructions_unchecked( vec![
12345, KlondikeInstruction::RotateStock,
DrawStockConfig::DrawThree, KlondikeInstruction::DstTableau(DstTableau {
[ src: KlondikePileStack::Stock,
KlondikeInstruction::RotateStock, tableau: Tableau::Tableau4,
KlondikeInstruction::DstTableau(DstTableau { }),
src: KlondikePileStack::Stock, KlondikeInstruction::RotateStock,
tableau: Tableau::Tableau4, KlondikeInstruction::DstFoundation(DstFoundation {
}), src: KlondikePile::Tableau(Tableau::Tableau4),
KlondikeInstruction::RotateStock, foundation: Foundation::Foundation1,
KlondikeInstruction::DstFoundation(DstFoundation { }),
src: KlondikePile::Tableau(Tableau::Tableau4), ],
foundation: Foundation::Foundation1,
}),
],
),
) )
} }
@@ -533,22 +518,25 @@ mod tests {
/// rolling history wiped on the v0.19.0 update. /// rolling history wiped on the v0.19.0 update.
#[test] #[test]
fn replay_loads_when_share_url_field_is_absent() { fn replay_loads_when_share_url_field_is_absent() {
// Build a current-schema JSON object, then strip the optional let pre_v019_json = format!(
// fields to simulate a file written before they existed. r#"{{
let mut value = serde_json::to_value(sample_replay()).expect("serialise sample"); "schema_version": {schema},
let obj = value.as_object_mut().expect("replay serialises as object"); "seed": 1,
obj.remove("share_url"); "draw_mode": "DrawOne",
obj.remove("win_move_index"); "mode": "Classic",
let parsed: Replay = serde_json::from_value(value) "time_seconds": 60,
.expect("replay JSON without optional fields must still deserialise"); "final_score": 100,
"recorded_at": "2025-01-01",
"moves": []
}}"#,
schema = REPLAY_SCHEMA_VERSION,
);
let parsed: Replay = serde_json::from_str(&pre_v019_json)
.expect("pre-v0.19.0 replay JSON must still deserialise");
assert!( assert!(
parsed.share_url.is_none(), parsed.share_url.is_none(),
"missing share_url field must default to None", "missing share_url field must default to None",
); );
assert!(
parsed.win_move_index.is_none(),
"missing win_move_index field must default to None",
);
} }
/// Atomic-write contract — `.tmp` must not be left behind after /// Atomic-write contract — `.tmp` must not be left behind after
@@ -600,11 +588,7 @@ mod tests {
60, 60,
id, id,
date, date,
SessionRecording::from_instructions_unchecked( vec![KlondikeInstruction::RotateStock],
id as u64,
DrawStockConfig::DrawOne,
[KlondikeInstruction::RotateStock],
),
) )
} }
@@ -840,14 +824,22 @@ mod tests {
let path = tmp_path("legacy_no_win_move_index"); let path = tmp_path("legacy_no_win_move_index");
let _ = fs::remove_file(&path); let _ = fs::remove_file(&path);
// Current-schema replay JSON with the win_move_index field stripped — // Hand-rolled minimal current-schema replay JSON with no
// the additive field must still default to None. // win_move_index field — the additive field must still default to None.
let mut value = serde_json::to_value(sample_replay()).expect("serialise sample"); let no_field = format!(
value r#"{{
.as_object_mut() "schema_version": {schema},
.expect("replay serialises as object") "seed": 1,
.remove("win_move_index"); "draw_mode": "DrawOne",
fs::write(&path, serde_json::to_string(&value).expect("to_string")).expect("write fixture"); "mode": "Classic",
"time_seconds": 60,
"final_score": 100,
"recorded_at": "2026-05-02",
"moves": []
}}"#,
schema = REPLAY_SCHEMA_VERSION,
);
fs::write(&path, no_field).expect("write fixture");
let loaded = load_latest_replay_from(&path).expect("load"); let loaded = load_latest_replay_from(&path).expect("load");
assert_eq!(loaded.win_move_index, None); assert_eq!(loaded.win_move_index, None);
+3 -65
View File
@@ -9,10 +9,7 @@ use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use solitaire_core::{ use solitaire_core::{DrawStockConfig, game_state::DifficultyLevel};
DrawStockConfig,
game_state::{DifficultyLevel, GameMode},
};
const SETTINGS_FILE_NAME: &str = "settings.json"; const SETTINGS_FILE_NAME: &str = "settings.json";
@@ -154,14 +151,6 @@ pub struct Settings {
/// `#[serde(default)]`. /// `#[serde(default)]`.
#[serde(default)] #[serde(default)]
pub reduce_motion_mode: bool, pub reduce_motion_mode: bool,
/// When `true`, replaces translucent "glass" chrome (the floating
/// touch tab bar) with opaque fills so table content behind it can
/// never reduce label/icon contrast. The accessibility counterpart
/// of iOS "Reduce Transparency". Older `settings.json` files written
/// before this field existed deserialize cleanly to `false` thanks
/// to `#[serde(default)]`.
#[serde(default)]
pub reduce_transparency_mode: bool,
/// Window size and screen position to restore on next launch. `None` /// Window size and screen position to restore on next launch. `None`
/// means "use platform defaults" — set on first run, then populated /// means "use platform defaults" — set on first run, then populated
/// as the player resizes / moves the window. Older `settings.json` /// as the player resizes / moves the window. Older `settings.json`
@@ -172,10 +161,8 @@ pub struct Settings {
/// Identifier of the active card-art theme. Matches `meta.id` from /// Identifier of the active card-art theme. Matches `meta.id` from
/// the theme's `theme.ron` manifest. `"dark"` and `"classic"` are /// the theme's `theme.ron` manifest. `"dark"` and `"classic"` are
/// always present; user-supplied themes register under their own ids. /// always present; user-supplied themes register under their own ids.
/// Older `settings.json` files that stored `"default"` (the /// Older `settings.json` files that stored `"default"` or `"classic"`
/// pre-rename id of the dark theme) are migrated to `"dark"` by /// are migrated to `"dark"` by [`Settings::sanitized`].
/// [`Settings::sanitized`]; `"classic"` is a valid player choice
/// and is never rewritten.
#[serde(default = "default_theme_id")] #[serde(default = "default_theme_id")]
pub selected_theme_id: String, pub selected_theme_id: String,
/// Set to `true` once the achievement-onboarding info-toast has been /// Set to `true` once the achievement-onboarding info-toast has been
@@ -259,38 +246,6 @@ pub struct Settings {
/// cleanly to `None` via `#[serde(default)]`. /// cleanly to `None` via `#[serde(default)]`.
#[serde(default)] #[serde(default)]
pub last_difficulty: Option<DifficultyLevel>, pub last_difficulty: Option<DifficultyLevel>,
/// Mode of the last game the player launched from the home overlay.
/// The home hero "New Game" button replays this mode with one tap.
/// Older `settings.json` files written before this field existed
/// deserialize cleanly to `GameMode::Classic` via `#[serde(default)]`.
#[serde(default)]
pub last_mode: GameMode,
/// The release version whose "What's new" card the player has already
/// seen (e.g. `"0.46.0"`). Empty on installs that predate the card,
/// which correctly reads as "there is news to show" after an upgrade;
/// fresh installs stamp it silently when onboarding completes. Older
/// `settings.json` files deserialize cleanly to `""` via
/// `#[serde(default)]`.
#[serde(default)]
pub last_seen_whats_new: String,
/// `true` once the one-shot "Stuck? Try a hint" contextual tip has
/// fired (or been suppressed as unnecessary). Phase I teach: fired
/// by the situation, shown once, like `shown_achievement_onboarding`.
#[serde(default)]
pub shown_stall_hint_tip: bool,
/// `true` once the one-shot radial-menu contextual tip has fired —
/// or the player has already opened the radial menu on their own,
/// which marks the tip as unnecessary without showing it.
#[serde(default)]
pub shown_radial_menu_tip: bool,
/// Global UI scale multiplier applied to all UI chrome (HUD, modals,
/// action bar) — the table itself stays window-fit via
/// `compute_layout`. Cycles through 0.9 / 1.0 / 1.15 / 1.3 in
/// Settings → Accessibility; clamped to `[UI_SCALE_MIN,
/// UI_SCALE_MAX]` by [`Settings::sanitized`]. Older `settings.json`
/// files deserialize cleanly to `1.0` via the serde default.
#[serde(default = "default_ui_scale")]
pub ui_scale: f32,
/// Custom public name displayed on the leaderboard. When `None`, the /// Custom public name displayed on the leaderboard. When `None`, the
/// player's server `username` is used instead. Trimmed to 32 characters /// player's server `username` is used instead. Trimmed to 32 characters
/// before submission. Older `settings.json` files written before this /// before submission. Older `settings.json` files written before this
@@ -399,16 +354,6 @@ fn default_replay_move_interval_secs() -> f32 {
0.45 0.45
} }
/// Lowest / highest UI scale the settings row offers; anything outside
/// (hand-edited settings.json) clamps here on load.
pub const UI_SCALE_MIN: f32 = 0.9;
/// See [`UI_SCALE_MIN`].
pub const UI_SCALE_MAX: f32 = 1.3;
fn default_ui_scale() -> f32 {
1.0
}
fn default_matomo_site_id() -> u32 { fn default_matomo_site_id() -> u32 {
1 1
} }
@@ -459,7 +404,6 @@ impl Default for Settings {
color_blind_mode: false, color_blind_mode: false,
high_contrast_mode: false, high_contrast_mode: false,
reduce_motion_mode: false, reduce_motion_mode: false,
reduce_transparency_mode: false,
window_geometry: None, window_geometry: None,
selected_theme_id: default_theme_id(), selected_theme_id: default_theme_id(),
shown_achievement_onboarding: false, shown_achievement_onboarding: false,
@@ -469,11 +413,6 @@ impl Default for Settings {
disable_smart_default_size: false, disable_smart_default_size: false,
replay_move_interval_secs: default_replay_move_interval_secs(), replay_move_interval_secs: default_replay_move_interval_secs(),
last_difficulty: None, last_difficulty: None,
last_mode: GameMode::Classic,
last_seen_whats_new: String::new(),
shown_stall_hint_tip: false,
shown_radial_menu_tip: false,
ui_scale: default_ui_scale(),
leaderboard_display_name: None, leaderboard_display_name: None,
leaderboard_opted_in: false, leaderboard_opted_in: false,
take_from_foundation: true, take_from_foundation: true,
@@ -509,7 +448,6 @@ impl Settings {
replay_move_interval_secs: self replay_move_interval_secs: self
.replay_move_interval_secs .replay_move_interval_secs
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS), .clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS),
ui_scale: self.ui_scale.clamp(UI_SCALE_MIN, UI_SCALE_MAX),
selected_theme_id, selected_theme_id,
..self ..self
} }
+36 -17
View File
@@ -46,6 +46,22 @@ pub fn save_stats_to(path: &Path, stats: &StatsSnapshot) -> io::Result<()> {
Ok(()) Ok(())
} }
/// Load stats from the platform default path. Returns default if the path
/// is unavailable or the file is missing/corrupt.
pub fn load_stats() -> StatsSnapshot {
stats_file_path()
.map(|p| load_stats_from(&p))
.unwrap_or_default()
}
/// Save stats to the platform default path. Returns an error if the platform
/// data dir is unavailable or the write fails.
pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> {
let path = stats_file_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "platform data dir unavailable"))?;
save_stats_to(&path, stats)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// In-progress game state // In-progress game state
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -229,6 +245,18 @@ pub fn delete_time_attack_session_at(path: &Path) -> io::Result<()> {
} }
} }
/// Convenience helper for callers that want to stamp a session with the
/// current wall-clock time. Equivalent to constructing the struct
/// manually and setting `saved_at_unix_secs` to `SystemTime::now()`.
pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttackSession {
let now = Utc::now().timestamp().max(0) as u64;
TimeAttackSession {
remaining_secs,
wins,
saved_at_unix_secs: now,
}
}
/// Inner helper: delete `*.tmp` entries inside `dir`. /// Inner helper: delete `*.tmp` entries inside `dir`.
/// ///
/// Per-file errors (already deleted, permission denied) are silently ignored. /// Per-file errors (already deleted, permission denied) are silently ignored.
@@ -477,7 +505,7 @@ mod tests {
/// again must reproduce byte-identical JSON. `undo_count` deliberately resets /// again must reproduce byte-identical JSON. `undo_count` deliberately resets
/// to 0 on load because only the forward instruction history is persisted. /// to 0 on load because only the forward instruction history is persisted.
#[test] #[test]
fn game_state_v6_mid_game_round_trip() { fn game_state_v5_mid_game_round_trip() {
use solitaire_core::KlondikeInstruction; use solitaire_core::KlondikeInstruction;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
@@ -518,24 +546,19 @@ mod tests {
save_game_state_to(&path, &gs).expect("save"); save_game_state_to(&path, &gs).expect("save");
// Verify the file carries the v6 schema marker and the recording. // Verify the file carries the v5 schema marker.
let json = fs::read_to_string(&path).expect("read json"); let json = fs::read_to_string(&path).expect("read json");
let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse saved json");
assert_eq!(
parsed["schema_version"], 6,
"saved file must use schema version 6",
);
assert!( assert!(
parsed["recording"].is_object(), json.contains("\"schema_version\"") && json.contains('5'),
"saved file must embed the session recording", "saved file must use schema version 5",
); );
let loaded = let loaded = load_game_state_from(&path)
load_game_state_from(&path).expect("a valid in-progress game must load without error"); .expect("a valid in-progress game must load without error");
// The forward instruction history round-trips, so the reconstructed board // The forward instruction history round-trips, so the reconstructed board
// re-serialises to byte-identical JSON. // re-serialises to byte-identical JSON.
let path_reload = gs_path("v6_mid_game_reload"); let path_reload = gs_path("v5_mid_game_reload");
let _ = fs::remove_file(&path_reload); let _ = fs::remove_file(&path_reload);
save_game_state_to(&path_reload, &loaded).expect("re-save loaded"); save_game_state_to(&path_reload, &loaded).expect("re-save loaded");
assert_eq!( assert_eq!(
@@ -546,11 +569,7 @@ mod tests {
// Derived board reads match the live game (move count + recycle count are // Derived board reads match the live game (move count + recycle count are
// both rebuilt from the replayed forward history). // both rebuilt from the replayed forward history).
assert_eq!( assert_eq!(loaded.move_count(), gs.move_count(), "move_count round-trips");
loaded.move_count(),
gs.move_count(),
"move_count round-trips"
);
assert_eq!( assert_eq!(
loaded.recycle_count(), loaded.recycle_count(),
gs.recycle_count(), gs.recycle_count(),
+9 -35
View File
@@ -77,11 +77,6 @@ pub struct SolitaireServerClient {
username: String, username: String,
/// Shared `reqwest` client (keeps connection pools alive across calls). /// Shared `reqwest` client (keeps connection pools alive across calls).
client: reqwest::Client, client: reqwest::Client,
/// Serialises token refreshes. The server rotates refresh tokens and each
/// is single-use, so two overlapping 401-retries must not both spend one:
/// the loser's (already-consumed) token would be rejected and surface as
/// a spurious "session expired" to the player. See [`Self::refresh_token`].
refresh_lock: tokio::sync::Mutex<()>,
} }
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
@@ -95,7 +90,6 @@ impl SolitaireServerClient {
base_url: base_url.into().trim_end_matches('/').to_owned(), base_url: base_url.into().trim_end_matches('/').to_owned(),
username: username.into(), username: username.into(),
client: reqwest::Client::new(), client: reqwest::Client::new(),
refresh_lock: tokio::sync::Mutex::new(()),
} }
} }
@@ -178,27 +172,7 @@ impl SolitaireServerClient {
/// The server rotates refresh tokens on each call: the response includes a /// The server rotates refresh tokens on each call: the response includes a
/// new refresh token that replaces the old one. Both tokens are persisted /// new refresh token that replaces the old one. Both tokens are persisted
/// to the OS keychain on success. /// to the OS keychain on success.
/// async fn refresh_token(&self) -> Result<(), SyncError> {
/// `stale_access` is the access token that just earned the caller a 401.
/// Refreshes are serialised behind [`Self::refresh_lock`]: with single-use
/// rotated refresh tokens, two overlapping 401-retries (e.g. a replay
/// upload racing a manual sync) must not both call `/api/auth/refresh` —
/// the second call would present an already-consumed token, get rejected,
/// and force a re-login for no user-visible reason. Whoever loses the lock
/// race checks whether the stored access token has already moved past
/// `stale_access`; if so the refresh already happened and there is nothing
/// left to do.
async fn refresh_token(&self, stale_access: &str) -> Result<(), SyncError> {
let _guard = self.refresh_lock.lock().await;
if let Ok(current) = load_access_token(&self.username)
&& current != stale_access
{
// Another task refreshed while we waited on the lock; retry with
// the token it stored rather than spending the new refresh token.
return Ok(());
}
let old_refresh = let old_refresh =
load_refresh_token(&self.username).map_err(|e| SyncError::Auth(e.to_string()))?; load_refresh_token(&self.username).map_err(|e| SyncError::Auth(e.to_string()))?;
@@ -257,7 +231,7 @@ impl SyncProvider for SolitaireServerClient {
if resp.status() == reqwest::StatusCode::UNAUTHORIZED { if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
// Token expired — refresh and retry once. // Token expired — refresh and retry once.
self.refresh_token(&token).await?; self.refresh_token().await?;
let new_token = self.access_token()?; let new_token = self.access_token()?;
let resp = self let resp = self
.client .client
@@ -290,7 +264,7 @@ impl SyncProvider for SolitaireServerClient {
if resp.status() == reqwest::StatusCode::UNAUTHORIZED { if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
// Token expired — refresh and retry once. // Token expired — refresh and retry once.
self.refresh_token(&token).await?; self.refresh_token().await?;
let new_token = self.access_token()?; let new_token = self.access_token()?;
let resp = self let resp = self
.client .client
@@ -357,7 +331,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?; .map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED { if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token(&token).await?; self.refresh_token().await?;
let new_token = self.access_token()?; let new_token = self.access_token()?;
let resp = self let resp = self
.client .client
@@ -392,7 +366,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?; .map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED { if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token(&token).await?; self.refresh_token().await?;
let new_token = self.access_token()?; let new_token = self.access_token()?;
let resp = self let resp = self
.client .client
@@ -432,7 +406,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?; .map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED { if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token(&token).await?; self.refresh_token().await?;
let new_token = self.access_token()?; let new_token = self.access_token()?;
let resp = self let resp = self
.client .client
@@ -472,7 +446,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?; .map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED { if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token(&token).await?; self.refresh_token().await?;
let new_token = self.access_token()?; let new_token = self.access_token()?;
let resp = self let resp = self
.client .client
@@ -506,7 +480,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?; .map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED { if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token(&token).await?; self.refresh_token().await?;
let new_token = self.access_token()?; let new_token = self.access_token()?;
let resp = self let resp = self
.client .client
@@ -568,7 +542,7 @@ impl SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?; .map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED { if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token(&token).await?; self.refresh_token().await?;
let new_token = self.access_token()?; let new_token = self.access_token()?;
let resp = self let resp = self
.client .client
-240
View File
@@ -1,240 +0,0 @@
//! HTTP client for the server's theme-store endpoints.
//!
//! Fetches the catalog (`GET /api/themes`) and downloads theme
//! archives, verifying each download's size and SHA-256 against the
//! catalog entry before returning the bytes. The caller (the engine's
//! theme-store UI) hands verified bytes to the theme importer, which
//! independently re-validates the archive's structure — the store
//! pipeline never trusts a byte the importer hasn't checked.
//!
//! Native-only: gated out on wasm32 alongside the other `reqwest`
//! consumers in this crate.
use sha2::{Digest, Sha256};
use solitaire_sync::{ThemeCatalogEntry, ThemeCatalogResponse};
use thiserror::Error;
/// Hard cap on a theme archive download, matching the engine
/// importer's `MAX_ARCHIVE_BYTES` — anything larger can never import,
/// so downloading it is pure waste.
pub const MAX_THEME_DOWNLOAD_BYTES: u64 = 20 * 1024 * 1024;
/// Hard cap on a preview PNG download — previews are small decorative
/// thumbnails; anything past this is a misconfigured server.
pub const MAX_PREVIEW_BYTES: u64 = 512 * 1024;
/// Errors surfaced by [`ThemeStoreClient`].
#[derive(Debug, Error)]
pub enum ThemeStoreError {
/// The request could not be sent or the response body not read.
#[error("network error: {0}")]
Network(String),
/// The server answered with a non-success status code.
#[error("server returned HTTP {0}")]
Http(u16),
/// The catalog JSON did not parse.
#[error("malformed catalog: {0}")]
MalformedCatalog(String),
/// The archive is larger than [`MAX_THEME_DOWNLOAD_BYTES`] or its
/// catalog-declared size.
#[error("archive size {got} exceeds the expected {expected} bytes")]
Oversized { expected: u64, got: u64 },
/// The downloaded bytes do not hash to the catalog's SHA-256 —
/// the file changed on the server or was corrupted in transit.
#[error("archive checksum mismatch (expected {expected}, got {got})")]
ChecksumMismatch { expected: String, got: String },
}
/// Client for one server's theme store.
///
/// Unauthenticated: the catalog and downloads are public endpoints,
/// so unlike `SolitaireServerClient` there is no token handling.
pub struct ThemeStoreClient {
/// Base URL of the server, trailing slash stripped.
base_url: String,
client: reqwest::Client,
}
impl ThemeStoreClient {
/// Construct a client for the server at `base_url`
/// (e.g. `"https://solitaire.example.com"`).
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into().trim_end_matches('/').to_owned(),
client: reqwest::Client::new(),
}
}
/// Fetch the theme catalog, sorted by display name (server-side).
pub async fn fetch_catalog(&self) -> Result<Vec<ThemeCatalogEntry>, ThemeStoreError> {
let resp = self
.client
.get(format!("{}/api/themes", self.base_url))
.send()
.await
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
if !resp.status().is_success() {
return Err(ThemeStoreError::Http(resp.status().as_u16()));
}
let catalog: ThemeCatalogResponse = resp
.json()
.await
.map_err(|e| ThemeStoreError::MalformedCatalog(e.to_string()))?;
Ok(catalog.themes)
}
/// Download `entry`'s archive and verify it against the catalog's
/// size and SHA-256. Returns the verified `.zip` bytes, ready for
/// the engine's theme importer.
pub async fn download_theme(
&self,
entry: &ThemeCatalogEntry,
) -> Result<Vec<u8>, ThemeStoreError> {
// The catalog itself could name an absurd size; refuse before
// buffering anything.
if entry.size_bytes > MAX_THEME_DOWNLOAD_BYTES {
return Err(ThemeStoreError::Oversized {
expected: MAX_THEME_DOWNLOAD_BYTES,
got: entry.size_bytes,
});
}
let resp = self
.client
.get(format!("{}{}", self.base_url, entry.download_url))
.send()
.await
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
if !resp.status().is_success() {
return Err(ThemeStoreError::Http(resp.status().as_u16()));
}
let bytes = resp
.bytes()
.await
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
verify_archive(&bytes, entry)?;
Ok(bytes.to_vec())
}
/// Fetch the preview PNG the catalog advertises for `entry`.
///
/// Returns `Http(404)` when the entry carries no `preview_url` —
/// the same shape the server answers with when the file is absent,
/// so callers only handle one "no preview" case. Previews are
/// decorative; unlike archives they carry no checksum, only the
/// [`MAX_PREVIEW_BYTES`] size cap.
pub async fn fetch_preview(
&self,
entry: &ThemeCatalogEntry,
) -> Result<Vec<u8>, ThemeStoreError> {
let Some(path) = entry.preview_url.as_deref() else {
return Err(ThemeStoreError::Http(404));
};
let resp = self
.client
.get(format!("{}{}", self.base_url, path))
.send()
.await
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
if !resp.status().is_success() {
return Err(ThemeStoreError::Http(resp.status().as_u16()));
}
let bytes = resp
.bytes()
.await
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
if bytes.len() as u64 > MAX_PREVIEW_BYTES {
return Err(ThemeStoreError::Oversized {
expected: MAX_PREVIEW_BYTES,
got: bytes.len() as u64,
});
}
Ok(bytes.to_vec())
}
}
/// Checks downloaded `bytes` against the catalog `entry`'s declared
/// size and SHA-256. Pure so it can be unit-tested without a server.
fn verify_archive(bytes: &[u8], entry: &ThemeCatalogEntry) -> Result<(), ThemeStoreError> {
if bytes.len() as u64 != entry.size_bytes {
return Err(ThemeStoreError::Oversized {
expected: entry.size_bytes,
got: bytes.len() as u64,
});
}
let got = hex_digest(bytes);
// Case-insensitive: the server emits lowercase hex, but a
// hand-authored catalog mirror shouldn't fail on case alone.
if !got.eq_ignore_ascii_case(&entry.sha256) {
return Err(ThemeStoreError::ChecksumMismatch {
expected: entry.sha256.clone(),
got,
});
}
Ok(())
}
/// Lowercase-hex SHA-256 of `bytes`. Mirrors the server's digest
/// encoding in `solitaire_server::theme_store`.
fn hex_digest(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut out = String::with_capacity(digest.len() * 2);
for byte in digest {
out.push_str(&format!("{byte:02x}"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn entry_for(bytes: &[u8]) -> ThemeCatalogEntry {
ThemeCatalogEntry {
id: "neon".into(),
name: "Neon".into(),
author: "Test".into(),
version: "1.0.0".into(),
card_aspect: (2, 3),
size_bytes: bytes.len() as u64,
sha256: hex_digest(bytes),
download_url: "/api/themes/neon/download".into(),
preview_url: None,
}
}
#[test]
fn verify_accepts_matching_bytes() {
let bytes = b"theme archive bytes";
assert!(verify_archive(bytes, &entry_for(bytes)).is_ok());
}
#[test]
fn verify_accepts_uppercase_catalog_hash() {
let bytes = b"theme archive bytes";
let mut entry = entry_for(bytes);
entry.sha256 = entry.sha256.to_uppercase();
assert!(verify_archive(bytes, &entry).is_ok());
}
#[test]
fn verify_rejects_size_mismatch() {
let bytes = b"theme archive bytes";
let mut entry = entry_for(bytes);
entry.size_bytes += 1;
assert!(matches!(
verify_archive(bytes, &entry),
Err(ThemeStoreError::Oversized { .. })
));
}
#[test]
fn verify_rejects_checksum_mismatch() {
let bytes = b"theme archive bytes";
let mut entry = entry_for(bytes);
entry.sha256 = "0".repeat(64);
assert!(matches!(
verify_archive(bytes, &entry),
Err(ThemeStoreError::ChecksumMismatch { .. })
));
}
}
-137
View File
@@ -1,137 +0,0 @@
//! Local data export/import (Phase M — sync transparency).
//!
//! Bundles every player-owned JSON save (settings, stats, achievements,
//! progress) into one versioned backup file so a player can move their
//! data between devices without an account, or keep a manual backup.
//!
//! The bundle deliberately excludes the in-progress game and the replay
//! history: the former is transient, the latter can be large and is
//! shareable through the replay upload path instead.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::settings::Settings;
use solitaire_sync::{AchievementRecord, PlayerProgress, StatsSnapshot};
/// Bundle format version. Bump on any breaking shape change; the loader
/// rejects newer versions rather than misreading them.
pub const DATA_BUNDLE_SCHEMA_VERSION: u32 = 1;
/// File name of the default backup location (next to the other saves).
pub const DATA_BUNDLE_FILE_NAME: &str = "ferrous-solitaire-backup.json";
/// One-file backup of every player-owned JSON save.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DataBundle {
/// Format version — see [`DATA_BUNDLE_SCHEMA_VERSION`].
pub schema_version: u32,
/// When the bundle was written.
pub exported_at: DateTime<Utc>,
pub settings: Settings,
pub stats: StatsSnapshot,
pub achievements: Vec<AchievementRecord>,
pub progress: PlayerProgress,
}
/// Why a bundle could not be loaded.
#[derive(Debug, thiserror::Error)]
pub enum BundleError {
#[error("could not read backup file: {0}")]
Io(#[from] io::Error),
#[error("backup file is not valid JSON: {0}")]
Parse(#[from] serde_json::Error),
#[error("backup was written by a newer app version (schema {found} > {supported})")]
UnsupportedVersion { found: u32, supported: u32 },
}
/// Default backup path: `<data dir>/<app dir>/ferrous-solitaire-backup.json`,
/// or `None` when no data directory is available on this platform.
pub fn data_bundle_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(DATA_BUNDLE_FILE_NAME))
}
/// Write `bundle` to `path` atomically (`.tmp` → rename), creating parent
/// directories as needed. Mirrors the other save-file writers.
pub fn save_bundle_to(path: &Path, bundle: &DataBundle) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(bundle).map_err(io::Error::other)?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, json.as_bytes())?;
fs::rename(&tmp, path)?;
Ok(())
}
/// Read and validate a bundle from `path`. Unlike the per-file loaders this
/// does NOT default-on-error — an import silently replacing player data
/// with defaults would be destructive, so every failure is surfaced.
pub fn load_bundle_from(path: &Path) -> Result<DataBundle, BundleError> {
let data = fs::read(path)?;
let bundle: DataBundle = serde_json::from_slice(&data)?;
if bundle.schema_version > DATA_BUNDLE_SCHEMA_VERSION {
return Err(BundleError::UnsupportedVersion {
found: bundle.schema_version,
supported: DATA_BUNDLE_SCHEMA_VERSION,
});
}
Ok(bundle)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_bundle() -> DataBundle {
DataBundle {
schema_version: DATA_BUNDLE_SCHEMA_VERSION,
exported_at: Utc::now(),
settings: Settings::default(),
stats: StatsSnapshot {
games_played: 7,
..Default::default()
},
achievements: vec![],
progress: PlayerProgress::default(),
}
}
#[test]
fn round_trips_through_disk() {
let dir = std::env::temp_dir().join(format!("bundle_test_{}", std::process::id()));
let path = dir.join(DATA_BUNDLE_FILE_NAME);
let bundle = sample_bundle();
save_bundle_to(&path, &bundle).expect("save should succeed");
let loaded = load_bundle_from(&path).expect("load should succeed");
assert_eq!(loaded.schema_version, DATA_BUNDLE_SCHEMA_VERSION);
assert_eq!(loaded.stats.games_played, 7);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn rejects_newer_schema() {
let dir = std::env::temp_dir().join(format!("bundle_test_v_{}", std::process::id()));
let path = dir.join(DATA_BUNDLE_FILE_NAME);
let mut bundle = sample_bundle();
bundle.schema_version = DATA_BUNDLE_SCHEMA_VERSION + 1;
save_bundle_to(&path, &bundle).expect("save should succeed");
assert!(matches!(
load_bundle_from(&path),
Err(BundleError::UnsupportedVersion { .. })
));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn missing_file_is_an_io_error() {
assert!(matches!(
load_bundle_from(Path::new("/nonexistent/backup.json")),
Err(BundleError::Io(_))
));
}
}
@@ -1,118 +0,0 @@
//! End-to-end theme-store test: a real `solitaire_server` router
//! serving a scanned catalog over a localhost TCP socket, consumed by
//! [`solitaire_data::ThemeStoreClient`].
//!
//! Mirrors the `sync_round_trip` harness: `TcpListener` on port 0,
//! router in a background `tokio::spawn`, no explicit shutdown.
#![cfg(not(target_arch = "wasm32"))]
use std::io::Write as _;
use std::path::{Path, PathBuf};
use solitaire_data::ThemeStoreClient;
use solitaire_server::theme_store::ThemeStore;
/// Minimal theme archive: the catalog scan only reads the `meta`
/// block, so no face SVGs are needed.
fn write_store_zip(dir: &Path, file_name: &str, id: &str, name: &str) -> PathBuf {
let manifest = format!(
r#"(
meta: (
id: "{id}",
name: "{name}",
author: "Round Trip",
version: "1.0.0",
card_aspect: (2, 3),
),
faces: {{}},
back: "back.svg",
)"#
);
let path = dir.join(file_name);
let file = std::fs::File::create(&path).expect("create zip");
let mut writer = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default();
writer.start_file("theme.ron", options).expect("start_file");
writer
.write_all(manifest.as_bytes())
.expect("write manifest");
writer.finish().expect("finish zip");
path
}
/// Spawn the test server with a theme store scanned from `store_dir`
/// and return its base URL.
async fn spawn_store_server(store_dir: &Path) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("failed to bind test listener");
let addr = listener.local_addr().expect("listener has no local addr");
let pool = solitaire_server::build_test_pool().await;
let app =
solitaire_server::build_test_router_with_theme_store(pool, ThemeStore::scan(store_dir));
tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app).await {
eprintln!("test server crashed: {e}");
}
});
format!("http://{addr}")
}
/// Catalog fetch → download → verified bytes match the file the
/// server scanned. This is the exact path the engine's store UI runs.
#[tokio::test]
async fn catalog_fetch_and_verified_download_round_trip() {
let dir = tempfile::tempdir().expect("tempdir");
let zip_path = write_store_zip(dir.path(), "neon.zip", "neon", "Neon");
let base_url = spawn_store_server(dir.path()).await;
let client = ThemeStoreClient::new(&base_url);
let catalog = client.fetch_catalog().await.expect("fetch catalog");
assert_eq!(catalog.len(), 1);
let entry = &catalog[0];
assert_eq!(entry.id, "neon");
let bytes = client.download_theme(entry).await.expect("download");
assert_eq!(bytes, std::fs::read(&zip_path).expect("read zip"));
}
/// A catalog entry whose hash no longer matches the served file must
/// be rejected client-side — the importer never sees unverified bytes.
#[tokio::test]
async fn download_with_stale_catalog_hash_is_rejected() {
let dir = tempfile::tempdir().expect("tempdir");
write_store_zip(dir.path(), "neon.zip", "neon", "Neon");
let base_url = spawn_store_server(dir.path()).await;
let client = ThemeStoreClient::new(&base_url);
let mut entry = client.fetch_catalog().await.expect("fetch catalog")[0].clone();
entry.sha256 = "0".repeat(64);
let err = client
.download_theme(&entry)
.await
.expect_err("mismatched hash must fail");
assert!(
matches!(
err,
solitaire_data::ThemeStoreError::ChecksumMismatch { .. }
),
"expected ChecksumMismatch, got: {err:?}"
);
}
/// An empty (or absent) store directory serves an empty catalog — the
/// client sees a store with nothing in it, not an error.
#[tokio::test]
async fn empty_store_yields_empty_catalog() {
let dir = tempfile::tempdir().expect("tempdir");
let base_url = spawn_store_server(dir.path()).await;
let client = ThemeStoreClient::new(&base_url);
let catalog = client.fetch_catalog().await.expect("fetch catalog");
assert!(catalog.is_empty());
}
+108 -88
View File
@@ -30,9 +30,13 @@ use crate::replay_playback::ReplayPlaybackState;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath}; use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::stats_plugin::{StatsResource, StatsUpdate}; use crate::stats_plugin::{StatsResource, StatsUpdate};
use crate::ui_modal::{
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, Z_MODAL_PANEL,
}; };
use crate::ui_tooltip::Tooltip; use crate::ui_tooltip::Tooltip;
@@ -67,16 +71,6 @@ pub struct AchievementsResource(pub Vec<AchievementRecord>);
#[derive(Resource, Debug, Clone)] #[derive(Resource, Debug, Clone)]
pub struct AchievementsStoragePath(pub Option<PathBuf>); pub struct AchievementsStoragePath(pub Option<PathBuf>);
/// Set wrapping the win-path achievement evaluator. It writes
/// `AchievementsResource`/`ProgressResource` and emits unlock events; UI
/// readers of those (HUD popovers, the stats/achievements panels) order
/// themselves `.after(AchievementEval)`. The replay-completion evaluator is
/// deliberately NOT a member: it runs after `AutoComplete`, and panel chains
/// that need `.after(AchievementEval)` run before `AutoComplete` — putting
/// both in one set would close an ordering cycle (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct AchievementEval;
pub struct AchievementPlugin { pub struct AchievementPlugin {
pub storage_path: Option<PathBuf>, pub storage_path: Option<PathBuf>,
} }
@@ -129,13 +123,9 @@ impl Plugin for AchievementPlugin {
.add_systems( .add_systems(
Update, Update,
evaluate_on_win evaluate_on_win
.in_set(AchievementEval)
.after(GameMutation) .after(GameMutation)
.after(StatsUpdate) .after(StatsUpdate)
.after(ProgressUpdate) .after(ProgressUpdate),
// The unlock/XP/level-up events it emits are drained by
// the toast pipeline this frame (#143).
.before(crate::animation_plugin::AnimFx),
) )
// Achievement-onboarding cue: fires once after the player's very // Achievement-onboarding cue: fires once after the player's very
// first win to teach the Achievements panel exists. Must run // first win to teach the Achievements panel exists. Must run
@@ -145,50 +135,20 @@ impl Plugin for AchievementPlugin {
Update, Update,
fire_achievement_onboarding_toast fire_achievement_onboarding_toast
.after(GameMutation) .after(GameMutation)
.after(StatsUpdate) .after(StatsUpdate),
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters)
// Writes the one-shot onboarding flag into
// SettingsResource: board painters re-read settings, so
// land the write first; UiTextFx members read disjoint
// settings fields (reduce-motion), so writer/reader
// order there is provably irrelevant (#143).
.before(crate::card_plugin::BoardVisuals)
.ambiguous_with(crate::ui_theme::UiTextFx)
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
) )
// Open/close/tab handling moved to `you_hub_plugin` .add_systems(Update, toggle_achievements_screen)
// (Phase E) — this plugin now owns body content + scroll. .add_systems(Update, handle_achievements_close_button)
.add_systems(Update, scroll_achievements_panel)
.add_systems( .add_systems(
Update, Update,
scroll_achievements_panel crate::ui_modal::touch_scroll_panel::<AchievementsScrollable>,
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
)
.add_systems(
Update,
crate::ui_modal::touch_scroll_panel::<AchievementsScrollable>
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
) )
// Event-driven unlock: observe `ReplayPlaybackState` and unlock // Event-driven unlock: observe `ReplayPlaybackState` and unlock
// `cinephile` the first time playback runs to natural completion. // `cinephile` the first time playback runs to natural completion.
// Reads the resource via `Option<Res<_>>` so headless tests that // Reads the resource via `Option<Res<_>>` so headless tests that
// omit `ReplayPlaybackPlugin` still build. // omit `ReplayPlaybackPlugin` still build.
.add_systems( .add_systems(Update, evaluate_cinephile_on_replay_completion);
Update,
// Shares the unlock stream and AchievementsResource with
// evaluate_on_win; ordered after it and before the toast
// pipeline that drains the unlock events (#143).
evaluate_cinephile_on_replay_completion
.after(evaluate_on_win)
// The stats overlay's watch-replay button (ordered
// before AutoComplete) writes ReplayPlaybackState this
// system reads.
.after(crate::auto_complete_plugin::AutoComplete)
.before(crate::animation_plugin::AnimFx),
);
} }
} }
@@ -425,6 +385,47 @@ pub fn display_name_for(id: &str) -> String {
achievement_by_id(id).map_or_else(|| id.to_string(), |d| d.name.to_string()) achievement_by_id(id).map_or_else(|| id.to_string(), |d| d.name.to_string())
} }
/// Marker on the "Done" button inside the Achievements modal.
#[derive(Component, Debug)]
pub struct AchievementsCloseButton;
/// Toggle the achievements overlay — `A` keyboard accelerator or
/// `ToggleAchievementsRequestEvent` from the HUD Menu popover.
fn toggle_achievements_screen(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleAchievementsRequestEvent>,
achievements: Res<AchievementsResource>,
font_res: Option<Res<FontResource>>,
screens: Query<Entity, With<AchievementsScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<AchievementsScreen>)>,
) {
let button_clicked = requests.read().count() > 0;
if !keys.just_pressed(KeyCode::KeyA) && !button_clicked {
return;
}
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else if other_modal_scrims.is_empty() {
spawn_achievements_screen(&mut commands, &achievements.0, font_res.as_deref());
}
}
/// Click handler for the modal's "Done" button — despawns the overlay
/// the same way the `A` accelerator does.
fn handle_achievements_close_button(
mut commands: Commands,
close_buttons: Query<&Interaction, (With<AchievementsCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<AchievementsScreen>>,
) {
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
/// Routes mouse-wheel events into the Achievements modal's scrollable body /// Routes mouse-wheel events into the Achievements modal's scrollable body
/// while the panel is open. /// while the panel is open.
/// ///
@@ -457,18 +458,14 @@ fn scroll_achievements_panel(
} }
} }
/// Builds the Achievements tab body inside the You hub's card. The fn spawn_achievements_screen(
/// unlock-count line that used to live in the standalone modal's commands: &mut Commands,
/// header renders as the first body line instead (the hub owns the
/// header). All markers (`AchievementRow`, `AchievementsScrollable`)
/// are unchanged.
pub(crate) fn spawn_achievements_body(
card: &mut ChildSpawnerCommands,
records: &[AchievementRecord], records: &[AchievementRecord],
font_res: Option<&FontResource>, font_res: Option<&FontResource>,
) { ) {
let unlocked: Vec<_> = records.iter().filter(|r| r.unlocked).collect(); let unlocked: Vec<_> = records.iter().filter(|r| r.unlocked).collect();
let total = ALL_ACHIEVEMENTS.len(); let total = ALL_ACHIEVEMENTS.len();
let header = format!("Achievements ({}/{})", unlocked.len(), total);
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default(); let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_name = TextFont { let font_name = TextFont {
@@ -489,13 +486,8 @@ pub(crate) fn spawn_achievements_body(
let any_unlocked = records.iter().any(|r| r.unlocked); let any_unlocked = records.iter().any(|r| r.unlocked);
{ let scrim = spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| {
// Unlock progress — formerly the standalone modal's header. spawn_modal_header(card, header, font_res);
card.spawn((
Text::new(format!("Unlocked {} / {}", unlocked.len(), total)),
font_name.clone(),
TextColor(TEXT_SECONDARY),
));
// First-time hint — shown until the player has unlocked anything. // First-time hint — shown until the player has unlocked anything.
// The list itself describes individual rewards, but a top-level // The list itself describes individual rewards, but a top-level
@@ -602,7 +594,21 @@ pub(crate) fn spawn_achievements_body(
)); ));
} }
}); });
}
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
AchievementsCloseButton,
"Done",
Some("A"),
ButtonVariant::Primary,
font_res,
);
});
});
// Achievements is a read-only list — clicking the scrim outside
// the card dismisses alongside the existing A / Done paths.
commands.entity(scrim).insert(ScrimDismissible);
} }
fn format_reward(reward: Reward) -> String { fn format_reward(reward: Reward) -> String {
@@ -662,8 +668,7 @@ mod tests {
.add_plugins(TablePlugin) .add_plugins(TablePlugin)
.add_plugins(StatsPlugin::headless()) .add_plugins(StatsPlugin::headless())
.add_plugins(crate::progress_plugin::ProgressPlugin::headless()) .add_plugins(crate::progress_plugin::ProgressPlugin::headless())
.add_plugins(AchievementPlugin::headless()) .add_plugins(AchievementPlugin::headless());
.add_plugins(crate::you_hub_plugin::YouHubPlugin);
// StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under // StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under
// MinimalPlugins it isn't auto-registered. // MinimalPlugins it isn't auto-registered.
app.init_resource::<ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
@@ -906,7 +911,8 @@ mod tests {
// Put the active game in Zen mode. evaluate_on_win reads // Put the active game in Zen mode. evaluate_on_win reads
// GameStateResource.mode directly to populate last_win_is_zen. // GameStateResource.mode directly to populate last_win_is_zen.
app.world_mut().resource_mut::<GameStateResource>().0.mode = GameMode::Zen; app.world_mut().resource_mut::<GameStateResource>().0.mode =
GameMode::Zen;
app.world_mut().write_message(GameWonEvent { app.world_mut().write_message(GameWonEvent {
score: 0, score: 0,
@@ -1387,9 +1393,7 @@ mod tests {
use crate::replay_playback::ReplayPlaybackState; use crate::replay_playback::ReplayPlaybackState;
use chrono::NaiveDate; use chrono::NaiveDate;
use solitaire_core::{ use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
DrawStockConfig, KlondikeInstruction, SessionRecording, game_state::GameMode,
};
use solitaire_data::Replay; use solitaire_data::Replay;
/// Headless app variant that injects a default `ReplayPlaybackState` /// Headless app variant that injects a default `ReplayPlaybackState`
@@ -1410,11 +1414,7 @@ mod tests {
10, 10,
100, 100,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"), NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
SessionRecording::from_instructions_unchecked( vec![KlondikeInstruction::RotateStock],
1,
DrawStockConfig::DrawOne,
[KlondikeInstruction::RotateStock],
),
) )
} }
@@ -1458,8 +1458,12 @@ mod tests {
// Frame 1: enter Playing. The observer's first sample sees // Frame 1: enter Playing. The observer's first sample sees
// `last_was_playing = false` and `now_playing = true`. // `last_was_playing = false` and `now_playing = true`.
*app.world_mut().resource_mut::<ReplayPlaybackState>() = *app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false); replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
app.update(); app.update();
assert!( assert!(
!cinephile_unlocked(&app), !cinephile_unlocked(&app),
@@ -1488,8 +1492,12 @@ mod tests {
fn cinephile_does_not_unlock_on_stop_button_abort() { fn cinephile_does_not_unlock_on_stop_button_abort() {
let mut app = cinephile_app(); let mut app = cinephile_app();
*app.world_mut().resource_mut::<ReplayPlaybackState>() = *app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false); replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
app.update(); app.update();
// Direct Playing → Inactive — the path the Stop button takes via // Direct Playing → Inactive — the path the Stop button takes via
@@ -1515,8 +1523,12 @@ mod tests {
let mut app = cinephile_app(); let mut app = cinephile_app();
// First completion cycle to unlock. // First completion cycle to unlock.
*app.world_mut().resource_mut::<ReplayPlaybackState>() = *app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false); replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
app.update(); app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed; *app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
app.update(); app.update();
@@ -1534,8 +1546,12 @@ mod tests {
// Second cycle: Inactive → Playing → Completed once more. // Second cycle: Inactive → Playing → Completed once more.
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Inactive; *app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Inactive;
app.update(); app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() = *app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false); replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
app.update(); app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed; *app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
app.update(); app.update();
@@ -1554,8 +1570,12 @@ mod tests {
fn cinephile_fires_once_across_completed_linger() { fn cinephile_fires_once_across_completed_linger() {
let mut app = cinephile_app(); let mut app = cinephile_app();
*app.world_mut().resource_mut::<ReplayPlaybackState>() = *app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false); replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
app.update(); app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed; *app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
app.update(); app.update();
+45 -222
View File
@@ -29,9 +29,7 @@ use crate::events::{
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource; use crate::layout::LayoutResource;
use crate::pause_plugin::PausedResource; use crate::pause_plugin::PausedResource;
use crate::platform::USE_TOUCH_UI_LAYOUT;
use crate::progress_plugin::LevelUpEvent; use crate::progress_plugin::LevelUpEvent;
use crate::safe_area::SafeAreaAnchoredBottom;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
use crate::time_attack_plugin::TimeAttackEndedEvent; use crate::time_attack_plugin::TimeAttackEndedEvent;
use crate::ui_theme::{ use crate::ui_theme::{
@@ -162,82 +160,9 @@ pub struct ActiveToast {
/// Duration of each queued info-toast in seconds. /// Duration of each queued info-toast in seconds.
const QUEUED_TOAST_SECS: f32 = 2.5; const QUEUED_TOAST_SECS: f32 = 2.5;
/// Marker on the persistent bottom-anchored flex column every toast
/// spawns into (Phase H). Stacking through one container gives queued
/// and immediate toasts a single shared anchor — simultaneous toasts
/// stack upward instead of relying on the old staggered-percentage
/// anchors to dodge each other.
#[derive(Component, Debug)]
pub struct ToastStackRoot;
/// Marker on every toast card node (both paths). Freshly spawned toasts
/// start `Visibility::Hidden` and unparented; [`adopt_toasts_into_stack`]
/// re-parents them under [`ToastStackRoot`] and reveals them — one frame
/// of latency, imperceptible at toast timescales, in exchange for the 14
/// toast handlers keeping their `Commands`-only signatures.
#[derive(Component, Debug)]
pub struct ToastNode;
/// Logical-pixel gap between the screen bottom and the toast stack,
/// before safe-area insets. Clears the floating glass tab bar on touch
/// (its margin + height + breathing room); desktop's docked bar needs less.
const TOAST_STACK_BASE_BOTTOM_PX: f32 = if USE_TOUCH_UI_LAYOUT {
crate::hud_plugin::tab_bar::TAB_BAR_CLEARANCE_PX
} else {
72.0
};
/// Spawns the persistent [`ToastStackRoot`] container at startup.
fn spawn_toast_stack_root(mut commands: Commands) {
commands.spawn((
ToastStackRoot,
Node {
position_type: PositionType::Absolute,
bottom: Val::Px(TOAST_STACK_BASE_BOTTOM_PX),
left: Val::Px(0.0),
width: Val::Percent(100.0),
// Newest toast sits nearest the bottom edge; older ones
// push upward.
flex_direction: FlexDirection::ColumnReverse,
align_items: AlignItems::Center,
row_gap: VAL_SPACE_2,
..default()
},
SafeAreaAnchoredBottom {
base_bottom: TOAST_STACK_BASE_BOTTOM_PX,
},
ZIndex(Z_TOAST),
));
}
/// Re-parents freshly spawned [`ToastNode`]s under the stack root and
/// reveals them. No-op when every toast is already adopted.
fn adopt_toasts_into_stack(
mut commands: Commands,
orphans: Query<Entity, (With<ToastNode>, Without<ChildOf>)>,
root: Query<Entity, With<ToastStackRoot>>,
) {
let Ok(root) = root.single() else {
return;
};
for toast in &orphans {
commands
.entity(toast)
.insert((ChildOf(root), Visibility::Inherited));
}
}
/// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence. /// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence.
pub struct AnimationPlugin; pub struct AnimationPlugin;
/// Set wrapping this plugin's Update chain (card slide/cascade animation and
/// the toast pipeline). Ordered after [`crate::card_animation::CardTweenFx`]
/// on the shared visual spine — members write card `Transform`s, which every
/// other painter set also touches — and after the toast/stat/progress writer
/// sets so the toast handlers drain this frame's events (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct AnimFx;
impl Plugin for AnimationPlugin { impl Plugin for AnimationPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
// Register the events this plugin consumes so tests that don't include // Register the events this plugin consumes so tests that don't include
@@ -260,37 +185,13 @@ impl Plugin for AnimationPlugin {
.init_resource::<EffectiveSlideDuration>() .init_resource::<EffectiveSlideDuration>()
.init_resource::<ToastQueue>() .init_resource::<ToastQueue>()
.init_resource::<ActiveToast>() .init_resource::<ActiveToast>()
.add_systems(Startup, (init_slide_duration, spawn_toast_stack_root)) .add_systems(Startup, init_slide_duration)
.configure_sets(
Update,
AnimFx
// Visual spine: …BoardVisuals < MarkerVisuals <
// CardTweenFx < AnimFx < FeedbackFx (#143). GameMutation
// ordering is inherited transitively.
.after(crate::card_animation::CardTweenFx)
.after(crate::table_plugin::MarkerVisuals)
// Stats/progress must settle before the win/level-up
// toast handlers read their events. (No edge against
// InfoToastWriters: toast writers exist on both sides
// of this set — enqueue_toasts drains via cursor and
// joins the club instead; a toast surfacing one frame
// later is invisible.)
.after(crate::stats_plugin::StatsUpdate)
.after(crate::progress_plugin::ProgressUpdate)
.after(GameMutation)
// HUD text pulses are disjoint from the card entities
// this set animates (see UiTextFx × BoardVisuals, #143).
.ambiguous_with(crate::ui_theme::UiTextFx),
)
.add_systems( .add_systems(
Update, Update,
( (
advance_card_anims, advance_card_anims,
// Both drain SettingsChangedEvent / read anim-speed sync_slide_duration,
// fields via cursor; a home deal-option edit surfacing handle_win_cascade,
// one frame later is invisible (#143).
sync_slide_duration.ambiguous_with(crate::settings_plugin::SettingsAccess),
handle_win_cascade.ambiguous_with(crate::settings_plugin::SettingsAccess),
handle_achievement_toast, handle_achievement_toast,
handle_levelup_toast, handle_levelup_toast,
handle_daily_goal_announcement_toast, handle_daily_goal_announcement_toast,
@@ -298,23 +199,15 @@ impl Plugin for AnimationPlugin {
handle_weekly_toast, handle_weekly_toast,
handle_time_attack_toast, handle_time_attack_toast,
handle_challenge_toast, handle_challenge_toast,
handle_settings_toast.ambiguous_with(crate::settings_plugin::SettingsAccess), handle_settings_toast,
handle_auto_complete_toast, handle_auto_complete_toast,
handle_xp_awarded_toast, handle_xp_awarded_toast,
handle_move_rejected_toast, handle_move_rejected_toast,
handle_warning_toast, handle_warning_toast,
tick_toasts, tick_toasts,
( (enqueue_toasts, drive_toast_display).chain(),
enqueue_toasts
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
drive_toast_display,
)
.chain(),
adopt_toasts_into_stack,
) )
.chain() .after(GameMutation),
.in_set(AnimFx),
); );
} }
} }
@@ -359,17 +252,10 @@ fn advance_card_anims(
time: Res<Time>, time: Res<Time>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
mut anims: Query<(Entity, &mut Transform, &mut CardAnim)>, mut anims: Query<(Entity, &mut Transform, &mut CardAnim)>,
mut redraw: MessageWriter<RequestRedraw>,
) { ) {
if paused.is_some_and(|p| p.0) { if paused.is_some_and(|p| p.0) {
return; return;
} }
// Keep the winit loop awake at full frame rate while slides (including
// staggered deals still in their delay phase) are in flight — required
// for Android's reactive_low_power focused_mode.
if !anims.is_empty() {
redraw.write(RequestRedraw);
}
let dt = time.delta_secs(); let dt = time.delta_secs();
for (entity, mut transform, mut anim) in &mut anims { for (entity, mut transform, mut anim) in &mut anims {
if anim.delay > 0.0 { if anim.delay > 0.0 {
@@ -672,17 +558,10 @@ fn drive_toast_display(
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
mut queue: ResMut<ToastQueue>, mut queue: ResMut<ToastQueue>,
mut active: ResMut<ActiveToast>, mut active: ResMut<ActiveToast>,
mut redraw: MessageWriter<RequestRedraw>,
) { ) {
if paused.is_some_and(|p| p.0) { if paused.is_some_and(|p| p.0) {
return; return;
} }
// Keep the loop ticking while a toast is displayed or queued so the
// countdown advances and the despawn frame isn't held hostage by
// Android's reactive_low_power wake ceiling.
if active.entity.is_some() || !queue.0.is_empty() {
redraw.write(RequestRedraw);
}
let dt = time.delta_secs(); let dt = time.delta_secs();
// Tick down the active toast timer. // Tick down the active toast timer.
@@ -714,9 +593,9 @@ pub enum ToastVariant {
/// Neutral system message — teal border. Default for `InfoToastEvent`, /// Neutral system message — teal border. Default for `InfoToastEvent`,
/// settings volume notifications, and the auto-complete announcement. /// settings volume notifications, and the auto-complete announcement.
Info, Info,
/// Caution / penalty — gold border. Used by [`handle_warning_toast`] /// Caution / penalty — gold border. Currently unused by an in-engine
/// for `WarningToastEvent` messages (daily-challenge expiry, sync, /// event; kept so future warning-flavoured toasts have a slot.
/// theme-store, and leaderboard warnings). #[allow(dead_code)]
Warning, Warning,
/// Failure / rejected action — pink border. Used by /// Failure / rejected action — pink border. Used by
/// [`handle_move_rejected_toast`] for illegal-placement /// [`handle_move_rejected_toast`] for illegal-placement
@@ -744,14 +623,26 @@ impl ToastVariant {
} }
} }
/// Spawns a `ToastEntity` for the queued toast system. /// Spawns a bottom-anchored `ToastEntity` for the queued toast system.
/// ///
/// Queued toasts always carry [`ToastVariant::Info`] — the queue is fed /// Queued toasts always carry [`ToastVariant::Info`] — the queue is fed
/// by [`InfoToastEvent`] which is by definition neutral system info. /// by [`InfoToastEvent`] which is by definition neutral system info.
/// Variants other than `Info` belong on the immediate-fire path /// Variants other than `Info` belong on the immediate-fire path
/// ([`spawn_toast`]) where the call site knows the semantic intent. /// ([`spawn_toast`]) where the call site knows the semantic intent.
fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity { fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity {
spawn_toast_node(commands, ToastEntity, message, ToastVariant::Info) spawn_toast_node(
commands,
ToastEntity,
message,
ToastVariant::Info,
// Slightly taller anchor than the immediate-fire path so a
// queued info banner doesn't collide with a celebration toast
// fired in the same frame.
Val::Percent(6.0),
Val::Percent(15.0),
Val::Percent(70.0),
UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
)
} }
fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpAwardedEvent>) { fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpAwardedEvent>) {
@@ -839,6 +730,12 @@ fn spawn_toast(
(ToastOverlay, ToastTimer(duration_secs)), (ToastOverlay, ToastTimer(duration_secs)),
message, message,
variant, variant,
// Sits above the queued banner so a celebration toast spawned
// alongside a queued info message remains readable.
Val::Percent(14.0),
Val::Percent(25.0),
Val::Percent(50.0),
UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
); );
} }
@@ -855,25 +752,31 @@ fn spawn_toast(
/// rungs; 18 is the closest rung that preserves the scale invariants /// rungs; 18 is the closest rung that preserves the scale invariants
/// tested in `ui_theme::tests`. /// tested in `ui_theme::tests`.
/// - [`RADIUS_MD`] corners. /// - [`RADIUS_MD`] corners.
/// /// - Bottom-anchored absolute position; `bottom_pct` differs between
/// Layout is owned by [`ToastStackRoot`] (Phase H): the node spawns /// queued and immediate paths so they layer instead of overlap.
/// hidden and unpositioned, and [`adopt_toasts_into_stack`] slots it // The 8-argument signature is intentional — these are the per-toast
/// into the shared bottom-anchored column — one anchor for every toast, // layout values that genuinely differ between the queued and fire-and-
/// simultaneous toasts stack instead of overlapping. // forget call sites. A struct wrapper would just rename the same data.
#[allow(clippy::too_many_arguments)]
fn spawn_toast_node<B: Bundle>( fn spawn_toast_node<B: Bundle>(
commands: &mut Commands, commands: &mut Commands,
bundle: B, bundle: B,
message: String, message: String,
variant: ToastVariant, variant: ToastVariant,
bottom_pct: Val,
left_pct: Val,
width_pct: Val,
padding: UiRect,
) -> Entity { ) -> Entity {
commands commands
.spawn(( .spawn((
bundle, bundle,
ToastNode,
Visibility::Hidden,
Node { Node {
max_width: Val::Percent(70.0), position_type: PositionType::Absolute,
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_3), left: left_pct,
bottom: bottom_pct,
width: width_pct,
padding,
justify_content: JustifyContent::Center, justify_content: JustifyContent::Center,
align_items: AlignItems::Center, align_items: AlignItems::Center,
border: UiRect::all(Val::Px(1.0)), border: UiRect::all(Val::Px(1.0)),
@@ -882,6 +785,7 @@ fn spawn_toast_node<B: Bundle>(
}, },
BackgroundColor(BG_ELEVATED), BackgroundColor(BG_ELEVATED),
BorderColor::all(variant.border_color()), BorderColor::all(variant.border_color()),
ZIndex(Z_TOAST),
)) ))
.with_children(|b| { .with_children(|b| {
b.spawn(( b.spawn((
@@ -1397,85 +1301,4 @@ mod tests {
fn cascade_duration_instant_is_zero() { fn cascade_duration_instant_is_zero() {
assert_eq!(cascade_duration_secs(AnimSpeed::Instant), 0.0); assert_eq!(cascade_duration_secs(AnimSpeed::Instant), 0.0);
} }
// -----------------------------------------------------------------------
// Phase H: unified toast stack
// -----------------------------------------------------------------------
/// Both toast paths must end up as visible children of the single
/// [`ToastStackRoot`] — the Phase H "one anchor" contract.
#[test]
fn queued_and_immediate_toasts_stack_under_one_root() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
app.update(); // Startup: spawns the stack root.
// One immediate (error) toast + one queued (info) toast in the
// same frame — the exact collision case the old staggered
// anchors existed to dodge.
use solitaire_core::{KlondikePile, Tableau};
app.world_mut().write_message(MoveRejectedEvent {
from: KlondikePile::Tableau(Tableau::Tableau1),
to: KlondikePile::Tableau(Tableau::Tableau2),
count: 1,
});
app.world_mut()
.write_message(InfoToastEvent("stacked info".to_string()));
app.update(); // handlers spawn both toasts (hidden, unparented)
app.update(); // adopt_toasts_into_stack re-parents + reveals
let root = app
.world_mut()
.query_filtered::<Entity, With<ToastStackRoot>>()
.single(app.world())
.expect("exactly one ToastStackRoot must exist");
let toasts: Vec<(Entity, &ChildOf, &Visibility)> = app
.world_mut()
.query_filtered::<(Entity, &ChildOf, &Visibility), With<ToastNode>>()
.iter(app.world())
.collect();
assert_eq!(
toasts.len(),
2,
"both the immediate and the queued toast must be adopted"
);
for (entity, child_of, visibility) in toasts {
assert_eq!(
child_of.parent(),
root,
"toast {entity} must be a child of the shared stack root"
);
assert_eq!(
*visibility,
Visibility::Inherited,
"adopted toast {entity} must be revealed"
);
}
}
/// Toast nodes must not carry their own absolute positioning — the
/// stack root owns layout (regression guard against reintroducing
/// per-path anchors).
#[test]
fn toast_nodes_have_no_absolute_position() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
app.update();
app.world_mut()
.write_message(WarningToastEvent("layout check".to_string()));
app.update();
let node = app
.world_mut()
.query_filtered::<&Node, With<ToastNode>>()
.single(app.world())
.expect("warning toast must spawn a ToastNode");
assert_eq!(
node.position_type,
PositionType::Relative,
"toast nodes are flex children of the stack, not absolute overlays"
);
}
} }
-127
View File
@@ -1,127 +0,0 @@
//! SVG builders for the floating tab bar's action icons.
//!
//! The touch tab bar (`hud_plugin::tab_bar`) is icon-first, but the bundled
//! FiraMono face has no icon glyphs (and the Geometric Shapes block U+25xx
//! is missing on Android entirely), so the icons are authored here as tiny
//! stroke-based SVGs and rasterised through the same `usvg`/`resvg` pipeline
//! as the card themes (`assets::rasterize_svg`).
//!
//! Icons are drawn in **white** on a transparent background inside a
//! 24-unit logical box; the spawn site tints them to the theme colour via
//! `ImageNode::color` (a per-node multiply), so one texture serves every
//! button state.
use bevy::image::Image;
use bevy::math::UVec2;
use super::svg_loader::{SvgLoaderError, rasterize_svg};
/// Rasterisation size for one tab-bar icon texture. Icons display at
/// 24 logical px; 96 px texels keep them crisp at the Fold's 1.2 scale
/// factor (and any future 3× phone) while staying trivially small.
pub const HUD_ICON_TEXTURE_PX: u32 = 96;
/// The five actions the floating touch tab bar can show.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HudIcon {
/// Counter-clockwise "take back" arrow.
Undo,
/// Two offset card outlines — draw from the stock.
Draw,
/// Lightbulb — highlight a suggested move.
Hint,
/// Two vertical bars.
Pause,
/// Three-line hamburger — the floating menu button.
Menu,
}
impl HudIcon {
/// Build the icon's SVG document. All icons share one visual language:
/// 2-unit white strokes, round caps, transparent fill, 24-unit box.
pub fn svg(self) -> String {
let body = match self {
// Arrow head pointing left into a half-circle sweeping back
// over the top — the conventional undo mark.
HudIcon::Undo => concat!(
r##"<path d="M 8 5 L 4 9 L 8 13"/>"##,
r##"<path d="M 4 9 H 14 A 5.5 5.5 0 0 1 14 20 H 8"/>"##,
),
// Back card peeks out behind the front card; the front card is
// drawn second so its stroke reads on top.
HudIcon::Draw => concat!(
r##"<rect x="8.5" y="3" width="11" height="15" rx="2"/>"##,
r##"<rect x="4" y="6.5" width="11" height="15" rx="2"/>"##,
),
// Bulb dome with a flat lamp base beneath.
HudIcon::Hint => concat!(
r##"<path d="M 9 15.5 A 6.5 6.5 0 1 1 15 15.5 V 18 H 9 Z"/>"##,
r##"<path d="M 10 21 H 14"/>"##,
),
HudIcon::Pause => concat!(
r##"<path d="M 9 6 V 18"/>"##,
r##"<path d="M 15 6 V 18"/>"##,
),
HudIcon::Menu => concat!(
r##"<path d="M 4 6.5 H 20"/>"##,
r##"<path d="M 4 12 H 20"/>"##,
r##"<path d="M 4 17.5 H 20"/>"##,
),
};
format!(
concat!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">"##,
r##"<g fill="none" stroke="#ffffff" stroke-width="2" "##,
r##"stroke-linecap="round" stroke-linejoin="round">{body}</g>"##,
r##"</svg>"##,
),
body = body
)
}
/// Rasterise this icon into a Bevy [`Image`] at
/// [`HUD_ICON_TEXTURE_PX`] square.
pub fn rasterize(self) -> Result<Image, SvgLoaderError> {
rasterize_svg(self.svg().as_bytes(), UVec2::splat(HUD_ICON_TEXTURE_PX))
}
}
#[cfg(test)]
mod tests {
use super::*;
const ALL: [HudIcon; 5] = [
HudIcon::Undo,
HudIcon::Draw,
HudIcon::Hint,
HudIcon::Pause,
HudIcon::Menu,
];
/// Every icon must survive the real usvg/resvg pipeline at the real
/// texture size — a malformed path here would otherwise only surface
/// as a blank button on device.
#[test]
fn every_icon_rasterizes_at_texture_size() {
for icon in ALL {
let img = icon.rasterize().expect("icon SVG must rasterise");
assert_eq!(img.width(), HUD_ICON_TEXTURE_PX, "{icon:?} width");
assert_eq!(img.height(), HUD_ICON_TEXTURE_PX, "{icon:?} height");
}
}
/// Rasterised icons must contain visible (non-transparent) pixels —
/// guards against a stroke accidentally drawn outside the viewBox.
#[test]
fn every_icon_has_visible_pixels() {
for icon in ALL {
let img = icon.rasterize().expect("icon SVG must rasterise");
let data = img.data.as_ref().expect("rasterised image has data");
let opaque = data.chunks_exact(4).filter(|px| px[3] > 0).count();
assert!(
opaque > 100,
"{icon:?} renders only {opaque} visible pixels"
);
}
}
}
-1
View File
@@ -5,7 +5,6 @@
//! custom `AssetSource` implementations used by the theme system. //! custom `AssetSource` implementations used by the theme system.
pub mod card_face_svg; pub mod card_face_svg;
pub mod hud_icon_svg;
pub mod icon_svg; pub mod icon_svg;
pub mod sources; pub mod sources;
pub mod svg_loader; pub mod svg_loader;
+24 -27
View File
@@ -53,12 +53,12 @@ pub fn set_user_theme_dir(path: PathBuf) -> Result<(), PathBuf> {
/// Returns the absolute path of the user-themes directory on the /// Returns the absolute path of the user-themes directory on the
/// current platform. /// current platform.
/// ///
/// When [`solitaire_data::data_dir`] returns `None` (broken `$HOME` / /// # Panics
/// `$XDG_*` on desktop; always on wasm32, which has no filesystem) this ///
/// returns an empty path — callers treat that as "no user themes" and /// Panics if [`solitaire_data::data_dir`] returns `None`, which on
/// the bundled default theme still works. A warning naming the /// desktop indicates a broken `$HOME` / `$XDG_*` configuration.
/// [`set_user_theme_dir`] workaround is logged once. Android always /// Android always returns `Some`. The panic message names the
/// resolves. /// supported workaround ([`set_user_theme_dir`]).
pub fn user_theme_dir() -> PathBuf { pub fn user_theme_dir() -> PathBuf {
if let Some(p) = USER_THEME_DIR_OVERRIDE.get() { if let Some(p) = USER_THEME_DIR_OVERRIDE.get() {
return p.clone(); return p.clone();
@@ -76,32 +76,29 @@ fn user_theme_dir_for(data_dir: PathBuf) -> PathBuf {
/// Per-target-os resolution of the platform's data dir. Delegates /// Per-target-os resolution of the platform's data dir. Delegates
/// to [`solitaire_data::data_dir`] which encapsulates the /// to [`solitaire_data::data_dir`] which encapsulates the
/// per-target shape (desktop: `dirs::data_dir()`; android: the /// per-target shape (desktop: `dirs::data_dir()`; android: the
/// hardcoded `/data/data/<package>/files` sandbox path). /// hardcoded `/data/data/<package>/files` sandbox path). Panics
/// /// only when the underlying resolver returns `None`, which on
/// When the resolver returns `None` — always on wasm32 (no /// desktop indicates a broken `$HOME` / `$XDG_*` configuration —
/// filesystem), or a broken `$HOME` / `$XDG_*` configuration on /// the panic message names the supported workaround.
/// desktop — this degrades to an empty path, which downstream theme
/// scanning treats as "no user themes"; the bundled default theme is
/// unaffected. CLAUDE.md §2.3 forbids panicking here: losing custom
/// themes must not take the whole game down with it.
fn detected_platform_data_dir() -> PathBuf { fn detected_platform_data_dir() -> PathBuf {
solitaire_data::data_dir().unwrap_or_else(|| { solitaire_data::data_dir().unwrap_or_else(|| {
// On wasm32, data_dir() always returns None — there is no filesystem.
// User themes are not supported in the browser build; return an empty
// path so callers produce a benign empty dir rather than panicking.
#[cfg(target_arch = "wasm32")]
{
PathBuf::new()
}
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
{ {
use std::sync::Once; panic!(
static WARN_ONCE: Once = Once::new(); "user_theme_dir(): platform data directory is unavailable. \
WARN_ONCE.call_once(|| { On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \
bevy::log::warn!( the OS reported no Application Support / AppData path. \
"user_theme_dir(): platform data directory is unavailable; \ As a workaround call solitaire_engine::assets::user_dir::\
user themes are disabled. On Linux check $XDG_DATA_HOME or \ set_user_theme_dir() before App::run()."
$HOME; on macOS / Windows the OS reported no Application \ )
Support / AppData path. As a workaround call \
solitaire_engine::assets::user_dir::set_user_theme_dir() \
before App::run()."
);
});
} }
PathBuf::new()
}) })
} }
+4 -5
View File
@@ -1,7 +1,7 @@
//! Sound-effect playback via `kira`. //! Sound-effect playback via `kira`.
//! //!
//! Loads seven embedded WAVs (`include_bytes!`) at startup — six SFX plus //! Loads five embedded WAVs (`include_bytes!`) at startup and plays them in
//! the ambient loop — and plays them in response to gameplay events: //! response to gameplay events:
//! //!
//! | Event | Sound | //! | Event | Sound |
//! |---|---| //! |---|---|
@@ -10,7 +10,6 @@
//! | `MoveRejectedEvent` | `card_invalid.wav` | //! | `MoveRejectedEvent` | `card_invalid.wav` |
//! | `NewGameRequestEvent` | `card_deal.wav` | //! | `NewGameRequestEvent` | `card_deal.wav` |
//! | `GameWonEvent` | `win_fanfare.wav` | //! | `GameWonEvent` | `win_fanfare.wav` |
//! | `FoundationCompletedEvent` | `foundation_complete.wav` |
//! //!
//! An ambient loop (`ambient_loop.wav`) is started at plugin startup at very //! An ambient loop (`ambient_loop.wav`) is started at plugin startup at very
//! low volume (0.05 amplitude) routed through `music_track`. //! low volume (0.05 amplitude) routed through `music_track`.
@@ -39,7 +38,7 @@ use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
/// Volume amplitude for the stock-recycle draw sound (half of normal 1.0). /// Volume amplitude for the stock-recycle draw sound (half of normal 1.0).
const RECYCLE_VOLUME: f64 = 0.5; const RECYCLE_VOLUME: f64 = 0.5;
/// Volume amplitude for the ambient music loop. /// Volume amplitude for the ambient music loop placeholder.
const AMBIENT_VOLUME: f64 = 0.05; const AMBIENT_VOLUME: f64 = 0.05;
/// Converts a linear amplitude (0.01.0+) to the `Decibels` type used by /// Converts a linear amplitude (0.01.0+) to the `Decibels` type used by
@@ -102,7 +101,7 @@ pub struct MuteState {
pub music_muted: bool, pub music_muted: bool,
} }
/// Plays sound effects and background music via `kira`. Responds to game events (card place, flip, invalid move, win fanfare) and respects volume settings from `SettingsResource`. /// Plays sound effects and background music via `bevy_kira_audio`. Responds to game events (card place, flip, invalid move, win fanfare) and respects volume settings from `SettingsResource`.
pub struct AudioPlugin; pub struct AudioPlugin;
impl Plugin for AudioPlugin { impl Plugin for AudioPlugin {
+2 -11
View File
@@ -147,7 +147,6 @@ fn drive_auto_complete(
time: Res<Time>, time: Res<Time>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
mut moves: MessageWriter<MoveRequestEvent>, mut moves: MessageWriter<MoveRequestEvent>,
mut redraw: MessageWriter<RequestRedraw>,
) { ) {
if !state.active { if !state.active {
return; return;
@@ -155,10 +154,6 @@ fn drive_auto_complete(
if paused.is_some_and(|p| p.0) { if paused.is_some_and(|p| p.0) {
return; return;
} }
// Keepalive: the step-interval cooldown only advances on frames that
// actually run, so keep the winit loop awake for the whole burst under
// Android's reactive_low_power focused_mode.
redraw.write(RequestRedraw);
state.cooldown -= time.delta_secs(); state.cooldown -= time.delta_secs();
if state.cooldown > 0.0 { if state.cooldown > 0.0 {
@@ -179,9 +174,9 @@ mod tests {
use super::*; use super::*;
use crate::game_plugin::GamePlugin; use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin; use crate::table_plugin::TablePlugin;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{Deck, Rank, Suit}; use solitaire_core::{Deck, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState}; use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::{Foundation, KlondikePile, Tableau};
fn headless_app() -> App { fn headless_app() -> App {
let mut app = App::new(); let mut app = App::new();
@@ -219,11 +214,7 @@ mod tests {
} }
g.set_test_tableau_cards( g.set_test_tableau_cards(
Tableau::Tableau1, Tableau::Tableau1,
vec![solitaire_core::Card::new( vec![solitaire_core::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
Deck::Deck1,
Suit::Clubs,
Rank::Ace,
)],
); );
g.set_test_auto_completable(true); g.set_test_auto_completable(true);
let expected = ( let expected = (
@@ -18,6 +18,11 @@
//! The sine term is 0 at `t = 0` and `t = 1` and peaks at `t = 0.5`, so the //! The sine term is 0 at `t = 0` and `t = 1` and peaks at `t = 0.5`, so the
//! card "floats up" in the middle of its travel and lands at its correct rest z. //! card "floats up" in the middle of its travel and lands at its correct rest z.
//! //!
//! # Retargeting
//!
//! When a card is redirected mid-flight, call [`retarget_animation`]. It reads
//! the current interpolated position so the card never snaps.
//!
//! # Coexistence with `CardAnim` //! # Coexistence with `CardAnim`
//! //!
//! `CardAnimation` and the legacy `CardAnim` can coexist in the same world but //! `CardAnimation` and the legacy `CardAnim` can coexist in the same world but
@@ -28,7 +33,6 @@
use std::f32::consts::PI; use std::f32::consts::PI;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::RequestRedraw;
use super::curves::{MotionCurve, sample_curve}; use super::curves::{MotionCurve, sample_curve};
use super::timing::compute_duration; use super::timing::compute_duration;
@@ -118,6 +122,8 @@ impl CardAnimation {
} }
/// Returns the current interpolated XY position without advancing time. /// Returns the current interpolated XY position without advancing time.
///
/// Used by [`retarget_animation`] to read mid-flight position cleanly.
pub fn current_xy(&self) -> Vec2 { pub fn current_xy(&self) -> Vec2 {
if self.duration <= 0.0 { if self.duration <= 0.0 {
return self.end; return self.end;
@@ -128,6 +134,90 @@ impl CardAnimation {
} }
} }
// ---------------------------------------------------------------------------
// Retarget helper
// ---------------------------------------------------------------------------
/// Redirects a card to a new destination without snapping or interrupting motion.
///
/// Reads the card's current interpolated position (from a live [`CardAnimation`]
/// if present, or from `Transform` if stationary) and starts a fresh
/// [`CardAnimation`] from that position. Duration is recalculated from the
/// remaining distance so short paths stay quick.
///
/// # Velocity continuity
///
/// When a card is mid-flight, the new animation starts with a small positive
/// `elapsed` offset (`carry`) derived from how far through the current animation
/// the card is. This preserves a sense of forward momentum: the new curve does
/// not restart from zero velocity, avoiding a visible "lurch" when the target
/// changes rapidly.
///
/// The carry is deliberately small (≤ 10 % of the new duration) so that it
/// never causes a visible position jump — the card's start position is still
/// read from the current transform.
///
/// # Example
///
/// ```ignore
/// // Inside a system that decides to move a card to a new target:
/// let (entity, transform, anim) = cards.get(card_entity)?;
/// retarget_animation(
/// &mut commands,
/// entity,
/// anim, // Option<&CardAnimation>
/// transform,
/// Vec2::new(400.0, 200.0),
/// resting_z,
/// MotionCurve::SmoothSnap,
/// );
/// ```
pub fn retarget_animation(
commands: &mut Commands,
entity: Entity,
current_anim: Option<&CardAnimation>,
transform: &Transform,
new_end: Vec2,
new_end_z: f32,
curve: MotionCurve,
) {
let (current_xy, current_z, momentum_carry) = match current_anim {
Some(anim) if anim.duration > 0.0 => {
// Estimate how far into the current animation we are and carry
// a small fraction of that progress into the new animation.
// This avoids restarting from zero velocity and makes the motion
// feel continuous when the target changes mid-flight.
let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0);
// Cap at 10 % of the new animation so there's no visible jump.
let carry = (t * 0.12).min(0.10);
(anim.current_xy(), transform.translation.z, carry)
}
_ => (
transform.translation.truncate(),
transform.translation.z,
0.0,
),
};
let distance = current_xy.distance(new_end);
let duration = compute_duration(distance);
commands.entity(entity).insert(CardAnimation {
start: current_xy,
end: new_end,
// Start slightly into the new animation to carry forward momentum.
elapsed: momentum_carry * duration,
duration,
curve,
delay: 0.0,
start_z: current_z,
end_z: new_end_z,
z_lift: 8.0,
scale_start: 1.0,
scale_end: 1.0,
});
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// System // System
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -142,18 +232,10 @@ pub(crate) fn advance_card_animations(
time: Res<Time>, time: Res<Time>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
mut q: Query<(Entity, &mut Transform, &mut CardAnimation)>, mut q: Query<(Entity, &mut Transform, &mut CardAnimation)>,
mut redraw: MessageWriter<RequestRedraw>,
) { ) {
if paused.is_some_and(|p| p.0) { if paused.is_some_and(|p| p.0) {
return; return;
} }
// Keep the winit event loop awake while any animation (including one
// still in its delay phase) needs per-frame ticks. Without this,
// Android's reactive_low_power focused_mode only wakes at its 100 ms
// ceiling and card slides render at ~10 fps.
if !q.is_empty() {
redraw.write(RequestRedraw);
}
let dt = time.delta_secs(); let dt = time.delta_secs();
for (entity, mut transform, mut anim) in &mut q { for (entity, mut transform, mut anim) in &mut q {
@@ -201,6 +283,27 @@ pub(crate) fn advance_card_animations(
} }
} }
// ---------------------------------------------------------------------------
// Win cascade
// ---------------------------------------------------------------------------
/// Win-cascade scatter targets — 8 points beyond the window edges.
///
/// Scaled by `radius` (pass `layout.card_size.x * 8.0` for a good result).
pub fn win_scatter_targets(radius: f32) -> [Vec2; 8] {
let r = radius;
[
Vec2::new(r, r),
Vec2::new(-r, r),
Vec2::new(r, -r),
Vec2::new(-r, -r),
Vec2::new(0.0, r),
Vec2::new(0.0, -r),
Vec2::new(r, 0.0),
Vec2::new(-r, 0.0),
]
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tests // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -283,4 +386,21 @@ mod tests {
.with_z_lift(12.0); .with_z_lift(12.0);
assert!((anim.z_lift - 12.0).abs() < 1e-6); assert!((anim.z_lift - 12.0).abs() < 1e-6);
} }
#[test]
fn win_scatter_has_eight_targets() {
let targets = win_scatter_targets(800.0);
assert_eq!(targets.len(), 8);
}
#[test]
fn win_scatter_targets_are_off_center() {
for t in win_scatter_targets(400.0) {
let dist = t.length();
assert!(
dist > 100.0,
"scatter target should be well off-center: {t:?}"
);
}
}
} }
@@ -72,7 +72,9 @@ pub struct HoverState {
/// Describes a user action that arrived while cards were still animating. /// Describes a user action that arrived while cards were still animating.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum BufferedInput { pub enum BufferedInput {
Move { from: MoveRequestEvent }, Move {
from: MoveRequestEvent,
},
Draw, Draw,
Undo, Undo,
} }
+112 -77
View File
@@ -31,6 +31,38 @@
//! )); //! ));
//! ``` //! ```
//! //!
//! Retarget a card mid-flight:
//!
//! ```ignore
//! use solitaire_engine::card_animation::retarget_animation;
//!
//! fn handle_drop(
//! mut commands: Commands,
//! q: Query<(Entity, &Transform, Option<&CardAnimation>), With<CardEntity>>,
//! ) {
//! let (entity, transform, anim) = q.get(card_entity).unwrap();
//! retarget_animation(
//! &mut commands,
//! entity,
//! anim,
//! transform,
//! new_target_xy,
//! new_target_z,
//! MotionCurve::SmoothSnap,
//! );
//! }
//! ```
//!
//! # Win cascade with `Expressive` curve
//!
//! The existing `AnimationPlugin` drives the win cascade with `CardAnim`
//! (linear). To use the curve-based cascade instead, disable
//! `handle_win_cascade` in `AnimationPlugin` and register `WinCascadePlugin`
//! (declared below) which uses `CardAnimation` + `MotionCurve::Expressive`.
//!
//! They **must not both be active** — both write to `Transform` on the same
//! 52 entities and will race.
//!
//! # Coexistence rules //! # Coexistence rules
//! //!
//! | Condition | Safe? | //! | Condition | Safe? |
@@ -48,21 +80,24 @@ pub mod interaction;
pub mod timing; pub mod timing;
pub mod tuning; pub mod tuning;
pub use animation::CardAnimation; pub use animation::{CardAnimation, retarget_animation, win_scatter_targets};
pub use chain::AnimationChain; pub use chain::AnimationChain;
pub use curves::{MotionCurve, sample_curve}; pub use curves::{MotionCurve, sample_curve};
pub use diagnostics::{FrameTimeDiagnostics, WINDOW_SIZE as DIAG_WINDOW_SIZE}; pub use diagnostics::{FrameTimeDiagnostics, WINDOW_SIZE as DIAG_WINDOW_SIZE};
pub use interaction::{BufferedInput, HoverState, InputBuffer}; pub use interaction::{BufferedInput, HoverState, InputBuffer};
pub use timing::{ pub use timing::{
DEAL_INTERVAL_SECS, MAX_DURATION_SECS, MIN_DURATION_SECS, compute_duration, micro_vary, DEAL_INTERVAL_SECS, MAX_DURATION_SECS, MIN_DURATION_SECS, WIN_CASCADE_INTERVAL_SECS,
cascade_delay, compute_duration, micro_vary,
}; };
pub use tuning::{AnimationTuning, InputPlatform}; pub use tuning::{AnimationTuning, InputPlatform};
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::RequestRedraw; use bevy::window::RequestRedraw;
use crate::card_plugin::CardEntity;
use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, UndoRequestEvent}; use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, UndoRequestEvent};
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource;
use crate::resources::DragState; use crate::resources::DragState;
use animation::advance_card_animations; use animation::advance_card_animations;
@@ -83,14 +118,6 @@ use tuning::update_input_platform;
/// long as no single entity carries both `CardAnim` and `CardAnimation`. /// long as no single entity carries both `CardAnim` and `CardAnimation`.
pub struct CardAnimationPlugin; pub struct CardAnimationPlugin;
/// Set wrapping this plugin's Update chain (curve tweens, hover/drag
/// visuals). First anim stage on the visual spine: …BoardVisuals <
/// MarkerVisuals < CardTweenFx < [`crate::animation_plugin::AnimFx`] <
/// FeedbackFx — members write card `Transform`s, which every other painter
/// set also touches (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct CardTweenFx;
impl Plugin for CardAnimationPlugin { impl Plugin for CardAnimationPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
// Register events and resources idempotently — double-registration is // Register events and resources idempotently — double-registration is
@@ -123,14 +150,7 @@ impl Plugin for CardAnimationPlugin {
// still present in the same frame it was removed (deferred // still present in the same frame it was removed (deferred
// commands aren't applied until the next ApplyDeferred // commands aren't applied until the next ApplyDeferred
// point), causing a 1-frame gap between every chain step. // point), causing a 1-frame gap between every chain step.
// ApplyDeferred,
// `ambiguous_with_all`: an explicit sync point conflicts
// (on `World`) with every unordered system in the
// schedule. Only the flush of THIS chain's commands needs
// ordering — the chain edges provide that; whether an
// unrelated system's commands flush here or at the next
// sync point is immaterial to it (#143).
ApplyDeferred.ambiguous_with_all(),
// After each animation finishes, pop the next chain segment. // After each animation finishes, pop the next chain segment.
advance_animation_chains, advance_animation_chains,
// Interaction visuals (run after animation for final positions). // Interaction visuals (run after animation for final positions).
@@ -138,28 +158,71 @@ impl Plugin for CardAnimationPlugin {
apply_hover_scale, apply_hover_scale,
apply_drag_visual, apply_drag_visual,
// Drain buffered inputs only when no animations remain. // Drain buffered inputs only when no animations remain.
// It re-emits buffered undo/new-game requests, so it drain_input_buffer,
// joins the self-ambiguous writer clubs (#143).
drain_input_buffer
.in_set(crate::game_plugin::UndoRequestWriters)
.ambiguous_with(crate::game_plugin::UndoRequestWriters),
) )
.chain() .chain()
.in_set(CardTweenFx), .after(GameMutation),
)
.configure_sets(
Update,
CardTweenFx
.after(GameMutation)
.after(crate::table_plugin::MarkerVisuals)
// HUD text pulses touch Transform on UI entities only —
// disjoint from the card entities this set animates
// (same rationale as UiTextFx × BoardVisuals, #143).
.ambiguous_with(crate::ui_theme::UiTextFx),
); );
} }
} }
// ---------------------------------------------------------------------------
// Optional: win cascade with Expressive curve
// ---------------------------------------------------------------------------
/// Optional plugin that replaces the linear win cascade in `AnimationPlugin`
/// with an `Expressive`-curve cascade.
///
/// **Do not register this alongside `AnimationPlugin`'s win cascade** — they
/// will race on the same card entities. To use this plugin, prevent
/// `AnimationPlugin` from handling `GameWonEvent` (or remove it and manage
/// win toasts manually).
pub struct WinCascadePlugin;
impl Plugin for WinCascadePlugin {
fn build(&self, app: &mut App) {
app.add_systems(Update, trigger_expressive_win_cascade.after(GameMutation));
}
}
/// Inserts `CardAnimation` (Expressive curve) on every card when `GameWonEvent` fires.
///
/// Cards scatter to 8 off-screen positions with per-card stagger. The z-lift
/// creates a "burst" effect as cards fly outward.
fn trigger_expressive_win_cascade(
mut events: MessageReader<GameWonEvent>,
cards: Query<(Entity, &Transform), With<CardEntity>>,
layout: Option<Res<LayoutResource>>,
mut commands: Commands,
) {
if events.read().next().is_none() {
return;
}
let radius = layout.as_ref().map_or(800.0, |l| l.0.card_size.x * 8.0);
let targets = win_scatter_targets(radius);
for (index, (entity, transform)) in cards.iter().enumerate() {
let start_xy = transform.translation.truncate();
let start_z = transform.translation.z;
let target = targets[index % targets.len()];
commands.entity(entity).insert(
CardAnimation::slide(
start_xy,
start_z,
target,
start_z + 60.0,
MotionCurve::Expressive,
)
.with_delay(cascade_delay(index, WIN_CASCADE_INTERVAL_SECS))
.with_duration(0.65)
.with_z_lift(25.0),
);
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tests // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -244,49 +307,6 @@ mod tests {
); );
} }
/// Regression test for the v0.40.0 Android animation-lag bug: commit
/// 38e4c03 switched Android to `reactive_low_power` focused_mode on the
/// premise that animation systems write `RequestRedraw` while active,
/// but the writers were never added — card slides rendered at the 100 ms
/// wake ceiling (~10 fps). Active animations MUST emit `RequestRedraw`
/// every frame; an idle board must not.
#[test]
fn active_card_animation_requests_redraw() {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(CardAnimationPlugin);
// Idle board: no redraw requests.
app.update();
assert!(
app.world().resource::<Messages<RequestRedraw>>().is_empty(),
"no RequestRedraw expected while no animation is active"
);
app.world_mut().spawn((
Transform::from_translation(Vec3::ZERO),
CardAnimation {
start: Vec2::ZERO,
end: Vec2::new(100.0, 0.0),
elapsed: 0.0,
duration: 1.0,
curve: MotionCurve::Responsive,
delay: 0.0,
start_z: 0.0,
end_z: 0.0,
z_lift: 0.0,
scale_start: 1.0,
scale_end: 1.0,
},
));
app.update();
assert!(
!app.world().resource::<Messages<RequestRedraw>>().is_empty(),
"an active CardAnimation must write RequestRedraw each frame to \
sustain the reactive render loop"
);
}
#[test] #[test]
fn card_animation_instant_snaps_on_zero_duration() { fn card_animation_instant_snaps_on_zero_duration() {
let mut app = App::new(); let mut app = App::new();
@@ -392,4 +412,19 @@ mod tests {
let state = HoverState::default(); let state = HoverState::default();
assert!(state.entity.is_none()); assert!(state.entity.is_none());
} }
#[test]
fn win_scatter_produces_eight_distinct_points() {
let targets = win_scatter_targets(600.0);
assert_eq!(targets.len(), 8);
// All must be different.
for i in 0..8 {
for j in (i + 1)..8 {
assert_ne!(
targets[i], targets[j],
"scatter targets {i} and {j} must be distinct"
);
}
}
}
} }
@@ -49,6 +49,17 @@ pub fn micro_vary(duration: f32, entity_index: u32) -> f32 {
duration * (1.0 + variation) duration * (1.0 + variation)
} }
/// Returns the pre-animation delay for card at `index` in a staggered cascade.
///
/// `delay = index × interval_secs`.
#[inline]
pub fn cascade_delay(index: usize, interval_secs: f32) -> f32 {
index as f32 * interval_secs
}
/// Recommended per-card interval for the win cascade (Normal speed).
pub const WIN_CASCADE_INTERVAL_SECS: f32 = 0.018;
/// Recommended per-card interval for deal animations (Normal speed). /// Recommended per-card interval for deal animations (Normal speed).
pub const DEAL_INTERVAL_SECS: f32 = 0.022; pub const DEAL_INTERVAL_SECS: f32 = 0.022;
@@ -126,4 +137,22 @@ mod tests {
"micro_vary should differ for different indices" "micro_vary should differ for different indices"
); );
} }
#[test]
fn cascade_delay_zero_index_is_zero() {
assert_eq!(cascade_delay(0, 0.018), 0.0);
}
#[test]
fn cascade_delay_scales_linearly() {
let interval = 0.018;
for i in 0..52usize {
let expected = i as f32 * interval;
let actual = cascade_delay(i, interval);
assert!(
(actual - expected).abs() < 1e-6,
"cascade_delay({i}) = {actual}, expected {expected}"
);
}
}
} }
+4 -1
View File
@@ -10,7 +10,9 @@ use crate::animation_plugin::EffectiveSlideDuration;
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent}; use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
use crate::layout::LayoutResource; use crate::layout::LayoutResource;
use crate::resources::DragState; use crate::resources::DragState;
use crate::ui_theme::{CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z}; use crate::ui_theme::{
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z,
};
/// Listens for `CardFlippedEvent` and inserts a `CardFlipAnim` on the entity. /// Listens for `CardFlippedEvent` and inserts a `CardFlipAnim` on the entity.
/// ///
@@ -195,3 +197,4 @@ pub(super) fn update_card_shadows_on_drag(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Task #28 — Hint highlight tick system // Task #28 — Hint highlight tick system
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -2,6 +2,7 @@
use super::*; use super::*;
use bevy::color::Color; use bevy::color::Color;
use solitaire_core::Card; use solitaire_core::Card;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
@@ -271,3 +272,5 @@ pub(super) fn find_top_card_at(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Task #28 — Stock-empty visual indicator // Task #28 — Stock-empty visual indicator
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -2,6 +2,7 @@
use super::*; use super::*;
use bevy::color::Color; use bevy::color::Color;
use bevy::sprite::Anchor; use bevy::sprite::Anchor;
use solitaire_core::{Card, Rank, Suit}; use solitaire_core::{Card, Rank, Suit};
@@ -204,3 +205,4 @@ pub(super) fn add_android_corner_label(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Task #34 — Card-flip animation systems // Task #34 — Card-flip animation systems
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+6 -1
View File
@@ -15,7 +15,10 @@ use crate::font_plugin::FontResource;
use crate::layout::{Layout, LayoutResource}; use crate::layout::{Layout, LayoutResource};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::table_plugin::PileMarker; use crate::table_plugin::PileMarker;
use crate::ui_theme::{CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_PADDING_DRAG, CARD_SHADOW_PADDING_IDLE}; use crate::ui_theme::{
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_PADDING_DRAG,
CARD_SHADOW_PADDING_IDLE,
};
/// Coalesces every `WindowResized` event arriving this frame into the latest /// Coalesces every `WindowResized` event arriving this frame into the latest
/// pending size on [`ResizeThrottle`]. /// pending size on [`ResizeThrottle`].
@@ -344,3 +347,5 @@ pub(super) fn fill_tableau_fan_on_startup(
}; };
crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0); crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0);
} }
+8 -6
View File
@@ -14,8 +14,8 @@ use std::collections::HashMap;
use bevy::color::Color; use bevy::color::Color;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::Card;
use solitaire_core::{KlondikePile, Tableau}; use solitaire_core::{KlondikePile, Tableau};
use solitaire_core::Card;
use crate::card_animation::CardAnimation; use crate::card_animation::CardAnimation;
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent}; use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
@@ -577,7 +577,10 @@ impl Plugin for CardPlugin {
// the chain each pair is a scheduler ambiguity (#143). All // the chain each pair is a scheduler ambiguity (#143). All
// members are cheap and mostly change-gated; sequential // members are cheap and mostly change-gated; sequential
// execution is not a cost that matters here. // execution is not a cost that matters here.
.configure_sets(Update, LayoutSystem::UpdateOnResize.before(BoardVisuals)) .configure_sets(
Update,
LayoutSystem::UpdateOnResize.before(BoardVisuals),
)
.add_systems( .add_systems(
Update, Update,
( (
@@ -592,11 +595,10 @@ impl Plugin for CardPlugin {
tick_right_click_highlights, tick_right_click_highlights,
clear_right_click_highlights_on_state_change, clear_right_click_highlights_on_state_change,
clear_right_click_highlights_on_pause, clear_right_click_highlights_on_pause,
// Reads only the anim-speed/reduce-motion flags; a frame tick_hint_highlight,
// of lag is invisible (#143).
tick_hint_highlight.ambiguous_with(crate::settings_plugin::SettingsAccess),
update_stock_empty_indicator, update_stock_empty_indicator,
update_stock_count_badge.run_if(resource_changed::<GameStateResource>), update_stock_count_badge
.run_if(resource_changed::<GameStateResource>),
collect_resize_events, collect_resize_events,
snap_cards_on_window_resize, snap_cards_on_window_resize,
resize_android_corner_labels, resize_android_corner_labels,
+6 -1
View File
@@ -2,6 +2,7 @@
use super::*; use super::*;
use bevy::color::Color; use bevy::color::Color;
use solitaire_core::KlondikePile; use solitaire_core::KlondikePile;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
@@ -11,7 +12,10 @@ use crate::font_plugin::FontResource;
use crate::layout::{Layout, LayoutResource}; use crate::layout::{Layout, LayoutResource};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker}; use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker};
use crate::ui_theme::{STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY, TYPE_BODY, Z_STOCK_BADGE}; use crate::ui_theme::{
STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY,
TYPE_BODY, Z_STOCK_BADGE,
};
/// Sprite colour applied to the stock `PileMarker` when the stock pile is empty, /// Sprite colour applied to the stock `PileMarker` when the stock pile is empty,
/// to signal to the player that there are no more cards to draw. Pure white /// to signal to the player that there are no more cards to draw. Pure white
@@ -284,3 +288,4 @@ pub(super) fn update_stock_count_badge(
} }
} }
} }
+2 -1
View File
@@ -6,9 +6,9 @@ use super::*;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use bevy::color::Color; use bevy::color::Color;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{Card, Rank, Suit}; use solitaire_core::{Card, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState}; use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration}; use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration};
use crate::card_animation::CardAnimation; use crate::card_animation::CardAnimation;
@@ -700,3 +700,4 @@ pub(super) fn update_card_entity(
commands.entity(entity).insert(new_children_key); commands.entity(entity).insert(new_children_key);
} }
} }
+33 -20
View File
@@ -1,16 +1,17 @@
use super::*; use super::*;
use crate::events::StateChangedEvent;
use crate::game_plugin::GamePlugin; use crate::game_plugin::GamePlugin;
use crate::layout::LayoutResource;
use crate::layout::TABLEAU_FAN_FRAC; use crate::layout::TABLEAU_FAN_FRAC;
use crate::resources::DragState;
use crate::table_plugin::TablePlugin; use crate::table_plugin::TablePlugin;
use crate::ui_theme::TEXT_PRIMARY_HC;
use bevy::window::WindowResized;
use solitaire_core::Deck; use solitaire_core::Deck;
use bevy::window::WindowResized;
use solitaire_core::{Card, Rank, Suit}; use solitaire_core::{Card, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState};
use std::collections::HashSet; use std::collections::HashSet;
use crate::events::StateChangedEvent;
use crate::layout::LayoutResource;
use crate::resources::DragState;
use crate::ui_theme::TEXT_PRIMARY_HC;
use solitaire_core::{DrawStockConfig, game_state::GameState};
/// Convenience constructor — all unit tests use Deck1. /// Convenience constructor — all unit tests use Deck1.
fn make_card(suit: Suit, rank: Rank) -> Card { fn make_card(suit: Suit, rank: Rank) -> Card {
@@ -118,7 +119,8 @@ fn waste_draw_one_only_renders_top_card() {
for _ in 0..3 { for _ in 0..3 {
let _ = g.draw(); let _ = g.draw();
} }
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect(); let waste_ids: HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect();
assert_eq!(waste_ids.len(), 3); assert_eq!(waste_ids.len(), 3);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
@@ -161,7 +163,8 @@ fn waste_draw_three_renders_up_to_three_fanned_cards() {
"need at least 3 waste cards for this test" "need at least 3 waste cards for this test"
); );
let waste_ids: HashSet<Card> = waste_pile.iter().map(|c| c.0.clone()).collect(); let waste_ids: HashSet<Card> =
waste_pile.iter().map(|c| c.0.clone()).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
@@ -213,7 +216,8 @@ fn waste_draw_three_fans_correctly_when_pile_smaller_than_visible() {
let count = waste_pile.len(); let count = waste_pile.len();
assert!(count >= 2, "need at least 2 waste cards"); assert!(count >= 2, "need at least 2 waste cards");
let waste_ids: HashSet<Card> = waste_pile.iter().map(|c| c.0.clone()).collect(); let waste_ids: HashSet<Card> =
waste_pile.iter().map(|c| c.0.clone()).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
@@ -250,7 +254,8 @@ fn waste_draw_one_buffer_card_at_same_xy_as_top() {
for _ in 0..3 { for _ in 0..3 {
let _ = g.draw(); let _ = g.draw();
} }
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect(); let waste_ids: HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
let waste_rendered: Vec<_> = positions let waste_rendered: Vec<_> = positions
@@ -740,7 +745,8 @@ fn advance_past_resize_throttle(app: &mut App) {
fn fire_window_resize(app: &mut App, width: f32, height: f32) { fn fire_window_resize(app: &mut App, width: f32, height: f32) {
// Any Entity will do — the snap system reads only width/height. // Any Entity will do — the snap system reads only width/height.
let window = Entity::from_raw_u32(0).expect("Entity::from_raw_u32(0) is a valid placeholder"); let window = Entity::from_raw_u32(0)
.expect("Entity::from_raw_u32(0) is a valid placeholder");
app.world_mut().write_message(WindowResized { app.world_mut().write_message(WindowResized {
window, window,
width, width,
@@ -922,7 +928,8 @@ fn resize_in_place_updates_card_label_font_size() {
// Sanity-check: the new font size matches FONT_SIZE_FRAC × the // Sanity-check: the new font size matches FONT_SIZE_FRAC × the
// post-resize card width, so the in-place path is using the // post-resize card width, so the in-place path is using the
// refreshed Layout. // refreshed Layout.
let expected_layout = crate::layout::compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0, true); let expected_layout =
crate::layout::compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0, true);
let expected = expected_layout.card_size.x * FONT_SIZE_FRAC; let expected = expected_layout.card_size.x * FONT_SIZE_FRAC;
assert!( assert!(
(after - expected).abs() < 1e-3, (after - expected).abs() < 1e-3,
@@ -1039,8 +1046,7 @@ fn shadow_offset_increases_during_drag() {
q.iter(app.world()) q.iter(app.world())
.next() .next()
.expect("fixture should spawn at least one CardEntity") .expect("fixture should spawn at least one CardEntity")
.card .card.clone()
.clone()
}; };
// Pick a *different* card to act as the negative control — // Pick a *different* card to act as the negative control —
@@ -1095,7 +1101,9 @@ fn shadow_offset_increases_during_drag() {
fn shadow_offset_for_card(app: &mut App, card: &Card) -> Vec2 { fn shadow_offset_for_card(app: &mut App, card: &Card) -> Vec2 {
// Map every CardEntity to its (Entity, card). // Map every CardEntity to its (Entity, card).
let card_entity = { let card_entity = {
let mut q = app.world_mut().query::<(Entity, &CardEntity)>(); let mut q = app
.world_mut()
.query::<(Entity, &CardEntity)>();
q.iter(app.world()) q.iter(app.world())
.find(|(_, c)| c.card == *card) .find(|(_, c)| c.card == *card)
.map(|(e, _)| e) .map(|(e, _)| e)
@@ -1190,7 +1198,8 @@ fn stock_badge_updates_when_stock_count_changes() {
assert_eq!(stock_badge_text(&mut app), "24"); assert_eq!(stock_badge_text(&mut app), "24");
{ {
let mut game = app.world_mut().resource_mut::<GameStateResource>(); let mut game = app.world_mut().resource_mut::<GameStateResource>();
let mut stock: Vec<Card> = game.0.stock_cards().into_iter().map(|(c, _)| c).collect(); let mut stock: Vec<Card> =
game.0.stock_cards().into_iter().map(|(c, _)| c).collect();
let _ = stock.pop(); let _ = stock.pop();
game.0.set_test_stock_cards(stock); game.0.set_test_stock_cards(stock);
} }
@@ -1225,7 +1234,8 @@ fn image_set_with_distinct_back_handles() -> CardImageSet {
// distinct dummy `Image`. We never render these; we only // distinct dummy `Image`. We never render these; we only
// compare ids. // compare ids.
let mut images = Assets::<Image>::default(); let mut images = Assets::<Image>::default();
let backs: [Handle<Image>; 5] = std::array::from_fn(|_| images.add(Image::default())); let backs: [Handle<Image>; 5] =
std::array::from_fn(|_| images.add(Image::default()));
CardImageSet { CardImageSet {
faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())), faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())),
backs, backs,
@@ -1483,7 +1493,8 @@ fn waste_pile_cards_have_strictly_increasing_z() {
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect(); let waste_ids: HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_zs: Vec<f32> = positions let mut waste_zs: Vec<f32> = positions
.iter() .iter()
@@ -1532,7 +1543,8 @@ fn waste_cards_do_not_overlap_stock_column_on_portrait() {
let stock_x = layout.pile_positions[&KlondikePile::Stock].x; let stock_x = layout.pile_positions[&KlondikePile::Stock].x;
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect(); let waste_ids: HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_positions: Vec<_> = card_positions(&g, &layout) let mut waste_positions: Vec<_> = card_positions(&g, &layout)
.into_iter() .into_iter()
@@ -1561,7 +1573,8 @@ fn waste_pile_draw_one_cards_have_distinct_z() {
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect(); let waste_ids: HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_zs: Vec<f32> = positions let mut waste_zs: Vec<f32> = positions
.iter() .iter()
+4 -23
View File
@@ -11,7 +11,8 @@ use solitaire_data::{challenge_count, challenge_seed_for, save_progress_to};
use crate::events::{ use crate::events::{
GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent, GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
}; };
use crate::progress_plugin::{ProgressResource, ProgressStoragePath}; use crate::game_plugin::GameMutation;
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
/// Minimum player level required to start a Challenge run. /// Minimum player level required to start a Challenge run.
@@ -37,28 +38,8 @@ impl Plugin for ChallengePlugin {
.add_message::<StartChallengeRequestEvent>() .add_message::<StartChallengeRequestEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
// Run after ProgressUpdate so we don't fight ProgressPlugin's add_xp. // Run after ProgressUpdate so we don't fight ProgressPlugin's add_xp.
// Ordering comes from the mode-completion chain configured in .add_systems(Update, advance_on_challenge_win.after(ProgressUpdate))
// progress_plugin; the toast it emits joins the writer club .add_systems(Update, handle_start_challenge_request.before(GameMutation));
// drained by the toast pipeline this frame (#143).
.add_systems(
Update,
advance_on_challenge_win
.in_set(crate::progress_plugin::ChallengeCompletion)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
)
// ModeStart places it between the stats abandon-recorder and
// GameMutation; it shares the new-game request/toast streams
// with the other writers (#143).
.add_systems(
Update,
handle_start_challenge_request
.in_set(crate::game_plugin::ModeStart)
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
);
} }
} }
-194
View File
@@ -1,194 +0,0 @@
//! Hold-`/` hotkey cheat sheet (Phase J).
//!
//! While `/` is held on a keyboard platform, a lightweight overlay
//! lists every binding from [`crate::hotkeys::HOTKEYS`] — the same
//! table the onboarding slide teaches from, so the two can never
//! disagree. Releasing the key hides it instantly; it never captures
//! input, never pauses the game, and never spawns while a modal owns
//! the screen (which also keeps it out of the seed-entry text field's
//! way).
//!
//! Not a `spawn_modal` modal on purpose: modals are sticky and guarded;
//! this is a momentary reference card, closer to a tooltip than a
//! dialog.
use bevy::input::ButtonInput;
use bevy::prelude::*;
use crate::font_plugin::FontResource;
use crate::hotkeys::HOTKEYS;
use crate::platform::SHOW_KEYBOARD_ACCELERATORS;
use crate::ui_modal::ModalScrim;
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, HighContrastBorder, RADIUS_MD, TEXT_PRIMARY,
TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
Z_TOOLTIP,
};
/// Marker on the cheat-sheet overlay root.
#[derive(Component, Debug)]
pub struct CheatSheetOverlay;
/// Registers the hold-`/` driver. Inert on touch-first builds.
pub struct CheatSheetPlugin;
impl Plugin for CheatSheetPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ButtonInput<KeyCode>>()
.add_systems(Update, drive_cheat_sheet);
}
}
/// Shows the overlay while `/` is held (and no modal owns the screen);
/// hides it the frame the key releases.
fn drive_cheat_sheet(
keys: Res<ButtonInput<KeyCode>>,
scrims: Query<(), With<ModalScrim>>,
existing: Query<Entity, With<CheatSheetOverlay>>,
font_res: Option<Res<FontResource>>,
mut commands: Commands,
) {
if !SHOW_KEYBOARD_ACCELERATORS {
return;
}
let held = keys.pressed(KeyCode::Slash);
if held && existing.is_empty() && scrims.is_empty() {
spawn_cheat_sheet(&mut commands, font_res.as_deref());
} else if !held {
for entity in &existing {
commands.entity(entity).despawn();
}
}
}
fn spawn_cheat_sheet(commands: &mut Commands, font_res: Option<&FontResource>) {
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_header = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY,
..default()
};
let font_keys = TextFont {
font: font_handle.clone(),
font_size: TYPE_CAPTION,
..default()
};
let font_desc = TextFont {
font: font_handle,
font_size: TYPE_CAPTION,
..default()
};
commands
.spawn((
CheatSheetOverlay,
Node {
position_type: PositionType::Absolute,
right: Val::Px(16.0),
top: Val::Percent(8.0),
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_1,
padding: UiRect::all(VAL_SPACE_4),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
max_height: Val::Percent(84.0),
overflow: Overflow::scroll_y(),
..default()
},
BackgroundColor(BG_ELEVATED),
BorderColor::all(BORDER_STRONG),
HighContrastBorder::with_default(BORDER_STRONG),
GlobalZIndex(Z_TOOLTIP),
))
.with_children(|panel| {
panel.spawn((
Text::new("Keyboard shortcuts"),
font_header.clone(),
TextColor(TEXT_PRIMARY),
Node {
margin: UiRect::bottom(VAL_SPACE_2),
..default()
},
));
for row in HOTKEYS {
panel
.spawn(Node {
flex_direction: FlexDirection::Row,
column_gap: VAL_SPACE_3,
..default()
})
.with_children(|line| {
line.spawn((
Text::new(row.keys),
font_keys.clone(),
TextColor(ACCENT_PRIMARY),
Node {
min_width: Val::Px(110.0),
..default()
},
));
line.spawn((
Text::new(row.description),
font_desc.clone(),
TextColor(TEXT_SECONDARY),
));
});
}
});
}
#[cfg(test)]
mod tests {
use super::*;
fn app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(CheatSheetPlugin);
app.update();
app
}
fn overlay_count(app: &mut App) -> usize {
app.world_mut()
.query::<&CheatSheetOverlay>()
.iter(app.world())
.count()
}
#[test]
fn holding_slash_shows_and_release_hides() {
let mut app = app();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::Slash);
app.update();
assert_eq!(overlay_count(&mut app), 1, "held / must show the sheet");
// Still held on later frames: exactly one overlay, no stacking.
app.update();
assert_eq!(overlay_count(&mut app), 1);
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.release(KeyCode::Slash);
app.update();
app.update();
assert_eq!(overlay_count(&mut app), 0, "release must hide the sheet");
}
#[test]
fn suppressed_while_a_modal_is_open() {
let mut app = app();
app.world_mut().spawn(ModalScrim);
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::Slash);
app.update();
assert_eq!(
overlay_count(&mut app),
0,
"the sheet must not spawn over a modal (or into a text field)"
);
}
}
@@ -1,309 +0,0 @@
//! Contextual one-time tips (Phase I of the 2026-07 UI redesign).
//!
//! Everything the first-run onboarding doesn't cover is invisible until
//! stumbled upon: hint cycling, the radial quick-action menu. These
//! tips fire **from the situation, not a tour** — each exactly once,
//! recorded in `Settings` like `shown_achievement_onboarding`:
//!
//! - **Stall tip** — after [`STALL_TIP_SECS`] with no board change in an
//! active game, an info toast points at Hint. A player staring at a
//! stuck board is the one moment the tip is welcome.
//! - **Radial tip** — once a game reaches [`RADIAL_TIP_MIN_MOVES`] moves
//! (an engaged player) and the radial menu has never been opened, an
//! info toast teaches the long-press / right-click gesture. Opening
//! the radial organically marks the tip as unnecessary — it is never
//! shown to someone who already knows.
//!
//! Tips render through the queued [`InfoToastEvent`] path, so they share
//! the unified toast stack and never interrupt play.
use bevy::prelude::*;
use solitaire_data::save_settings_to;
use crate::events::{InfoToastEvent, StateChangedEvent};
use crate::game_plugin::GameMutation;
use crate::pause_plugin::PausedResource;
use crate::platform::SHOW_KEYBOARD_ACCELERATORS;
use crate::radial_menu::RightClickRadialState;
use crate::resources::GameStateResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::ui_modal::ModalScrim;
/// Seconds without any board change before the stall tip fires.
/// Long enough that normal thinking never triggers it; short enough to
/// reach a genuinely stuck player before they quit.
const STALL_TIP_SECS: f32 = 45.0;
/// Move count at which an engaged player earns the radial-menu teach.
const RADIAL_TIP_MIN_MOVES: u32 = 15;
/// Stall-tip copy per platform input vocabulary.
const STALL_TIP: &str = if SHOW_KEYBOARD_ACCELERATORS {
"Stuck? Press H for a hint."
} else {
"Stuck? Tap Hint in the bottom bar for a suggested move."
};
/// Radial-tip copy per platform input vocabulary.
const RADIAL_TIP: &str = if SHOW_KEYBOARD_ACCELERATORS {
"Tip: right-click a card for quick actions."
} else {
"Tip: long-press a card for quick actions."
};
/// Seconds of board inactivity, frozen while paused / a modal is open,
/// reset by every [`StateChangedEvent`].
#[derive(Resource, Debug, Default)]
pub struct StallClock(pub f32);
/// Registers the stall clock and the two tip triggers.
pub struct ContextualTipsPlugin;
impl Plugin for ContextualTipsPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<StallClock>()
.add_message::<InfoToastEvent>()
.add_message::<StateChangedEvent>()
.add_systems(
Update,
(
tick_stall_clock,
fire_stall_hint_tip
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
observe_radial_menu_use,
fire_radial_menu_tip
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
)
.chain()
.after(GameMutation),
);
}
}
/// Advances the stall clock; resets on any board change; freezes while
/// paused, while a modal owns the screen, or when the game is over.
fn tick_stall_clock(
time: Res<Time>,
mut state_events: MessageReader<StateChangedEvent>,
paused: Option<Res<PausedResource>>,
scrims: Query<(), With<ModalScrim>>,
game: Option<Res<GameStateResource>>,
mut clock: ResMut<StallClock>,
) {
if state_events.read().next().is_some() {
clock.0 = 0.0;
return;
}
if paused.is_some_and(|p| p.0)
|| !scrims.is_empty()
|| game.as_ref().is_none_or(|g| g.0.is_won())
{
// Frozen, not reset: backgrounding into a menu mid-stall
// shouldn't restart the wait.
return;
}
clock.0 += time.delta_secs();
}
/// Fires the one-shot stall tip once the clock passes the threshold in
/// a game the player has actually started (at least one move).
fn fire_stall_hint_tip(
clock: Res<StallClock>,
game: Option<Res<GameStateResource>>,
mut settings: Option<ResMut<SettingsResource>>,
storage_path: Option<Res<SettingsStoragePath>>,
mut toast: MessageWriter<InfoToastEvent>,
) {
let Some(s) = settings.as_mut() else { return };
if s.0.shown_stall_hint_tip || clock.0 < STALL_TIP_SECS {
return;
}
if game
.as_ref()
.is_none_or(|g| g.0.move_count() == 0 || g.0.is_won())
{
return;
}
toast.write(InfoToastEvent(STALL_TIP.to_string()));
s.0.shown_stall_hint_tip = true;
persist(storage_path.as_deref(), &s.0);
}
/// A player who opens the radial menu on their own doesn't need the
/// teach — mark the tip done silently.
fn observe_radial_menu_use(
radial: Option<Res<RightClickRadialState>>,
mut settings: Option<ResMut<SettingsResource>>,
storage_path: Option<Res<SettingsStoragePath>>,
) {
let Some(radial) = radial else { return };
if !radial.is_active() {
return;
}
let Some(s) = settings.as_mut() else { return };
if s.0.shown_radial_menu_tip {
return;
}
s.0.shown_radial_menu_tip = true;
persist(storage_path.as_deref(), &s.0);
}
/// Fires the one-shot radial teach for an engaged player who has never
/// opened the menu themselves.
fn fire_radial_menu_tip(
game: Option<Res<GameStateResource>>,
mut settings: Option<ResMut<SettingsResource>>,
storage_path: Option<Res<SettingsStoragePath>>,
mut toast: MessageWriter<InfoToastEvent>,
) {
let Some(s) = settings.as_mut() else { return };
if s.0.shown_radial_menu_tip {
return;
}
if game
.as_ref()
.is_none_or(|g| g.0.move_count() < RADIAL_TIP_MIN_MOVES || g.0.is_won())
{
return;
}
toast.write(InfoToastEvent(RADIAL_TIP.to_string()));
s.0.shown_radial_menu_tip = true;
persist(storage_path.as_deref(), &s.0);
}
fn persist(storage_path: Option<&SettingsStoragePath>, settings: &solitaire_data::Settings) {
if let Some(p) = storage_path
&& let Some(path) = p.0.as_deref()
&& let Err(e) = save_settings_to(path, settings)
{
warn!("contextual tips: failed to persist tip flag: {e}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::ecs::message::Messages;
use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_data::Settings;
fn app_with(settings: Settings, game: GameState) -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(ContextualTipsPlugin);
app.insert_resource(SettingsResource(settings));
app.insert_resource(GameStateResource(game));
app.update();
app
}
fn info_toast_count(app: &App) -> usize {
let events = app.world().resource::<Messages<InfoToastEvent>>();
let mut cursor = events.get_cursor();
cursor.read(events).count()
}
fn started_game() -> GameState {
let mut game = GameState::new(7, DrawStockConfig::DrawOne);
game.draw().expect("draw from fresh deal");
game
}
#[test]
fn stall_tip_fires_once_past_threshold() {
let mut app = app_with(Settings::default(), started_game());
app.world_mut().resource_mut::<StallClock>().0 = STALL_TIP_SECS + 1.0;
app.update();
assert_eq!(info_toast_count(&app), 1, "stall tip must fire");
assert!(
app.world()
.resource::<SettingsResource>()
.0
.shown_stall_hint_tip,
"the tip flag must set so it never repeats"
);
app.world_mut().resource_mut::<StallClock>().0 = STALL_TIP_SECS + 30.0;
app.world_mut()
.resource_mut::<Messages<InfoToastEvent>>()
.clear();
app.update();
assert_eq!(info_toast_count(&app), 0, "the tip is one-shot");
}
#[test]
fn stall_tip_needs_a_started_unwon_game() {
// Untouched deal: staring at the launch screen is not a stall.
let mut app = app_with(
Settings::default(),
GameState::new(7, DrawStockConfig::DrawOne),
);
app.world_mut().resource_mut::<StallClock>().0 = STALL_TIP_SECS + 1.0;
app.update();
assert_eq!(info_toast_count(&app), 0, "no tip on an untouched deal");
// Won game: nothing to hint at.
let mut won = started_game();
won.set_test_won(true);
let mut app = app_with(Settings::default(), won);
app.world_mut().resource_mut::<StallClock>().0 = STALL_TIP_SECS + 1.0;
app.update();
assert_eq!(info_toast_count(&app), 0, "no tip on a won game");
}
#[test]
fn stall_clock_resets_on_state_change() {
let mut app = app_with(Settings::default(), started_game());
app.world_mut().resource_mut::<StallClock>().0 = 30.0;
app.world_mut().write_message(StateChangedEvent);
app.update();
assert_eq!(
app.world().resource::<StallClock>().0,
0.0,
"any board change must reset the stall clock"
);
}
#[test]
fn radial_tip_fires_for_engaged_player() {
let mut game = started_game();
game.set_test_move_count(RADIAL_TIP_MIN_MOVES);
let mut app = app_with(Settings::default(), game);
app.update();
assert_eq!(
info_toast_count(&app),
1,
"radial tip must fire at the move threshold"
);
assert!(
app.world()
.resource::<SettingsResource>()
.0
.shown_radial_menu_tip
);
}
#[test]
fn radial_tip_suppressed_after_organic_use() {
let mut game = started_game();
game.set_test_move_count(RADIAL_TIP_MIN_MOVES);
let mut app = app_with(
Settings {
shown_radial_menu_tip: true,
..Settings::default()
},
game,
);
app.update();
assert_eq!(
info_toast_count(&app),
0,
"a player who used the radial menu never sees the teach"
);
}
}
+7 -15
View File
@@ -14,19 +14,17 @@ use crate::platform::{
}; };
use crate::{ use crate::{
AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin,
CardAnimationPlugin, CardPlugin, ChallengePlugin, CheatSheetPlugin, ContextualTipsPlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin,
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin,
PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin, SelectionPlugin, SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncProvider,
SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin, StatsPlugin, SyncProvider, TablePlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin,
ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin, UiFocusPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WhatsNewPlugin, WinSummaryPlugin,
}; };
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use crate::{ use crate::{
AnalyticsPlugin, AudioPlugin, AvatarPlugin, LeaderboardPlugin, SyncPlugin, SyncSetupPlugin, AnalyticsPlugin, AudioPlugin, AvatarPlugin, LeaderboardPlugin, SyncPlugin, SyncSetupPlugin,
ThemeStorePlugin,
}; };
/// Groups all Ferrous Solitaire gameplay plugins. /// Groups all Ferrous Solitaire gameplay plugins.
@@ -94,7 +92,6 @@ impl Plugin for CoreGamePlugin {
.add_plugins(FeedbackAnimPlugin) .add_plugins(FeedbackAnimPlugin)
.add_plugins(CardAnimationPlugin) .add_plugins(CardAnimationPlugin)
.add_plugins(AutoCompletePlugin) .add_plugins(AutoCompletePlugin)
.add_plugins(SolutionPlaybackPlugin)
.add_plugins(ReplayPlaybackPlugin) .add_plugins(ReplayPlaybackPlugin)
.add_plugins(ReplayOverlayPlugin) .add_plugins(ReplayOverlayPlugin)
.add_plugins(StatsPlugin::default()) .add_plugins(StatsPlugin::default())
@@ -111,13 +108,9 @@ impl Plugin for CoreGamePlugin {
.add_plugins(HelpPlugin) .add_plugins(HelpPlugin)
.add_plugins(HomePlugin::default()) .add_plugins(HomePlugin::default())
.add_plugins(ProfilePlugin) .add_plugins(ProfilePlugin)
.add_plugins(crate::you_hub_plugin::YouHubPlugin)
.add_plugins(PausePlugin) .add_plugins(PausePlugin)
.add_plugins(SettingsPlugin::default()) .add_plugins(SettingsPlugin::default())
.add_plugins(OnboardingPlugin) .add_plugins(OnboardingPlugin)
.add_plugins(WhatsNewPlugin)
.add_plugins(ContextualTipsPlugin)
.add_plugins(CheatSheetPlugin)
.add_plugins(WinSummaryPlugin) .add_plugins(WinSummaryPlugin)
.add_plugins(UiModalPlugin) .add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin) .add_plugins(UiFocusPlugin)
@@ -133,7 +126,6 @@ impl Plugin for CoreGamePlugin {
.add_plugins(AudioPlugin) .add_plugins(AudioPlugin)
.add_plugins(SyncPlugin::new(sync_provider)) .add_plugins(SyncPlugin::new(sync_provider))
.add_plugins(SyncSetupPlugin) .add_plugins(SyncSetupPlugin)
.add_plugins(ThemeStorePlugin)
.add_plugins(AnalyticsPlugin) .add_plugins(AnalyticsPlugin)
.add_plugins(LeaderboardPlugin); .add_plugins(LeaderboardPlugin);
} }
+11 -30
View File
@@ -35,8 +35,8 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon}; use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
use solitaire_core::Card; use solitaire_core::Card;
use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{DrawStockConfig, game_state::GameState};
use crate::card_plugin::RightClickHighlight; use crate::card_plugin::RightClickHighlight;
use crate::layout::{Layout, LayoutResource}; use crate::layout::{Layout, LayoutResource};
@@ -76,31 +76,12 @@ pub struct CursorPlugin;
impl Plugin for CursorPlugin { impl Plugin for CursorPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
// All three read the DragState/game state the input chain and
// mutation systems settle, so they run after both. The highlight
// painters write card/overlay Sprites and join the visual spine
// ahead of the board painters; the cursor icon touches only the
// window cursor (#143).
app.add_systems( app.add_systems(
Update, Update,
( (
update_cursor_icon update_cursor_icon,
.after(crate::input_plugin::PointerInput) update_drop_highlights.run_if(resource_changed::<DragState>),
.after(crate::game_plugin::GameMutation) update_drop_target_overlays,
.after(crate::selection_plugin::SelectionFx)
.before(crate::card_plugin::BoardVisuals),
(
update_drop_highlights.run_if(resource_changed::<DragState>),
update_drop_target_overlays,
)
.chain()
.after(crate::input_plugin::PointerInput)
.after(crate::game_plugin::GameMutation)
// Selection maintenance clears DragState and the hint
// poll writes highlight sprites — both settle first.
.after(crate::selection_plugin::SelectionFx)
.after(crate::pending_hint::PendingHint)
.before(crate::card_plugin::BoardVisuals),
), ),
); );
} }
@@ -456,8 +437,7 @@ fn tableau_or_stack_pos(
base.x, base.x,
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32), base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
) )
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree } else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree {
{
let pile_len = game.waste_cards().len(); let pile_len = game.waste_cards().len();
let visible_start = pile_len.saturating_sub(3); let visible_start = pile_len.saturating_sub(3);
let slot = index.saturating_sub(visible_start) as f32; let slot = index.saturating_sub(visible_start) as f32;
@@ -601,10 +581,7 @@ mod tests {
use crate::layout::compute_layout; use crate::layout::compute_layout;
use solitaire_core::{Card, Deck, Rank, Suit}; use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::{ use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
DrawStockConfig,
game_state::{GameMode, GameState},
};
/// Builds an `App` with `MinimalPlugins` and the overlay system /// Builds an `App` with `MinimalPlugins` and the overlay system
/// registered, plus the resources the system needs. Callers /// registered, plus the resources the system needs. Callers
@@ -653,7 +630,11 @@ mod tests {
// — same colour family, illegal. Tableau(2) must NOT be // — same colour family, illegal. Tableau(2) must NOT be
// highlighted. // highlighted.
let mut game = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Classic); let mut game = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Classic);
set_tableau_top(&mut game, 2, Card::new(Deck::Deck1, Suit::Clubs, Rank::Six)); set_tableau_top(
&mut game,
2,
Card::new(Deck::Deck1, Suit::Clubs, Rank::Six),
);
let dragged = Card::new(Deck::Deck1, Suit::Spades, Rank::Five); let dragged = Card::new(Deck::Deck1, Suit::Spades, Rank::Five);
let mut app = overlay_test_app(game); let mut app = overlay_test_app(game);
+9 -43
View File
@@ -24,8 +24,8 @@ use crate::events::{
GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartDailyChallengeRequestEvent, GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartDailyChallengeRequestEvent,
WarningToastEvent, XpAwardedEvent, WarningToastEvent, XpAwardedEvent,
}; };
use crate::game_plugin::GameMutation;
use crate::progress_plugin::{ProgressResource, ProgressStoragePath}; use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use crate::sync_plugin::SyncProviderResource; use crate::sync_plugin::SyncProviderResource;
@@ -124,51 +124,17 @@ impl Plugin for DailyChallengePlugin {
.add_message::<StartDailyChallengeRequestEvent>() .add_message::<StartDailyChallengeRequestEvent>()
.add_message::<WarningToastEvent>() .add_message::<WarningToastEvent>()
.add_message::<XpAwardedEvent>() .add_message::<XpAwardedEvent>()
// Ordering comes from the mode-completion chain configured in // record/award after the base ProgressUpdate so we don't fight
// progress_plugin; the toast it emits joins the writer club // ProgressPlugin's add_xp on the same frame.
// drained by the toast pipeline this frame (#143). .add_systems(Update, handle_daily_completion.after(ProgressUpdate))
.add_systems( .add_systems(Update, handle_start_daily_request.before(GameMutation))
Update, .add_systems(Update, check_daily_expiry_warning)
handle_daily_completion .add_systems(Update, check_date_rollover);
.in_set(crate::progress_plugin::DailyCompletion)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
)
// ModeStart places it between the stats abandon-recorder and
// GameMutation; it shares the new-game request/toast streams
// with the other writers (#143).
.add_systems(
Update,
handle_start_daily_request
.in_set(crate::game_plugin::ModeStart)
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
)
// One chain for the DailyChallengeResource writers/readers:
// the local date rollover replaces today's challenge and the
// expiry warning reads it. Before ModeStart so a rollover
// lands before the start handler reads the seed this frame
// (#143).
.add_systems(
Update,
(
check_date_rollover,
check_daily_expiry_warning
.in_set(crate::game_plugin::WarningToastWriters)
.ambiguous_with(crate::game_plugin::WarningToastWriters),
)
.chain()
.before(crate::game_plugin::ModeStart),
);
// Server-challenge fetch uses SyncProviderResource (reqwest), not available on wasm. // Server-challenge fetch uses SyncProviderResource (reqwest), not available on wasm.
// The poll replaces DailyChallengeResource, so it heads the same
// pre-ModeStart timeline as the rollover/expiry chain above (#143).
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
app.add_systems(Startup, fetch_server_challenge) app.add_systems(Startup, fetch_server_challenge)
.add_systems(Update, poll_server_challenge.before(check_date_rollover)); .add_systems(Update, poll_server_challenge);
} }
} }
+3 -14
View File
@@ -21,6 +21,7 @@ use solitaire_core::game_state::{DifficultyLevel, GameMode};
use solitaire_data::difficulty_seeds::seeds_for; use solitaire_data::difficulty_seeds::seeds_for;
use crate::events::{NewGameRequestEvent, StartDifficultyRequestEvent}; use crate::events::{NewGameRequestEvent, StartDifficultyRequestEvent};
use crate::game_plugin::GameMutation;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Resources // Resources
@@ -53,10 +54,7 @@ impl DifficultyIndexResource {
DifficultyLevel::Hard => &mut self.hard, DifficultyLevel::Hard => &mut self.hard,
DifficultyLevel::Expert => &mut self.expert, DifficultyLevel::Expert => &mut self.expert,
DifficultyLevel::Grandmaster => &mut self.grandmaster, DifficultyLevel::Grandmaster => &mut self.grandmaster,
// Random has no catalog today, so seeds_for() already returned DifficultyLevel::Random => unreachable!("Random has no catalog"),
// None above; if it ever gains one, a time seed is still the
// right answer for "Random" — never a reachable panic.
DifficultyLevel::Random => return seed_from_system_time(),
}; };
let seed = catalog[*cursor % catalog.len()]; let seed = catalog[*cursor % catalog.len()];
*cursor = cursor.wrapping_add(1); *cursor = cursor.wrapping_add(1);
@@ -76,16 +74,7 @@ impl Plugin for DifficultyPlugin {
app.init_resource::<DifficultyIndexResource>() app.init_resource::<DifficultyIndexResource>()
.add_message::<StartDifficultyRequestEvent>() .add_message::<StartDifficultyRequestEvent>()
.add_message::<NewGameRequestEvent>() .add_message::<NewGameRequestEvent>()
// ModeStart places it between the stats abandon-recorder and .add_systems(Update, handle_difficulty_request.before(GameMutation));
// GameMutation; it shares the new-game request stream with the
// other writers (#143).
.add_systems(
Update,
handle_difficulty_request
.in_set(crate::game_plugin::ModeStart)
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
);
} }
} }
+20 -29
View File
@@ -2,9 +2,10 @@
use bevy::prelude::Message; use bevy::prelude::Message;
use solitaire_core::KlondikePile; use solitaire_core::KlondikePile;
use solitaire_core::game_state::GameMode;
use solitaire_core::{Card, Suit}; use solitaire_core::{Card, Suit};
use solitaire_core::game_state::GameMode;
use solitaire_data::AchievementRecord; use solitaire_data::AchievementRecord;
use solitaire_sync::SyncResponse;
/// Request to move `count` cards from `from` to `to`. Fired by input systems, /// Request to move `count` cards from `from` to `to`. Fired by input systems,
/// consumed by `GamePlugin`. /// consumed by `GamePlugin`.
@@ -133,12 +134,6 @@ pub struct ManualSyncRequestEvent;
#[derive(Message, Debug, Clone, Copy, Default)] #[derive(Message, Debug, Clone, Copy, Default)]
pub struct SyncConfigureRequestEvent; pub struct SyncConfigureRequestEvent;
/// Request to open the in-game theme-store modal. Fired by the
/// "Browse theme store" button in the Settings cosmetic section;
/// handled by `theme_store_plugin`.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ThemeStoreOpenRequestEvent;
/// Request to disconnect from the current sync backend, clear stored /// Request to disconnect from the current sync backend, clear stored
/// credentials, and reset to `SyncBackend::Local`. Fired by the "Disconnect" /// credentials, and reset to `SyncBackend::Local`. Fired by the "Disconnect"
/// button in the Settings sync section. /// button in the Settings sync section.
@@ -151,18 +146,6 @@ pub struct SyncLogoutRequestEvent;
#[derive(Message, Debug, Clone, Copy, Default)] #[derive(Message, Debug, Clone, Copy, Default)]
pub struct DeleteAccountRequestEvent; pub struct DeleteAccountRequestEvent;
/// Request to export every JSON save into one local backup bundle
/// (Phase M — sync transparency). Fired by the Account tab's "Export
/// data" button; handled in the `Last` schedule by `settings_plugin`.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct DataExportRequestEvent;
/// Request to restore every JSON save from the local backup bundle
/// (Phase M — sync transparency). Fired by the Account tab's "Import
/// data" button; handled in the `Last` schedule by `settings_plugin`.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct DataImportRequestEvent;
/// Request to toggle the pause overlay. Fired by the HUD "Pause" button so /// Request to toggle the pause overlay. Fired by the HUD "Pause" button so
/// the same toggle path runs whether the player presses `Esc` or clicks. /// the same toggle path runs whether the player presses `Esc` or clicks.
/// Consumed by `pause_plugin::toggle_pause`, which honours the same drag / /// Consumed by `pause_plugin::toggle_pause`, which honours the same drag /
@@ -170,12 +153,6 @@ pub struct DataImportRequestEvent;
#[derive(Message, Debug, Clone, Copy, Default)] #[derive(Message, Debug, Clone, Copy, Default)]
pub struct PauseRequestEvent; pub struct PauseRequestEvent;
/// Request to solve the current deal and auto-play the winning line.
/// Fired by the pause menu's "Show solution" button; consumed by
/// `solution_playback_plugin`.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ShowSolutionRequestEvent;
/// Request to toggle the help / controls overlay. Fired by the HUD "Help" /// Request to toggle the help / controls overlay. Fired by the HUD "Help"
/// button alongside the existing `F1` accelerator so the overlay is /// button alongside the existing `F1` accelerator so the overlay is
/// reachable without a keyboard. Consumed by `help_plugin::toggle_help_screen`. /// reachable without a keyboard. Consumed by `help_plugin::toggle_help_screen`.
@@ -254,10 +231,15 @@ pub struct ToggleSettingsRequestEvent;
#[derive(Message, Debug, Clone, Copy, Default)] #[derive(Message, Debug, Clone, Copy, Default)]
pub struct ToggleLeaderboardRequestEvent; pub struct ToggleLeaderboardRequestEvent;
/// Request to toggle the Home mode launcher. Fired by the HUD /// Fired by `SyncPlugin` after a pull task resolves and the merged result has
/// Menu-popover "Home" row alongside the existing `M` accelerator. /// been persisted to disk. `Ok(SyncResponse)` carries the merged payload plus
#[derive(Message, Debug, Clone, Copy, Default)] /// any `ConflictReport`s the merge produced. `Err(String)` carries a
pub struct ToggleHomeRequestEvent; /// human-readable failure message (network, auth, serialization, etc.).
///
/// UI systems listen for this to refresh views without polling
/// `SyncStatusResource`. See [ARCHITECTURE.md §4](../../ARCHITECTURE.md).
#[derive(Message, Debug, Clone)]
pub struct SyncCompleteEvent(pub Result<SyncResponse, String>);
/// Generic informational toast message. Any system can fire this to display /// Generic informational toast message. Any system can fire this to display
/// a short string to the player, e.g. "Locked — reach level 5". /// a short string to the player, e.g. "Locked — reach level 5".
@@ -301,6 +283,15 @@ pub struct ForfeitEvent;
#[derive(Message, Debug, Clone, Copy, Default)] #[derive(Message, Debug, Clone, Copy, Default)]
pub struct ForfeitRequestEvent; pub struct ForfeitRequestEvent;
/// Fired when the player clicks "Scan for new themes" in Settings.
///
/// Consumed by `handle_scan_themes` in `SettingsPlugin`, which scans
/// `user_theme_dir()` for `.zip` files, calls `import_theme()` on each
/// unrecognised archive, refreshes [`crate::theme::ThemeRegistry`], and
/// fires [`InfoToastEvent`] messages to report results.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ScanThemesRequestEvent;
/// Fired when the player requests a hint (H key). Carries the source card ID /// Fired when the player requests a hint (H key). Carries the source card ID
/// and destination pile for visual highlighting. /// and destination pile for visual highlighting.
/// ///
+11 -383
View File
@@ -49,11 +49,10 @@ use solitaire_core::klondike_adapter::foundation_from_slot;
use solitaire_data::AnimSpeed; use solitaire_data::AnimSpeed;
use crate::animation_plugin::CardAnim; use crate::animation_plugin::CardAnim;
use crate::card_animation::{MotionCurve, sample_curve}; use crate::card_plugin::CardEntity;
use crate::card_plugin::{CardEntity, CardEntityIndex};
use crate::events::{ use crate::events::{
DrawRequestEvent, FoundationCompletedEvent, HintVisualEvent, MoveRejectedEvent, DrawRequestEvent, FoundationCompletedEvent, MoveRejectedEvent, MoveRequestEvent,
MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, NewGameRequestEvent,
}; };
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource; use crate::layout::LayoutResource;
@@ -198,22 +197,6 @@ pub fn deal_stagger_jitter(card_id: u32) -> f32 {
/// animation systems. /// animation systems.
pub struct FeedbackAnimPlugin; pub struct FeedbackAnimPlugin;
/// Set wrapping the shake/settle/deal/flourish systems. Last anim stage on
/// the visual spine: …MarkerVisuals < CardTweenFx <
/// [`crate::animation_plugin::AnimFx`] < FeedbackFx. Runs after
/// MarkerVisuals because the foundation flourish tints pile-marker sprites,
/// which the marker painters also write; the tint must win within the frame
/// (#143). GameMutation ordering is inherited transitively.
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct FeedbackFx;
/// Set wrapping the hint-ghost chain (spawn → tick → despawn). Ghost
/// entities are `Without<CardEntity>` sprites owned solely by this chain —
/// systems that animate other entity domains (e.g. the win-summary camera
/// shake) declare themselves `.ambiguous_with(HintGhostFx)` (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct HintGhostFx;
impl Plugin for FeedbackAnimPlugin { impl Plugin for FeedbackAnimPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
// Register the events this plugin consumes so it can run in isolation // Register the events this plugin consumes so it can run in isolation
@@ -224,70 +207,23 @@ impl Plugin for FeedbackAnimPlugin {
.add_message::<MoveRejectedEvent>() .add_message::<MoveRejectedEvent>()
.add_message::<NewGameRequestEvent>() .add_message::<NewGameRequestEvent>()
.add_message::<FoundationCompletedEvent>() .add_message::<FoundationCompletedEvent>()
.add_message::<HintVisualEvent>()
.add_message::<StateChangedEvent>()
.add_message::<RequestRedraw>() .add_message::<RequestRedraw>()
.configure_sets(
Update,
FeedbackFx
.after(GameMutation)
.after(crate::animation_plugin::AnimFx)
// HUD text pulses are disjoint from the card/marker
// entities this set animates (see UiTextFx ×
// BoardVisuals, #143).
.ambiguous_with(crate::ui_theme::UiTextFx),
)
.add_systems( .add_systems(
Update, Update,
( (
// Reads only the reduce-motion flag; a frame of lag is start_shake_anim.after(GameMutation),
// invisible (#143).
start_shake_anim.ambiguous_with(crate::settings_plugin::SettingsAccess),
tick_shake_anim, tick_shake_anim,
start_settle_anim, start_settle_anim.after(GameMutation),
// tick_foundation_flourish writes the full Transform.scale // tick_foundation_flourish writes the full Transform.scale
// (Vec3); tick_settle_anim writes only scale.y on top of // (Vec3); tick_settle_anim writes only scale.y on top of
// it. Ordering ensures the settle's y-only write always // it. Ordering ensures the settle's y-only write always
// applies last so it wins on the ~0.15 s overlap when both // applies last so it wins on the ~0.15 s overlap when both
// components are present on the same King entity. // components are present on the same King entity.
tick_foundation_flourish, tick_foundation_flourish.before(tick_settle_anim),
tick_settle_anim, tick_settle_anim,
start_deal_anim, start_deal_anim.after(GameMutation),
start_foundation_flourish, start_foundation_flourish.after(GameMutation),
) ),
.chain()
.in_set(FeedbackFx),
)
// Hint ghost (Phase H): the spawn reads card Transform/Sprite,
// so it orders after the board painters; the tick only touches
// ghost entities (Without<CardEntity>) and stays conflict-free.
.add_systems(
Update,
(
spawn_hint_ghost
.after(GameMutation)
.after(crate::card_plugin::BoardVisuals),
tick_hint_ghosts,
despawn_hint_ghosts_on_state_change.after(GameMutation),
)
.chain()
.in_set(HintGhostFx)
// The spawn reads card Sprites the marker painters (theme
// apply, pile highlights) also write — those settle first.
// Ghost entities are Without<CardEntity> and never HUD
// chrome, and the spawn reads only the reduce-motion flag
// (frame-lag invisible) (#143).
.after(crate::table_plugin::MarkerVisuals)
// …and the card tweens/slides, whose Transform writes
// the spawn snapshot must not race.
.after(crate::card_animation::CardTweenFx)
// FeedbackFx (deal slide, shake) is the last Transform
// writer on the spine — the ghost snapshot reads after
// the whole visual pipeline has settled.
.after(FeedbackFx)
.ambiguous_with(crate::ui_theme::UiTextFx)
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
); );
} }
} }
@@ -340,16 +276,10 @@ fn tick_shake_anim(
time: Res<Time>, time: Res<Time>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
mut anims: Query<(Entity, &mut Transform, &mut ShakeAnim)>, mut anims: Query<(Entity, &mut Transform, &mut ShakeAnim)>,
mut redraw: MessageWriter<RequestRedraw>,
) { ) {
if paused.is_some_and(|p| p.0) { if paused.is_some_and(|p| p.0) {
return; return;
} }
// Sustain full-rate frames for the shake under Android's
// reactive_low_power focused_mode.
if !anims.is_empty() {
redraw.write(RequestRedraw);
}
let dt = time.delta_secs(); let dt = time.delta_secs();
for (entity, mut transform, mut anim) in &mut anims { for (entity, mut transform, mut anim) in &mut anims {
anim.elapsed += dt; anim.elapsed += dt;
@@ -426,16 +356,10 @@ fn tick_settle_anim(
time: Res<Time>, time: Res<Time>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
mut anims: Query<(Entity, &mut Transform, &mut SettleAnim)>, mut anims: Query<(Entity, &mut Transform, &mut SettleAnim)>,
mut redraw: MessageWriter<RequestRedraw>,
) { ) {
if paused.is_some_and(|p| p.0) { if paused.is_some_and(|p| p.0) {
return; return;
} }
// Sustain full-rate frames for the settle bounce under Android's
// reactive_low_power focused_mode.
if !anims.is_empty() {
redraw.write(RequestRedraw);
}
let dt = time.delta_secs(); let dt = time.delta_secs();
for (entity, mut transform, mut anim) in &mut anims { for (entity, mut transform, mut anim) in &mut anims {
anim.elapsed += dt; anim.elapsed += dt;
@@ -657,16 +581,10 @@ fn tick_foundation_flourish(
(Entity, &mut Sprite, &mut FoundationMarkerFlourish), (Entity, &mut Sprite, &mut FoundationMarkerFlourish),
Without<FoundationFlourish>, Without<FoundationFlourish>,
>, >,
mut redraw: MessageWriter<RequestRedraw>,
) { ) {
if paused.is_some_and(|p| p.0) { if paused.is_some_and(|p| p.0) {
return; return;
} }
// Sustain full-rate frames for the flourish under Android's
// reactive_low_power focused_mode.
if !card_anims.is_empty() || !marker_anims.is_empty() {
redraw.write(RequestRedraw);
}
let dt = time.delta_secs(); let dt = time.delta_secs();
// Advance the King's scale pulse. // Advance the King's scale pulse.
@@ -728,147 +646,6 @@ fn pile_cards(
} }
} }
// ---------------------------------------------------------------------------
// Phase H — hint ghost-motion preview
// ---------------------------------------------------------------------------
/// Duration of one ghost glide from the hinted card to its destination.
const HINT_GHOST_PASS_SECS: f32 = 0.7;
/// How many glides one hint plays before the ghost despawns. Two reads
/// as "this move, over there" without outstaying the 2 s static
/// highlight it accompanies.
const HINT_GHOST_PASSES: f32 = 2.0;
/// Ghost translucency — clearly a projection, never mistakable for the
/// real card.
const HINT_GHOST_ALPHA: f32 = 0.45;
/// Ghost render depth: above every settled pile (~1.04 max) and the
/// in-flight `CardAnim` lift (50), below a dragged card (500).
const HINT_GHOST_Z: f32 = 400.0;
/// A translucent copy of the hinted card gliding to the suggested
/// destination (Phase H). Purely decorative — despawned by timer, by a
/// newer hint, or by any state change.
#[derive(Component, Debug)]
pub struct HintGhost {
start: Vec3,
target: Vec3,
elapsed: f32,
}
/// Normalised progress of the current glide pass, restarting from the
/// source each pass. Pure for unit testing.
fn hint_ghost_pass_t(elapsed: f32) -> f32 {
(elapsed % HINT_GHOST_PASS_SECS) / HINT_GHOST_PASS_SECS
}
/// Ghost alpha at `pass_t` — full strength for most of the glide, then
/// fading over the last 20 % so the loop restart reads as a repeat
/// rather than a teleport. Pure for unit testing.
fn hint_ghost_alpha(pass_t: f32) -> f32 {
let fade_in_tail = ((pass_t - 0.8) / 0.2).clamp(0.0, 1.0);
HINT_GHOST_ALPHA * (1.0 - fade_in_tail)
}
/// Spawns the ghost when a hint fires. The static highlights (source
/// card + gold destination pile) still spawn regardless; under
/// reduce-motion they are the whole story and no ghost appears
/// (`design-system.md` §Accessibility).
#[allow(clippy::too_many_arguments)]
fn spawn_hint_ghost(
mut events: MessageReader<HintVisualEvent>,
settings: Option<Res<SettingsResource>>,
index: Option<Res<CardEntityIndex>>,
layout: Option<Res<LayoutResource>>,
cards: Query<(&Transform, &Sprite), With<CardEntity>>,
existing: Query<Entity, With<HintGhost>>,
mut commands: Commands,
) {
if events.is_empty() {
return;
}
if settings.is_some_and(|s| s.0.reduce_motion_mode) {
events.clear();
return;
}
let (Some(index), Some(layout)) = (index, layout) else {
events.clear();
return;
};
for ev in events.read() {
// A fresh hint replaces any ghost still in flight.
for entity in &existing {
commands.entity(entity).despawn();
}
let Some(card_entity) = index.get(&ev.source_card) else {
continue;
};
let Ok((transform, sprite)) = cards.get(card_entity) else {
continue;
};
let Some(&dest) = layout.0.pile_positions.get(&ev.dest_pile) else {
continue;
};
let start = transform.translation.truncate().extend(HINT_GHOST_Z);
let mut ghost_sprite = sprite.clone();
ghost_sprite.color = ghost_sprite.color.with_alpha(HINT_GHOST_ALPHA);
commands.spawn((
HintGhost {
start,
target: dest.extend(HINT_GHOST_Z),
elapsed: 0.0,
},
ghost_sprite,
Transform::from_translation(start),
));
}
}
/// Advances every ghost: eased glide per pass, tail fade, despawn after
/// [`HINT_GHOST_PASSES`]. Frozen while paused, like every other
/// decorative animation.
#[allow(clippy::type_complexity)]
fn tick_hint_ghosts(
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut ghosts: Query<
(Entity, &mut HintGhost, &mut Transform, &mut Sprite),
(Without<CardEntity>, Without<PileMarker>),
>,
mut commands: Commands,
) {
if paused.is_some_and(|p| p.0) {
return;
}
let dt = time.delta_secs();
for (entity, mut ghost, mut transform, mut sprite) in ghosts.iter_mut() {
ghost.elapsed += dt;
if ghost.elapsed >= HINT_GHOST_PASS_SECS * HINT_GHOST_PASSES {
commands.entity(entity).despawn();
continue;
}
let pass_t = hint_ghost_pass_t(ghost.elapsed);
let eased = sample_curve(MotionCurve::SmoothSnap, pass_t);
transform.translation = ghost.start.lerp(ghost.target, eased);
sprite.color = sprite.color.with_alpha(hint_ghost_alpha(pass_t));
}
}
/// A ghost previews a move against the *current* board; the moment the
/// board changes (move, undo, draw, new game) it is stale and vanishes.
fn despawn_hint_ghosts_on_state_change(
mut events: MessageReader<StateChangedEvent>,
ghosts: Query<Entity, With<HintGhost>>,
mut commands: Commands,
) {
if events.is_empty() {
return;
}
events.clear();
for entity in &ghosts {
commands.entity(entity).despawn();
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Unit tests (pure functions only — no Bevy world required) // Unit tests (pure functions only — no Bevy world required)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1075,10 +852,7 @@ mod tests {
let mut app = App::new(); let mut app = App::new();
app.add_plugins(MinimalPlugins) app.add_plugins(MinimalPlugins)
.add_plugins(FeedbackAnimPlugin); .add_plugins(FeedbackAnimPlugin);
app.insert_resource(GameStateResource(GameState::new( app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
1,
DrawStockConfig::DrawOne,
)));
app.insert_resource(SettingsResource(Settings { app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true, reduce_motion_mode: true,
..Settings::default() ..Settings::default()
@@ -1132,10 +906,7 @@ mod tests {
let mut app = App::new(); let mut app = App::new();
app.add_plugins(MinimalPlugins) app.add_plugins(MinimalPlugins)
.add_plugins(FeedbackAnimPlugin); .add_plugins(FeedbackAnimPlugin);
app.insert_resource(GameStateResource(GameState::new( app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
1,
DrawStockConfig::DrawOne,
)));
app.insert_resource(SettingsResource(Settings { app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true, reduce_motion_mode: true,
..Settings::default() ..Settings::default()
@@ -1160,147 +931,4 @@ mod tests {
"FoundationFlourish must not be inserted under reduce-motion" "FoundationFlourish must not be inserted under reduce-motion"
); );
} }
// -----------------------------------------------------------------------
// Phase H — hint ghost-motion preview
// -----------------------------------------------------------------------
#[test]
fn hint_ghost_pass_t_restarts_each_pass() {
assert_eq!(hint_ghost_pass_t(0.0), 0.0);
// Just past one full pass, progress wraps back near zero.
assert!(hint_ghost_pass_t(HINT_GHOST_PASS_SECS + 0.01) < 0.1);
}
#[test]
fn hint_ghost_alpha_fades_only_in_the_tail() {
assert_eq!(hint_ghost_alpha(0.0), HINT_GHOST_ALPHA);
assert_eq!(hint_ghost_alpha(0.79), HINT_GHOST_ALPHA);
assert!(hint_ghost_alpha(0.9) < HINT_GHOST_ALPHA);
assert!(hint_ghost_alpha(1.0).abs() < 1e-6);
}
/// App with one indexed card entity and a real layout, ready to
/// receive `HintVisualEvent`s.
fn ghost_app(reduce_motion: bool) -> (App, Card) {
use solitaire_core::{Deck, Rank, Suit};
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(FeedbackAnimPlugin);
// Sibling systems in the plugin take GameStateResource
// non-optionally; give them a fresh deal.
app.insert_resource(GameStateResource(
solitaire_core::game_state::GameState::new(1, solitaire_core::DrawStockConfig::DrawOne),
));
app.insert_resource(SettingsResource(solitaire_data::Settings {
reduce_motion_mode: reduce_motion,
..Default::default()
}));
let card = Card::new(Deck::Deck1, Suit::Spades, Rank::Ace);
let entity = app
.world_mut()
.spawn((
CardEntity { card: card.clone() },
Sprite::default(),
Transform::from_xyz(-100.0, 40.0, 1.0),
))
.id();
let mut index = CardEntityIndex::default();
index.0.insert(card.clone(), entity);
app.insert_resource(index);
app.insert_resource(LayoutResource(crate::layout::compute_layout(
Vec2::new(1280.0, 720.0),
0.0,
0.0,
true,
)));
app.update();
(app, card)
}
fn fire_hint(app: &mut App, card: Card) {
use solitaire_core::Tableau;
app.world_mut().write_message(HintVisualEvent {
source_card: card,
dest_pile: KlondikePile::Tableau(Tableau::Tableau2),
});
app.update();
}
fn ghost_count(app: &mut App) -> usize {
app.world_mut()
.query::<&HintGhost>()
.iter(app.world())
.count()
}
#[test]
fn hint_event_spawns_one_ghost_at_the_source_card() {
let (mut app, card) = ghost_app(false);
fire_hint(&mut app, card);
assert_eq!(ghost_count(&mut app), 1, "hint must spawn one ghost");
let transform = app
.world_mut()
.query_filtered::<&Transform, With<HintGhost>>()
.single(app.world())
.expect("ghost transform");
// The tick system may have advanced the ghost by one real-time
// frame already; assert it is still essentially at the source.
assert!(
transform
.translation
.truncate()
.distance(Vec2::new(-100.0, 40.0))
< 5.0,
"ghost must start at the hinted card, got {:?}",
transform.translation
);
assert_eq!(transform.translation.z, HINT_GHOST_Z);
}
#[test]
fn hint_ghost_suppressed_under_reduce_motion() {
let (mut app, card) = ghost_app(true);
fire_hint(&mut app, card);
assert_eq!(
ghost_count(&mut app),
0,
"no ghost may spawn under reduce-motion; the static highlight is the whole story"
);
}
#[test]
fn state_change_despawns_the_ghost() {
let (mut app, card) = ghost_app(false);
fire_hint(&mut app, card);
assert_eq!(ghost_count(&mut app), 1);
app.world_mut().write_message(StateChangedEvent);
app.update();
app.update();
assert_eq!(
ghost_count(&mut app),
0,
"a board change makes the previewed move stale — the ghost must vanish"
);
}
#[test]
fn second_hint_replaces_the_first_ghost() {
let (mut app, card) = ghost_app(false);
fire_hint(&mut app, card.clone());
fire_hint(&mut app, card);
app.update();
assert_eq!(
ghost_count(&mut app),
1,
"re-hinting must replace the in-flight ghost, not stack a second"
);
}
} }
+7 -80
View File
@@ -14,13 +14,8 @@ use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use bevy::window::AppLifecycle; use bevy::window::AppLifecycle;
use solitaire_core::KlondikePile; use solitaire_core::KlondikePile;
use solitaire_core::{ use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction, use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction};
};
use solitaire_core::{
DrawStockConfig,
game_state::{GameMode, GameState},
};
#[allow(deprecated)] #[allow(deprecated)]
use solitaire_data::latest_replay_path; use solitaire_data::latest_replay_path;
use solitaire_data::{ use solitaire_data::{
@@ -86,51 +81,11 @@ pub struct NewGameRequestWriters;
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct UndoRequestWriters; pub struct UndoRequestWriters;
/// Self-ambiguous set for writers of `DrawRequestEvent` — same rationale as
/// [`NewGameRequestWriters`]: consumers drain the queue, append order is
/// meaningless (#143). Members: keyboard `D`/`Space`, stock click, touch
/// stock tap (input_plugin), and the touch action bar's Draw button
/// (hud_plugin, Phase F).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct DrawRequestWriters;
/// Self-ambiguous set for writers of `InfoToastEvent` — toasts queue in /// Self-ambiguous set for writers of `InfoToastEvent` — toasts queue in
/// arrival order and any same-frame order is fine (#143). /// arrival order and any same-frame order is fine (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct InfoToastWriters; pub struct InfoToastWriters;
/// Self-ambiguous club for writers of `MoveRequestEvent` (drag drops, stock
/// clicks, radial menu, selection keys, replay step button). Consumers drain
/// the whole queue — append order is meaningless (#143). Same rules as
/// [`NewGameRequestWriters`]: do NOT hang ordering edges off this set.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct MoveRequestWriters;
/// Self-ambiguous club for writers of `WarningToastEvent` (daily-expiry
/// warning, leaderboard opt-in/out failure polls). The toast pipeline
/// drains the whole queue — append order is meaningless (#143). Same
/// rules as [`InfoToastWriters`].
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct WarningToastWriters;
/// Set + self-ambiguous club for the mode-start request handlers (challenge,
/// daily, time-attack, play-by-seed, difficulty). Each drains its own
/// request stream and resets `GameStateResource` for its mode; only one
/// stream carries an event on any real frame, so member order is irrelevant
/// (club), while the set as a whole runs between the stats abandon-recorder
/// ([`crate::stats_plugin::AbandonRecord`], which must read the old game
/// first) and [`GameMutation`] (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct ModeStart;
/// Set wrapping `GamePlugin`'s pre-mutation game-state spine (elapsed-time
/// tick, settings sync, restore prompt). Sits between
/// [`crate::settings_plugin::SettingsMutation`] and [`GameMutation`]; other
/// pre-mutation readers of `GameStateResource` (e.g. the stats
/// abandon-recorder) order themselves `.after(PreMutation)` (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct PreMutation;
/// Persistence path for the in-progress game state file. `None` disables I/O. /// Persistence path for the in-progress game state file. `None` disables I/O.
#[derive(Resource, Debug, Clone)] #[derive(Resource, Debug, Clone)]
pub struct GameStatePath(pub Option<PathBuf>); pub struct GameStatePath(pub Option<PathBuf>);
@@ -233,9 +188,7 @@ impl Plugin for GamePlugin {
) )
} else { } else {
( (
saved.unwrap_or_else(|| { saved.unwrap_or_else(|| GameState::new(seed_from_system_time(), DrawStockConfig::DrawOne)),
GameState::new(seed_from_system_time(), DrawStockConfig::DrawOne)
}),
None, None,
) )
}; };
@@ -299,14 +252,7 @@ impl Plugin for GamePlugin {
.in_set(InfoToastWriters) .in_set(InfoToastWriters)
.ambiguous_with(InfoToastWriters), .ambiguous_with(InfoToastWriters),
) )
.add_systems( .add_systems(Update, record_replay_on_win.after(GameMutation))
Update,
record_replay_on_win
.after(GameMutation)
// Before the playback skip-recorder (which runs after
// AutoComplete) — both touch RecordingReplay (#143).
.before(crate::auto_complete_plugin::AutoComplete),
)
.add_systems( .add_systems(
Update, Update,
( (
@@ -340,20 +286,9 @@ impl Plugin for GamePlugin {
.ambiguous_with(NewGameRequestWriters), .ambiguous_with(NewGameRequestWriters),
) )
.chain() .chain()
.in_set(PreMutation),
)
.configure_sets(
Update,
PreMutation
.after(crate::settings_plugin::SettingsMutation) .after(crate::settings_plugin::SettingsMutation)
.before(GameMutation), .before(GameMutation),
) )
.configure_sets(
Update,
ModeStart
.after(crate::stats_plugin::AbandonRecord)
.before(GameMutation),
)
.init_resource::<AutoSaveTimer>() .init_resource::<AutoSaveTimer>()
.add_systems(Update, auto_save_game_state.after(GameMutation)) .add_systems(Update, auto_save_game_state.after(GameMutation))
.add_systems(Last, save_game_state_on_exit); .add_systems(Last, save_game_state_on_exit);
@@ -1120,16 +1055,11 @@ pub fn record_replay_on_win(
if recording.moves.is_empty() { if recording.moves.is_empty() {
continue; continue;
} }
// The session itself is the authoritative recording: its history
// holds the dealt board plus the forward instruction list (undos
// already popped), and it serialises via the upstream card_game
// serializers so playback never re-deals from the seed.
let session_recording = game.0.recording();
// Recording freezes on win, so the move that triggered the // Recording freezes on win, so the move that triggered the
// win condition is the last one in the list. Storing the // win condition is the last one in the list. Storing the
// index explicitly lets the playback UI read the WIN MOVE // index explicitly lets the playback UI read the WIN MOVE
// position directly instead of re-deriving it on every render. // position directly instead of re-deriving it on every render.
let win_move_index = session_recording.len().checked_sub(1); 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(), game.0.draw_mode(),
@@ -1137,7 +1067,7 @@ pub fn record_replay_on_win(
ev.time_seconds, ev.time_seconds,
ev.score, ev.score,
Utc::now().date_naive(), Utc::now().date_naive(),
session_recording, recording.moves.clone(),
) )
.with_win_move_index(win_move_index); .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 {
@@ -1394,10 +1324,7 @@ fn auto_save_game_state(
// or there's a pending restore the player hasn't answered — saving // or there's a pending restore the player hasn't answered — saving
// the fresh-deal placeholder we seeded GameStateResource with at // the fresh-deal placeholder we seeded GameStateResource with at
// startup would clobber the real saved game on disk. // startup would clobber the real saved game on disk.
if paused.is_some_and(|p| p.0) if paused.is_some_and(|p| p.0) || game.0.is_won() || game.0.move_count() == 0 || pending.0.is_some()
|| game.0.is_won()
|| game.0.move_count() == 0
|| pending.0.is_some()
{ {
return; return;
} }
+22 -33
View File
@@ -372,7 +372,9 @@ fn moving_cards_off_face_up_card_does_not_fire_card_flipped_event() {
}); });
app.update(); app.update();
let events = app.world().resource::<Messages<CardFlippedEvent>>(); let events = app
.world()
.resource::<Messages<CardFlippedEvent>>();
let mut cursor = events.get_cursor(); let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).collect(); let fired: Vec<_> = cursor.read(events).collect();
assert!( assert!(
@@ -798,10 +800,7 @@ fn replay_recording_skips_undo() {
1, 1,
"only the draw is recorded; the undo does not erase it nor add a new entry", "only the draw is recorded; the undo does not erase it nor add a new entry",
); );
assert!(matches!( assert!(matches!(recording.moves[0], KlondikeInstruction::RotateStock));
recording.moves[0],
KlondikeInstruction::RotateStock
));
} }
/// Starting a new game wipes the recording so the next deal begins /// Starting a new game wipes the recording so the next deal begins
@@ -853,15 +852,17 @@ fn replay_recording_freezes_into_replay_on_game_won() {
let mut app = test_app(7654); let mut app = test_app(7654);
app.insert_resource(ReplayPath(Some(path.clone()))); app.insert_resource(ReplayPath(Some(path.clone())));
// Drive two real draws so the *session* history (the source the // Push two recorded instructions manually so we can verify they
// freeze now serialises from, via `GameState::recording()`) holds // survive the freeze/save round-trip without having to drive a
// two instructions. `RotateStock` is the only instruction the // real win. Both are `RotateStock` the only instruction
// engine can drive without the runtime-only `klondike` pile-stack // constructible without the runtime-only `klondike` pile-stack
// types; the round-trip shape is identical for any variant. // types (which the engine intentionally does not depend on); the
app.world_mut().write_message(DrawRequestEvent); // round-trip shape is identical for any instruction variant.
app.update(); {
app.world_mut().write_message(DrawRequestEvent); let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
app.update(); recording.moves.push(KlondikeInstruction::RotateStock);
recording.moves.push(KlondikeInstruction::RotateStock);
}
// Fire the win event the engine emits when the last foundation // Fire the win event the engine emits when the last foundation
// completes — `record_replay_on_win` listens for it. // completes — `record_replay_on_win` listens for it.
@@ -871,8 +872,8 @@ fn replay_recording_freezes_into_replay_on_game_won() {
}); });
app.update(); app.update();
let history = let history = load_replay_history_from(&path)
load_replay_history_from(&path).expect("a winning replay must be persisted to ReplayPath"); .expect("a winning replay must be persisted to ReplayPath");
assert_eq!( assert_eq!(
history.replays.len(), history.replays.len(),
1, 1,
@@ -893,15 +894,9 @@ fn replay_recording_freezes_into_replay_on_game_won() {
loaded.time_seconds, 250, loaded.time_seconds, 250,
"time_seconds must come from the win event" "time_seconds must come from the win event"
); );
let instructions = loaded.recording.instructions(); assert_eq!(loaded.moves.len(), 2, "every recorded move must round-trip");
assert_eq!(instructions.len(), 2, "every recorded move must round-trip"); assert!(matches!(loaded.moves[0], KlondikeInstruction::RotateStock));
assert!(matches!(instructions[0], KlondikeInstruction::RotateStock)); assert!(matches!(loaded.moves[1], KlondikeInstruction::RotateStock));
assert!(matches!(instructions[1], KlondikeInstruction::RotateStock));
assert_eq!(
loaded.win_move_index,
Some(1),
"win move index must point at the last recorded instruction",
);
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
@@ -1064,10 +1059,7 @@ fn new_game_with_solver_toggle_off_random_seed_path() {
app.update(); app.update();
// Game state was reseeded — move_count is 0 on the new game. // Game state was reseeded — move_count is 0 on the new game.
assert_eq!( assert_eq!(app.world().resource::<GameStateResource>().0.move_count(), 0);
app.world().resource::<GameStateResource>().0.move_count(),
0
);
} }
#[test] #[test]
@@ -1141,10 +1133,7 @@ fn new_game_with_solver_toggle_on_retries_until_winnable() {
// The chosen seed is non-deterministic (system time), // The chosen seed is non-deterministic (system time),
// but the new game must have been started cleanly: // but the new game must have been started cleanly:
// move_count back to 0, undo stack empty. // move_count back to 0, undo stack empty.
assert_eq!( assert_eq!(app.world().resource::<GameStateResource>().0.move_count(), 0);
app.world().resource::<GameStateResource>().0.move_count(),
0
);
assert_eq!( assert_eq!(
app.world() app.world()
.resource::<GameStateResource>() .resource::<GameStateResource>()
+8 -53
View File
@@ -54,23 +54,12 @@ impl Plugin for HelpPlugin {
.add_message::<TouchInput>() .add_message::<TouchInput>()
.add_systems( .add_systems(
Update, Update,
(toggle_help_screen, handle_help_close_button) (
.chain() toggle_help_screen,
// FocusKeys consumes Esc after the modal handlers handle_help_close_button,
// (mirrors toggle_settings_screen, #143). scroll_help_panel,
.before(crate::ui_focus::FocusKeys), crate::ui_modal::touch_scroll_panel::<HelpScrollable>,
) ),
.add_systems(
Update,
scroll_help_panel
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
)
.add_systems(
Update,
crate::ui_modal::touch_scroll_panel::<HelpScrollable>
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
); );
} }
} }
@@ -97,18 +86,13 @@ fn toggle_help_screen(
} }
/// Click handler for the modal's "Done" button. F1 toggles the overlay /// Click handler for the modal's "Done" button. F1 toggles the overlay
/// the same way; Esc closes too, so dismissal matches every other /// the same way; this just exposes the close action to mouse / touch.
/// modal (Phase C dismissal audit). Nothing ever stacks above Help,
/// so Esc needs no topmost gate.
fn handle_help_close_button( fn handle_help_close_button(
mut commands: Commands, mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
close_buttons: Query<&Interaction, (With<HelpCloseButton>, Changed<Interaction>)>, close_buttons: Query<&Interaction, (With<HelpCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<HelpScreen>>, screens: Query<Entity, With<HelpScreen>>,
) { ) {
let clicked = close_buttons.iter().any(|i| *i == Interaction::Pressed); if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
let esc = keys.just_pressed(KeyCode::Escape) && !screens.is_empty();
if !clicked && !esc {
return; return;
} }
for entity in &screens { for entity in &screens {
@@ -599,33 +583,4 @@ mod tests {
0 0
); );
} }
/// Esc must dismiss the Help modal like Done and F1 do (Phase C
/// dismissal audit).
#[test]
fn escape_closes_help_screen() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::F1);
app.update();
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(KeyCode::F1);
input.clear();
input.press(KeyCode::Escape);
}
app.update();
app.update();
assert_eq!(
app.world_mut()
.query::<&HelpScreen>()
.iter(app.world())
.count(),
0,
"Esc must close the Help modal"
);
}
} }
File diff suppressed because it is too large Load Diff
-169
View File
@@ -1,169 +0,0 @@
//! Single source of truth for the desktop keyboard bindings (Phase J).
//!
//! Two static hotkey tables had already diverged (onboarding's slide
//! and Help's controls reference); every future drift multiplies. This
//! module owns THE table: the onboarding slide renders the
//! [`HotkeyRow::essential`] subset, the hold-`/` cheat sheet
//! ([`crate::cheat_sheet_plugin`]) renders everything.
//!
//! The table is hand-maintained but **pinned by test** against the
//! handlers that actually consume each key — adding a binding without
//! updating this table (or vice versa) is designed to fail review, not
//! runtime. A registry generated from the input systems themselves is
//! the eventual ideal; this is the honest 90 % at 1 % of the cost.
/// One row of the hotkey table.
#[derive(Debug, Clone, Copy)]
pub struct HotkeyRow {
/// Display form of the key(s), e.g. `"D / Space"`.
pub keys: &'static str,
/// One-line action description.
pub description: &'static str,
/// `true` for the beginner-relevant subset the onboarding slide
/// shows; the cheat sheet always shows every row.
pub essential: bool,
}
/// Every desktop keyboard binding, in teaching order.
pub const HOTKEYS: &[HotkeyRow] = &[
HotkeyRow {
keys: "D / Space",
description: "Draw from stock",
essential: true,
},
HotkeyRow {
keys: "U",
description: "Undo last move (hold to repeat)",
essential: true,
},
HotkeyRow {
keys: "H",
description: "Hint (repeat to cycle alternatives)",
essential: true,
},
HotkeyRow {
keys: "Tab → Enter",
description: "Pick a card; arrows pick where; Enter to drop",
essential: true,
},
HotkeyRow {
keys: "N",
description: "New Classic game",
essential: true,
},
HotkeyRow {
keys: "M",
description: "Open Home (then 16 to pick a mode)",
essential: true,
},
HotkeyRow {
keys: "Esc",
description: "Pause / resume; close the top dialog",
essential: true,
},
HotkeyRow {
keys: "F1",
description: "Help / controls",
essential: true,
},
HotkeyRow {
keys: "S",
description: "Stats & progression",
essential: true,
},
HotkeyRow {
keys: "A",
description: "Achievements",
essential: true,
},
HotkeyRow {
keys: "O",
description: "Settings",
essential: true,
},
HotkeyRow {
keys: "P",
description: "Profile",
essential: false,
},
HotkeyRow {
keys: "L",
description: "Leaderboard",
essential: false,
},
HotkeyRow {
keys: "C",
description: "Daily Challenge",
essential: false,
},
HotkeyRow {
keys: "Z",
description: "Zen mode",
essential: false,
},
HotkeyRow {
keys: "X",
description: "Challenge mode",
essential: false,
},
HotkeyRow {
keys: "T",
description: "Time Attack",
essential: false,
},
HotkeyRow {
keys: "G",
description: "Give up the current deal",
essential: false,
},
HotkeyRow {
keys: "[ / ]",
description: "Volume down / up",
essential: false,
},
HotkeyRow {
keys: "F11",
description: "Toggle fullscreen",
essential: false,
},
HotkeyRow {
keys: "/ (hold)",
description: "This cheat sheet",
essential: false,
},
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table_is_populated_and_well_formed() {
assert!(!HOTKEYS.is_empty());
for row in HOTKEYS {
assert!(!row.keys.trim().is_empty(), "empty keys cell");
assert!(
!row.description.trim().is_empty(),
"empty description for {}",
row.keys
);
}
}
#[test]
fn essential_subset_is_a_teachable_size() {
let essentials = HOTKEYS.iter().filter(|r| r.essential).count();
assert!(
(6..=12).contains(&essentials),
"the onboarding slide wants a skimmable essential set, got {essentials}"
);
}
#[test]
fn keys_cells_are_unique() {
let mut seen = std::collections::HashSet::new();
for row in HOTKEYS {
assert!(seen.insert(row.keys), "duplicate hotkey row: {}", row.keys);
}
}
}
+6 -14
View File
@@ -3,6 +3,8 @@
use super::*; use super::*;
/// Auto-fade state for the action button bar. The bar fades out when /// Auto-fade state for the action button bar. The bar fades out when
/// the cursor is in the play area (below the HUD band) and back in when /// the cursor is in the play area (below the HUD band) and back in when
/// the cursor approaches the top of the window — same UX as a video /// the cursor approaches the top of the window — same UX as a video
@@ -47,11 +49,7 @@ const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;
/// `target` at a fixed rate so the visual transition is smooth across /// `target` at a fixed rate so the visual transition is smooth across
/// variable framerates. /// variable framerates.
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
pub(super) fn update_action_fade( pub(super) fn update_action_fade(windows: Query<&Window>, time: Res<Time>, mut fade: ResMut<HudActionFade>) {
windows: Query<&Window>,
time: Res<Time>,
mut fade: ResMut<HudActionFade>,
) {
let Ok(window) = windows.single() else { let Ok(window) = windows.single() else {
return; return;
}; };
@@ -109,19 +107,12 @@ pub(super) fn apply_action_fade(
/// Visual feedback for every action button — paints idle / hover / pressed /// Visual feedback for every action button — paints idle / hover / pressed
/// states by mutating `BackgroundColor` whenever the interaction state /// states by mutating `BackgroundColor` whenever the interaction state
/// changes. One query covers all action buttons via the shared /// changes. One query covers all action buttons via the shared
/// `ActionButton` marker. Glass tab-bar buttons are excluded: they carry /// `ActionButton` marker.
/// their own per-button colour triple and are painted by
/// `paint_glass_tab_buttons` instead (this system's opaque `ACTION_BTN_*`
/// fills would destroy the translucent glass look).
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
pub(super) fn paint_action_buttons( pub(super) fn paint_action_buttons(
mut buttons: Query< mut buttons: Query<
(&Interaction, &mut BackgroundColor), (&Interaction, &mut BackgroundColor),
( (With<ActionButton>, Changed<Interaction>),
With<ActionButton>,
Without<GlassTabButton>,
Changed<Interaction>,
),
>, >,
) { ) {
for (interaction, mut bg) in &mut buttons { for (interaction, mut bg) in &mut buttons {
@@ -436,3 +427,4 @@ pub(super) fn lerp_text_color(from: Color, to: Color, t: f32) -> Color {
from.alpha + (to.alpha - from.alpha) * t, from.alpha + (to.alpha - from.alpha) * t,
) )
} }
+67 -175
View File
@@ -3,6 +3,8 @@
use super::*; use super::*;
/// `Changed<Interaction>` filter ensures we only react on the frame the /// `Changed<Interaction>` filter ensures we only react on the frame the
/// interaction state transitions, avoiding repeat events while the button /// interaction state transitions, avoiding repeat events while the button
/// is held down. Each click handler fires the corresponding request event, /// is held down. Each click handler fires the corresponding request event,
@@ -30,86 +32,6 @@ pub(super) fn handle_undo_button(
} }
} }
/// Seconds the Undo button must be continuously held before hold-to-repeat
/// kicks in. Long enough that a normal tap (press + release inside one or
/// two frames) never triggers a second undo.
const UNDO_HOLD_INITIAL_DELAY_SECS: f32 = 0.45;
/// Interval between repeated undos while the hold continues. ~5.5 undos/s —
/// fast enough to unwind a long line, slow enough to release in time when
/// the board reaches the state the player wants.
const UNDO_HOLD_REPEAT_INTERVAL_SECS: f32 = 0.18;
/// Hold-to-repeat undo (Phase F): while the Undo button stays pressed,
/// fire additional [`UndoRequestEvent`]s after an initial delay, one per
/// repeat interval. The plain tap path stays in [`handle_undo_button`]
/// (its `Changed<Interaction>` filter fires exactly once per press);
/// this system only adds events once the hold outlives the delay, so a
/// tap never double-undoes.
///
/// Each repeat goes through the normal request queue — the scoring
/// penalty and No-Undo-mode gating in the consumer apply to every step.
pub(super) fn repeat_undo_on_hold(
time: Res<Time>,
buttons: Query<&Interaction, With<UndoButton>>,
mut state: ResMut<UndoHoldState>,
mut undo: MessageWriter<UndoRequestEvent>,
) {
let held = buttons.iter().any(|i| *i == Interaction::Pressed);
if !held {
if state.held_secs != 0.0 {
state.held_secs = 0.0;
}
return;
}
let before = state.held_secs;
state.held_secs += time.delta_secs();
if undo_hold_crossed_fire_boundary(before, state.held_secs) {
undo.write(UndoRequestEvent);
}
}
/// `true` when a hold that lasted `before` seconds at the previous frame
/// and `now` seconds this frame should fire a repeat undo: once when the
/// hold first outlives the initial delay, then once per repeat-interval
/// boundary. Pure so the timing contract is unit-testable without
/// fighting `Time`.
pub(super) fn undo_hold_crossed_fire_boundary(before: f32, now: f32) -> bool {
if now < UNDO_HOLD_INITIAL_DELAY_SECS {
return false;
}
if before < UNDO_HOLD_INITIAL_DELAY_SECS {
// Crossed the initial-delay threshold this frame — first repeat.
return true;
}
// Fire once each time the hold crosses another repeat-interval boundary.
let intervals_before =
((before - UNDO_HOLD_INITIAL_DELAY_SECS) / UNDO_HOLD_REPEAT_INTERVAL_SECS).floor();
let intervals_now =
((now - UNDO_HOLD_INITIAL_DELAY_SECS) / UNDO_HOLD_REPEAT_INTERVAL_SECS).floor();
intervals_now > intervals_before
}
/// Click on the touch action bar's Draw button — same
/// [`DrawRequestEvent`] the stock-pile tap writes, so the consumer's
/// rules (recycle, won-game rejection) apply identically. Skipped while
/// paused, mirroring `handle_stock_click`'s guard.
pub(super) fn handle_draw_button(
interaction_query: Query<&Interaction, (With<DrawButton>, Changed<Interaction>)>,
paused: Option<Res<PausedResource>>,
mut draw: MessageWriter<DrawRequestEvent>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
for interaction in &interaction_query {
if *interaction == Interaction::Pressed {
draw.write(DrawRequestEvent);
}
}
}
pub(super) fn handle_pause_button( pub(super) fn handle_pause_button(
interaction_query: Query<&Interaction, (With<PauseButton>, Changed<Interaction>)>, interaction_query: Query<&Interaction, (With<PauseButton>, Changed<Interaction>)>,
mut pause: MessageWriter<PauseRequestEvent>, mut pause: MessageWriter<PauseRequestEvent>,
@@ -391,65 +313,44 @@ pub(super) fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&Font
..default() ..default()
}; };
// One popover row: destination, label, hover tooltip. // Each row carries a tooltip alongside its label so hover reveals
type MenuRow = (MenuOption, &'static str, &'static str); // a one-line description of what each overlay shows — mirroring
// Destinations grouped into labelled sections (Phase C of the menu // the tooltips on the action-bar buttons that opened this popover.
// redesign): Play · You · Community · System. Each row carries a let rows: [(MenuOption, &'static str, &'static str); 7] = [
// tooltip alongside its label so hover reveals a one-line
// description of what each overlay shows — mirroring the tooltips
// on the action-bar buttons that opened this popover. Mode
// selection lives on Home now, so there is no Modes row.
let sections: [(&'static str, &'static [MenuRow]); 4] = [
( (
"Play", MenuOption::Help,
&[( "Help",
MenuOption::Home, "Show controls, rules, and keyboard shortcuts.",
"Home",
"Pick a mode, continue, or start a new game.",
)],
), ),
( (
"You", MenuOption::Modes,
&[ "Game Modes",
( "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
MenuOption::Profile,
"Profile",
"Your level, XP progress, and sync status.",
),
(
MenuOption::Stats,
"Stats",
"Lifetime totals: wins, streaks, fastest time, best score.",
),
(
MenuOption::Achievements,
"Achievements",
"Browse unlocked achievements and the rewards still ahead.",
),
],
), ),
( (
"Community", MenuOption::Stats,
&[( "Stats",
MenuOption::Leaderboard, "Lifetime totals: wins, streaks, fastest time, best score.",
"Leaderboard",
"Top players from your sync server. Opt in from Profile.",
)],
), ),
( (
"System", MenuOption::Achievements,
&[ "Achievements",
( "Browse unlocked achievements and the rewards still ahead.",
MenuOption::Settings, ),
"Settings", (
"Audio, animations, theme, draw mode, and sync.", MenuOption::Profile,
), "Profile",
( "Your level, XP progress, and sync status.",
MenuOption::Help, ),
"Help", (
"Show controls, rules, and keyboard shortcuts.", MenuOption::Settings,
), "Settings",
], "Audio, animations, theme, draw mode, and sync.",
),
(
MenuOption::Leaderboard,
"Leaderboard",
"Top players from your sync server. Opt in from Profile.",
), ),
]; ];
@@ -474,48 +375,27 @@ pub(super) fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&Font
ZIndex(Z_HUD_POPOVER), ZIndex(Z_HUD_POPOVER),
)) ))
.with_children(|panel| { .with_children(|panel| {
let section_font = TextFont { for (option, label, tooltip) in rows {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_CAPTION,
..default()
};
for (section, rows) in sections {
// Non-interactive section header — a quiet divider
// inside the existing panel, not a new widget.
panel panel
.spawn(Node { .spawn((
padding: UiRect::axes(VAL_SPACE_3, Val::Px(2.0)), option,
..default() ActionButton,
}) PopoverRow,
Button,
Tooltip::new(tooltip),
Node {
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
justify_content: JustifyContent::FlexStart,
align_items: AlignItems::Center,
min_width: Val::Px(150.0),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(ACTION_BTN_IDLE),
))
.with_children(|b| { .with_children(|b| {
b.spawn(( b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
Text::new(section),
section_font.clone(),
TextColor(TEXT_SECONDARY),
));
}); });
for &(option, label, tooltip) in rows {
panel
.spawn((
option,
ActionButton,
PopoverRow,
Button,
Tooltip::new(tooltip),
Node {
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
justify_content: JustifyContent::FlexStart,
align_items: AlignItems::Center,
min_width: Val::Px(150.0),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(ACTION_BTN_IDLE),
))
.with_children(|b| {
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
});
}
} }
}); });
@@ -544,28 +424,31 @@ pub(super) fn handle_menu_option_click(
interaction_query: Query<(&Interaction, &MenuOption), Changed<Interaction>>, interaction_query: Query<(&Interaction, &MenuOption), Changed<Interaction>>,
popovers: Query<Entity, With<MenuPopover>>, popovers: Query<Entity, With<MenuPopover>>,
backdrops: Query<Entity, With<MenuPopoverBackdrop>>, backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
mut home: MessageWriter<ToggleHomeRequestEvent>,
mut stats: MessageWriter<ToggleStatsRequestEvent>, mut stats: MessageWriter<ToggleStatsRequestEvent>,
mut achievements: MessageWriter<ToggleAchievementsRequestEvent>, mut achievements: MessageWriter<ToggleAchievementsRequestEvent>,
mut profile: MessageWriter<ToggleProfileRequestEvent>, mut profile: MessageWriter<ToggleProfileRequestEvent>,
mut settings: MessageWriter<ToggleSettingsRequestEvent>, mut settings: MessageWriter<ToggleSettingsRequestEvent>,
mut leaderboard: MessageWriter<ToggleLeaderboardRequestEvent>, mut leaderboard: MessageWriter<ToggleLeaderboardRequestEvent>,
mut help: MessageWriter<HelpRequestEvent>, mut help: MessageWriter<HelpRequestEvent>,
progress: Option<Res<ProgressResource>>,
daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>,
mut commands: Commands, mut commands: Commands,
) { ) {
let mut clicked_any = false; let mut clicked_any = false;
let mut open_modes = false;
for (interaction, option) in &interaction_query { for (interaction, option) in &interaction_query {
if *interaction != Interaction::Pressed { if *interaction != Interaction::Pressed {
continue; continue;
} }
clicked_any = true; clicked_any = true;
match option { match option {
MenuOption::Home => {
home.write(ToggleHomeRequestEvent);
}
MenuOption::Help => { MenuOption::Help => {
help.write(HelpRequestEvent); help.write(HelpRequestEvent);
} }
MenuOption::Modes => {
open_modes = true;
}
MenuOption::Stats => { MenuOption::Stats => {
stats.write(ToggleStatsRequestEvent); stats.write(ToggleStatsRequestEvent);
} }
@@ -589,6 +472,14 @@ pub(super) fn handle_menu_option_click(
commands.entity(e).despawn(); commands.entity(e).despawn();
} }
} }
if open_modes {
spawn_modes_popover(
&mut commands,
progress.as_deref(),
daily.as_deref(),
font_res.as_deref(),
);
}
} }
/// Despawns the [`ModesPopover`] and its backdrop when Escape / Android back /// Despawns the [`ModesPopover`] and its backdrop when Escape / Android back
@@ -722,3 +613,4 @@ pub(super) fn toggle_hud_on_tap(
} }
} }
} }
+38 -122
View File
@@ -12,15 +12,14 @@ use bevy::window::WindowResized;
mod fx; mod fx;
mod interaction; mod interaction;
mod spawn; mod spawn;
pub(crate) mod tab_bar;
mod updates; mod updates;
pub use fx::*; pub use fx::*;
use interaction::*; use interaction::*;
use spawn::*; use spawn::*;
use tab_bar::*;
use updates::*; use updates::*;
// On wasm32 AvatarPlugin is gated out; define a placeholder type so the // On wasm32 AvatarPlugin is gated out; define a placeholder type so the
// Option<Res<AvatarResource>> parameters below compile without changes. // Option<Res<AvatarResource>> parameters below compile without changes.
// The resource is never inserted on wasm, so every call resolves to None. // The resource is never inserted on wasm, so every call resolves to None.
@@ -30,11 +29,11 @@ struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::daily_challenge_plugin::DailyChallengeResource; use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::events::{ use crate::events::{
DrawRequestEvent, HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent, HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleHomeRequestEvent, StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent,
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent, ToggleStatsRequestEvent,
ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent, UndoRequestEvent, WinStreakMilestoneEvent,
}; };
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::game_plugin::{GameMutation, NewGameRequestWriters}; use crate::game_plugin::{GameMutation, NewGameRequestWriters};
@@ -315,18 +314,6 @@ pub struct UndoButton;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct PauseButton; pub struct PauseButton;
/// Accumulated hold time on the Undo button, driving hold-to-repeat undo
/// (Phase F). `handle_undo_button` owns the instant first undo on press;
/// `repeat_undo_on_hold` starts firing additional [`UndoRequestEvent`]s
/// once the hold passes `UNDO_HOLD_INITIAL_DELAY_SECS` and then every
/// `UNDO_HOLD_REPEAT_INTERVAL_SECS`. Each repeated undo goes through the
/// normal request path, so the existing scoring penalty applies per step.
#[derive(Resource, Debug, Default)]
pub struct UndoHoldState {
/// Seconds the Undo button has been continuously held.
held_secs: f32,
}
/// Marker on the "Help" action button. Click fires [`HelpRequestEvent`], /// Marker on the "Help" action button. Click fires [`HelpRequestEvent`],
/// mirroring the `F1` keyboard accelerator. /// mirroring the `F1` keyboard accelerator.
#[derive(Component, Debug)] #[derive(Component, Debug)]
@@ -337,27 +324,31 @@ pub struct HelpButton;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct HintButton; pub struct HintButton;
/// Marker on the touch action bar's "Draw" button (Phase F). Click fires
/// [`DrawRequestEvent`] — same queue as tapping the stock pile — so the
/// draw action is within thumb reach without stretching to the top of a
/// tall folded screen. Touch layout only; desktop draws via stock
/// click / `D` / `Space`.
#[derive(Component, Debug)]
pub struct DrawButton;
/// Android HUD label for the Hint button — shared with the help screen's /// Android HUD label for the Hint button — shared with the help screen's
/// controls reference so both always agree. /// controls reference so both always agree.
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
pub(crate) const ANDROID_HINT_LABEL: &str = "Hint"; pub(crate) const ANDROID_HINT_LABEL: &str = "Hint";
/// Hint label used by the touch action bar. Aliases [`ANDROID_HINT_LABEL`]
/// on Android so the bar and the help screen's controls reference can
/// never drift; the non-Android value only exists so the touch spawn
/// path stays host-compilable for tests.
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
const TOUCH_HINT_LABEL: &str = ANDROID_HINT_LABEL; const ACTION_BAR_LABELS: [&str; 7] = [
"Menu",
"Undo",
"Pause",
"Help",
ANDROID_HINT_LABEL,
"Mode",
"New",
];
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
const TOUCH_HINT_LABEL: &str = "Hint"; const ACTION_BAR_LABELS: [&str; 7] = [
"Menu \u{2193}",
"Undo",
"Pause",
"Help",
"Hint",
"Modes \u{2193}",
"New Game",
];
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
const ACTION_BAR_COLUMN_GAP: Val = Val::Px(4.0); const ACTION_BAR_COLUMN_GAP: Val = Val::Px(4.0);
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
@@ -396,9 +387,8 @@ pub enum ModeOption {
} }
/// Marker on the "Menu" action button. Click toggles the [`MenuPopover`] /// Marker on the "Menu" action button. Click toggles the [`MenuPopover`]
/// which exposes the Home / Profile / Stats / Achievements / /// which exposes the Stats / Achievements / Profile / Settings /
/// Leaderboard / Settings / Help overlays without needing the /// Leaderboard overlays without needing the S/A/P/O/L hotkeys.
/// M/P/S/A/L/O/F1 hotkeys.
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct MenuButton; pub struct MenuButton;
@@ -424,18 +414,16 @@ struct MenuPopoverBackdrop;
struct ModesPopoverBackdrop; struct ModesPopoverBackdrop;
/// One row inside the [`MenuPopover`]. The variant selects which /// One row inside the [`MenuPopover`]. The variant selects which
/// `Toggle*RequestEvent` the click handler fires. Rows render grouped /// `Toggle*RequestEvent` the click handler fires.
/// under section headers (Play · You · Community · System); mode
/// selection lives on Home, so there is no Modes row here.
#[derive(Component, Debug, Clone, Copy)] #[derive(Component, Debug, Clone, Copy)]
pub enum MenuOption { pub enum MenuOption {
Home, Help,
Profile, Modes,
Stats, Stats,
Achievements, Achievements,
Leaderboard, Profile,
Settings, Settings,
Help, Leaderboard,
} }
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens. /// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
@@ -465,7 +453,6 @@ impl Plugin for HudPlugin {
// idempotent. // idempotent.
app.add_message::<NewGameRequestEvent>() app.add_message::<NewGameRequestEvent>()
.add_message::<UndoRequestEvent>() .add_message::<UndoRequestEvent>()
.add_message::<DrawRequestEvent>()
.add_message::<PauseRequestEvent>() .add_message::<PauseRequestEvent>()
.add_message::<HelpRequestEvent>() .add_message::<HelpRequestEvent>()
.add_message::<StartZenRequestEvent>() .add_message::<StartZenRequestEvent>()
@@ -473,7 +460,6 @@ impl Plugin for HudPlugin {
.add_message::<StartTimeAttackRequestEvent>() .add_message::<StartTimeAttackRequestEvent>()
.add_message::<StartDailyChallengeRequestEvent>() .add_message::<StartDailyChallengeRequestEvent>()
.add_message::<ToggleStatsRequestEvent>() .add_message::<ToggleStatsRequestEvent>()
.add_message::<ToggleHomeRequestEvent>()
.add_message::<ToggleAchievementsRequestEvent>() .add_message::<ToggleAchievementsRequestEvent>()
.add_message::<ToggleProfileRequestEvent>() .add_message::<ToggleProfileRequestEvent>()
.add_message::<ToggleSettingsRequestEvent>() .add_message::<ToggleSettingsRequestEvent>()
@@ -482,7 +468,6 @@ impl Plugin for HudPlugin {
.init_resource::<PreviousScore>() .init_resource::<PreviousScore>()
.init_resource::<HudActionFade>() .init_resource::<HudActionFade>()
.init_resource::<HudVisibility>() .init_resource::<HudVisibility>()
.init_resource::<UndoHoldState>()
// Escape-close handlers for popovers read this; init defensively // Escape-close handlers for popovers read this; init defensively
// so HudPlugin works under MinimalPlugins in tests. // so HudPlugin works under MinimalPlugins in tests.
.init_resource::<ButtonInput<KeyCode>>() .init_resource::<ButtonInput<KeyCode>>()
@@ -517,31 +502,13 @@ impl Plugin for HudPlugin {
Update, Update,
(restore_hud_on_modal, apply_hud_visibility) (restore_hud_on_modal, apply_hud_visibility)
.chain() .chain()
.before(LayoutSystem::UpdateOnResize) .before(LayoutSystem::UpdateOnResize),
// Toggles Visibility on HUD chrome roots only — joins
// the chrome club (#143).
.in_set(UiTextFx)
.ambiguous_with(UiTextFx),
) )
.add_systems( .add_systems(
Update, Update,
( (
update_hud_avatar update_hud_avatar.after(crate::settings_plugin::SettingsMutation),
// Same-frame-exact after the settings spine, so NOT handle_avatar_button.ambiguous_with(HudButtons),
// a SettingsAccess member — but the club's readers
// and the home deal-option writers touch settings
// fields the avatar never reads (#143).
.ambiguous_with(crate::settings_plugin::SettingsAccess)
.after(crate::settings_plugin::SettingsMutation)
// Two more settings writers arrived with the wider
// ambiguity cluster: the pause overlay's draw-mode
// toggle and the achievement onboarding flag (#143).
.after(crate::pause_plugin::PauseMutation)
.after(crate::game_plugin::InfoToastWriters),
handle_avatar_button
.ambiguous_with(HudButtons)
.in_set(crate::profile_plugin::ProfileToggleWriters)
.ambiguous_with(crate::profile_plugin::ProfileToggleWriters),
), ),
) )
.add_systems( .add_systems(
@@ -556,11 +523,7 @@ impl Plugin for HudPlugin {
// the board painters that resize card/label text (#143). // the board painters that resize card/label text (#143).
.add_systems( .add_systems(
Update, Update,
update_hud_typography update_hud_typography.after(crate::card_plugin::BoardVisuals),
.after(crate::card_plugin::BoardVisuals)
// Writes HUD TextFont only — chrome club (#143).
.in_set(UiTextFx)
.ambiguous_with(UiTextFx),
) )
.add_systems( .add_systems(
Update, Update,
@@ -573,26 +536,16 @@ impl Plugin for HudPlugin {
.after(GameMutation) .after(GameMutation)
.in_set(UiTextFx) .in_set(UiTextFx)
.ambiguous_with(UiTextFx) .ambiguous_with(UiTextFx)
.ambiguous_with(crate::card_plugin::BoardVisuals) .ambiguous_with(crate::card_plugin::BoardVisuals),
// Reads only the reduce-motion flag; a frame of lag is
// invisible (#143).
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
) )
.add_systems( .add_systems(
Update, Update,
(start_streak_flourish, advance_streak_flourish) (start_streak_flourish, advance_streak_flourish)
.chain() .chain()
.after(GameMutation) .after(GameMutation)
// Reads the streak-milestone events StatsUpdate emits.
.after(crate::stats_plugin::StatsUpdate)
.in_set(UiTextFx) .in_set(UiTextFx)
.ambiguous_with(UiTextFx) .ambiguous_with(UiTextFx)
.ambiguous_with(crate::card_plugin::BoardVisuals) .ambiguous_with(crate::card_plugin::BoardVisuals),
// Reads only the reduce-motion flag; a frame of lag is
// invisible (#143).
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
) )
.add_systems( .add_systems(
Update, Update,
@@ -604,60 +557,23 @@ impl Plugin for HudPlugin {
.in_set(crate::game_plugin::UndoRequestWriters) .in_set(crate::game_plugin::UndoRequestWriters)
.ambiguous_with(crate::game_plugin::UndoRequestWriters) .ambiguous_with(crate::game_plugin::UndoRequestWriters)
.before(GameMutation), .before(GameMutation),
repeat_undo_on_hold
.in_set(crate::game_plugin::UndoRequestWriters)
.ambiguous_with(crate::game_plugin::UndoRequestWriters)
.before(GameMutation),
handle_draw_button
.in_set(crate::game_plugin::DrawRequestWriters)
.ambiguous_with(crate::game_plugin::DrawRequestWriters)
.before(GameMutation),
handle_pause_button, handle_pause_button,
handle_help_button, handle_help_button,
handle_hint_button handle_hint_button
.after(GameMutation) .after(GameMutation)
// A press cancels/spawns the solver task before
// this frame's drop/poll pass (#143).
.before(crate::pending_hint::PendingHint)
.in_set(crate::game_plugin::InfoToastWriters) .in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters), .ambiguous_with(crate::game_plugin::InfoToastWriters),
// The modes/menu popovers repaint the player level from handle_modes_button,
// ProgressResource — read it after this frame's XP award
// settles (#143).
handle_modes_button
.after(crate::progress_plugin::ProgressUpdate)
.after(crate::achievement_plugin::AchievementEval),
handle_mode_option_click handle_mode_option_click
.in_set(NewGameRequestWriters) .in_set(NewGameRequestWriters)
.ambiguous_with(NewGameRequestWriters), .ambiguous_with(NewGameRequestWriters),
handle_modes_backdrop_click, handle_modes_backdrop_click,
close_modes_popover_on_escape, close_modes_popover_on_escape,
handle_menu_button, handle_menu_button,
handle_menu_option_click handle_menu_option_click,
.after(crate::progress_plugin::ProgressUpdate)
.after(crate::achievement_plugin::AchievementEval)
.in_set(crate::profile_plugin::ProfileToggleWriters)
.ambiguous_with(crate::profile_plugin::ProfileToggleWriters),
handle_menu_backdrop_click, handle_menu_backdrop_click,
close_menu_popover_on_escape, close_menu_popover_on_escape,
paint_action_buttons, paint_action_buttons,
paint_glass_tab_buttons,
drive_tab_expansion,
// Writes tab-label TextColor and reads TextFont — entity-
// disjoint from the UiTextFx readout writers (tab labels
// vs HUD readouts), which Bevy can't prove, so declare it.
animate_tab_expansion
.ambiguous_with(UiTextFx)
// Tab-label fonts are never card/corner-label fonts.
.ambiguous_with(crate::card_plugin::BoardVisuals)
// Reads only reduce-motion; a frame of lag is
// invisible, so it joins the settings-reader club.
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
// Reads only reduce-motion — settings-reader club (#143).
duck_tab_bar_during_drag
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
) )
.chain() .chain()
.in_set(HudButtons) .in_set(HudButtons)
+81 -118
View File
@@ -2,6 +2,7 @@
use super::*; use super::*;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use crate::avatar_plugin::AvatarResource; use crate::avatar_plugin::AvatarResource;
@@ -291,9 +292,7 @@ pub(super) fn spawn_avatar_child(
const SIZE: f32 = 32.0; const SIZE: f32 = 32.0;
if let Some(handle) = avatar.and_then(|a| a.0.clone()) { if let Some(handle) = avatar.and_then(|a| a.0.clone()) {
// Logged-in with a downloaded avatar: keep the accent disc behind it. // Logged-in with a downloaded avatar: keep the accent disc behind it.
commands commands.entity(parent).insert(BackgroundColor(ACCENT_PRIMARY));
.entity(parent)
.insert(BackgroundColor(ACCENT_PRIMARY));
// Image fills the circle container; border_radius clips it to a disc. // Image fills the circle container; border_radius clips it to a disc.
commands.entity(parent).with_children(|b| { commands.entity(parent).with_children(|b| {
b.spawn(( b.spawn((
@@ -360,7 +359,6 @@ pub(super) fn handle_avatar_button(
pub(super) fn spawn_action_buttons( pub(super) fn spawn_action_buttons(
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
windows: Query<&Window>, windows: Query<&Window>,
images: Option<ResMut<Assets<Image>>>,
mut commands: Commands, mut commands: Commands,
) { ) {
let action_font_size = let action_font_size =
@@ -371,18 +369,9 @@ pub(super) fn spawn_action_buttons(
..default() ..default()
}; };
// Touch layout: the floating glass tab bar (pill + circular Menu). // Bottom bar: full-width, centered, sits above the gesture-navigation zone.
// `Assets<Image>` is absent under `MinimalPlugins`; `rasterize_into`
// returning `None` switches the bar to its text-fallback form.
if USE_TOUCH_UI_LAYOUT {
let icons = images.and_then(|mut imgs| TabIcons::rasterize_into(&mut imgs));
spawn_glass_tab_bar(&mut commands, &font, icons.as_ref());
return;
}
// Desktop bottom bar: full-width, centered, docked to the bottom edge.
// `SafeAreaAnchoredBottom` applies the correct logical-pixel inset once // `SafeAreaAnchoredBottom` applies the correct logical-pixel inset once
// the platform reports it; initial value is 0.0. // Android reports it (frames 1-3); initial value is 0.0.
commands commands
.spawn(( .spawn((
Node { Node {
@@ -410,89 +399,83 @@ pub(super) fn spawn_action_buttons(
HudActionBar, HudActionBar,
)) ))
.with_children(|row| { .with_children(|row| {
spawn_desktop_action_bar(row, &font); // The trailing `order` argument feeds `Focusable { group: Hud, order }`
// so Tab cycles the action bar in visual reading order.
// Undo and Pause are the primary gameplay actions — full brightness.
// Menu, Help, Hint, Modes, New are navigation/utility — dimmed.
spawn_action_button(
row,
MenuButton,
ACTION_BAR_LABELS[0],
None,
"Open Stats, Achievements, Profile, Settings, or Leaderboard.",
&font,
0,
TEXT_SECONDARY,
);
spawn_action_button(
row,
UndoButton,
ACTION_BAR_LABELS[1],
Some("U"),
"Take back your last move. Costs points and blocks No Undo.",
&font,
1,
TEXT_PRIMARY,
);
spawn_action_button(
row,
PauseButton,
ACTION_BAR_LABELS[2],
Some("Esc"),
"Pause the game and freeze the timer.",
&font,
2,
TEXT_PRIMARY,
);
spawn_action_button(
row,
HelpButton,
ACTION_BAR_LABELS[3],
Some("F1"),
"Show controls, rules, and keyboard shortcuts.",
&font,
3,
TEXT_SECONDARY,
);
spawn_action_button(
row,
HintButton,
ACTION_BAR_LABELS[4],
Some("H"),
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
&font,
4,
TEXT_SECONDARY,
);
spawn_action_button(
row,
ModesButton,
ACTION_BAR_LABELS[5],
None,
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
&font,
5,
TEXT_SECONDARY,
);
spawn_action_button(
row,
NewGameButton,
ACTION_BAR_LABELS[6],
Some("N"),
"Start a fresh deal. Confirms first if a game is in progress.",
&font,
6,
TEXT_SECONDARY,
);
}); });
} }
/// Desktop action bar — unchanged by Phase F (decision 5: the touch bar
/// is touch-only). All seven actions, uniform sizing.
pub(super) fn spawn_desktop_action_bar(row: &mut ChildSpawnerCommands, font: &TextFont) {
// The trailing `order` argument feeds `Focusable { group: Hud, order }`
// so Tab cycles the action bar in visual reading order.
// Undo and Pause are the primary gameplay actions — full brightness.
// Menu, Help, Hint, Modes, New are navigation/utility — dimmed.
spawn_action_button(
row,
MenuButton,
"Menu \u{2193}",
None,
"Open Stats, Achievements, Profile, Settings, or Leaderboard.",
font,
0,
TEXT_SECONDARY,
);
spawn_action_button(
row,
UndoButton,
"Undo",
Some("U"),
"Take back your last move. Hold to keep undoing. Costs points and blocks No Undo.",
font,
1,
TEXT_PRIMARY,
);
spawn_action_button(
row,
PauseButton,
"Pause",
Some("Esc"),
"Pause the game and freeze the timer.",
font,
2,
TEXT_PRIMARY,
);
spawn_action_button(
row,
HelpButton,
"Help",
Some("F1"),
"Show controls, rules, and keyboard shortcuts.",
font,
3,
TEXT_SECONDARY,
);
spawn_action_button(
row,
HintButton,
"Hint",
Some("H"),
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
font,
4,
TEXT_SECONDARY,
);
spawn_action_button(
row,
ModesButton,
"Modes \u{2193}",
None,
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
font,
5,
TEXT_SECONDARY,
);
spawn_action_button(
row,
NewGameButton,
"New Game",
Some("N"),
"Start a fresh deal. Confirms first if a game is in progress.",
font,
6,
TEXT_SECONDARY,
);
}
/// Spawns a single action button as a child of `row`. Each button shares /// Spawns a single action button as a child of `row`. Each button shares
/// the same node geometry, idle colour, and `ActionButton` marker so /// the same node geometry, idle colour, and `ActionButton` marker so
/// `paint_action_buttons` can recolour all of them with one query. /// `paint_action_buttons` can recolour all of them with one query.
@@ -526,34 +509,13 @@ pub(super) fn spawn_action_button<M: Component>(
None None
}; };
let (pad, min_w, min_h) = action_button_metrics();
spawn_action_button_sized(
row, marker, label, hotkey, tooltip, font, order, text_color, pad, min_w, min_h,
);
}
/// Shared body of [`spawn_action_button`] — one place owns the component
/// set so `paint_action_buttons`, tooltips, and the focus ring treat every
/// bar button identically.
#[allow(clippy::too_many_arguments)]
fn spawn_action_button_sized<M: Component>(
row: &mut ChildSpawnerCommands,
marker: M,
label: &str,
hotkey: Option<&'static str>,
tooltip: &'static str,
font: &TextFont,
order: i32,
text_color: Color,
pad: UiRect,
min_w: Val,
min_h: Val,
) {
let hotkey_font = TextFont { let hotkey_font = TextFont {
font: font.font.clone(), font: font.font.clone(),
font_size: TYPE_CAPTION, font_size: TYPE_CAPTION,
..default() ..default()
}; };
let (pad, min_w, min_h) = action_button_metrics();
row.spawn(( row.spawn((
marker, marker,
ActionButton, ActionButton,
@@ -587,3 +549,4 @@ fn spawn_action_button_sized<M: Component>(
} }
}); });
} }
-529
View File
@@ -1,529 +0,0 @@
//! Floating "liquid glass" touch tab bar — layout and animation.
//!
//! Replaces the docked full-width touch action bar (Phase F) with an
//! iOS-26-style floating arrangement: a pill of icon buttons hovering
//! above the bottom edge, plus a detached circular Menu button to its
//! right. The *visual treatment* (translucent sheen, specular rim, drop
//! shadow) lives in [`crate::ui_glass`]; this module owns only geometry,
//! button wiring, and the expand/collapse label animation, so the glass
//! material can be swapped without touching layout.
//!
//! Interaction compatibility: every button keeps the same marker
//! components as the old bar (`UndoButton`, `DrawButton`, …) plus
//! `ActionButton`, `Button`, `Tooltip`, and `Focusable`, so the click
//! handlers, tooltips, focus ring, and the tap-to-toggle chrome guard in
//! `interaction.rs` all work unchanged.
//!
//! Expansion model (user decision, 2026-07-15): **Draw** — the most
//! frequent action — is the persistently-expanded "active" pill showing
//! icon + label; the other buttons are icon-only and reveal their label
//! with a width-animated slide while pressed.
use super::*;
use crate::assets::hud_icon_svg::HudIcon;
use crate::ui_glass::{GLASS_BORDER_PX, glass_surface};
use crate::ui_theme::{ACCENT_PRIMARY_HOVER, SPACE_3};
use bevy::text::{LineBreak, TextLayout};
use bevy::ui::{Overflow, UiTransform, Val2};
use crate::resources::DragState;
use crate::safe_area::SafeAreaInsets;
/// Height of both the pill and the circular Menu button, in logical px.
/// 64 keeps the inner 48-px buttons comfortably past the 44-px touch
/// floor with 8 px of glass around them.
pub(crate) const TAB_BAR_HEIGHT: f32 = 64.0;
/// Gap between the bar and the bottom screen edge (the "floating" margin;
/// the Android gesture-bar inset is added on top by `SafeAreaAnchoredBottom`).
const TAB_BAR_MARGIN: f32 = SPACE_3;
/// Vertical space UI stacked above the bar (the toast stack) must clear:
/// floating margin + bar height + one spacing step of breathing room.
pub(crate) const TAB_BAR_CLEARANCE_PX: f32 = TAB_BAR_MARGIN + TAB_BAR_HEIGHT + SPACE_2;
/// Diameter of a button inside the pill (and its fully-round radius ×2).
const TAB_BUTTON_SIZE: f32 = 48.0;
/// Displayed icon size in logical px (texture is rasterised larger; see
/// `assets::hud_icon_svg::HUD_ICON_TEXTURE_PX`).
const TAB_ICON_PX: f32 = 24.0;
/// FiraMono glyph advance as a fraction of font size. The bar is
/// monospace-only, so `chars × advance × font_size` is the exact label
/// width — the same ratio `action_bar_font_size` documents.
const FIRA_ADVANCE: f32 = 0.62;
/// Gap between an icon and its revealed label, folded into the animated
/// wrapper width so it collapses together with the text.
const LABEL_LEAD_PX: f32 = SPACE_2;
/// Idle fill of an inactive icon button — fully transparent so the pill's
/// glass shows through untouched.
const GLASS_BTN_IDLE: Color = Color::srgba(1.0, 1.0, 1.0, 0.0);
/// Hover fill of an inactive icon button (desktop pointer / DPAD focus).
const GLASS_BTN_HOVER: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
/// Pressed fill of an inactive icon button.
const GLASS_BTN_PRESSED: Color = Color::srgba(1.0, 1.0, 1.0, 0.14);
/// Pressed state of the active (accent-filled) pill — darker than idle so
/// the press reads as the surface receding, mirroring `BG_ELEVATED_PRESSED`.
const GLASS_ACTIVE_PRESSED: Color = Color::srgb(0.55, 0.22, 0.22);
/// Idle / hovered / pressed fills for one tab-bar button. Presence of this
/// component also routes the button to `paint_glass_tab_buttons` instead of
/// the legacy `paint_action_buttons` (which excludes it).
#[derive(Component, Debug)]
pub struct GlassTabButton {
idle: Color,
hovered: Color,
pressed: Color,
}
/// Animated label-reveal state, on the clipping wrapper node between the
/// button and its `Text` label. `progress` ∈ [0, 1] maps to wrapper width
/// via [`expansion_width`]; `target` is where it is heading.
#[derive(Component, Debug)]
pub struct TabLabelWrap {
/// Current reveal amount, 0 = collapsed, 1 = fully expanded.
pub progress: f32,
/// Reveal amount the animation is moving towards.
pub target: f32,
/// `true` keeps the label expanded regardless of interaction —
/// the persistent Draw pill, and every button in text-fallback mode.
persistent: bool,
/// Label length; width is recomputed from the live font size each
/// frame so viewport-driven font rescales stay correct.
label_chars: usize,
/// Last width written to the node, to skip no-op layout writes.
last_width: f32,
}
/// Marker on the label `Text` inside a [`TabLabelWrap`].
#[derive(Component, Debug)]
pub struct TabLabel;
/// Duck-away state on the bar container: while a card drag is committed
/// the whole bar slides below the screen edge so it never occludes a
/// drop target on the bottom tableau rows, then slides back on release.
/// `progress` ∈ [0, 1]; 1 = fully off-screen.
#[derive(Component, Debug)]
pub struct TabBarDuck {
/// Current slide amount, animated by [`duck_tab_bar_during_drag`].
pub progress: f32,
}
/// Extra slide distance past the bar's own height so its drop shadow
/// (16 px blur + 6 px offset) also clears the screen edge.
const DUCK_SHADOW_ALLOWANCE_PX: f32 = 24.0;
/// Rasterised icon textures for the five tab-bar actions.
pub(super) struct TabIcons {
undo: Handle<Image>,
draw: Handle<Image>,
hint: Handle<Image>,
pause: Handle<Image>,
menu: Handle<Image>,
}
impl TabIcons {
/// Rasterise all five icons into `images`. Returns `None` (and warns)
/// if any icon fails — the caller then falls back to text-only labels,
/// so a bad SVG degrades the look, never the playability.
pub(super) fn rasterize_into(images: &mut Assets<Image>) -> Option<Self> {
let mut add = |icon: HudIcon| match icon.rasterize() {
Ok(img) => Some(images.add(img)),
Err(e) => {
warn!("tab_bar: failed to rasterise {icon:?} icon: {e}");
None
}
};
Some(Self {
undo: add(HudIcon::Undo)?,
draw: add(HudIcon::Draw)?,
hint: add(HudIcon::Hint)?,
pause: add(HudIcon::Pause)?,
menu: add(HudIcon::Menu)?,
})
}
}
/// Wrapper width in logical px for a label of `chars` monospace glyphs at
/// `font_size`, revealed to `progress`. Pure so the animation curve is
/// unit-testable without an app.
pub(super) fn expansion_width(chars: usize, font_size: f32, progress: f32) -> f32 {
progress.clamp(0.0, 1.0) * (LABEL_LEAD_PX + chars as f32 * font_size * FIRA_ADVANCE)
}
/// Advance `progress` towards `target` by one frame of `dt` seconds.
/// Linear tween over `MOTION_SLIDE_SECS`, snapping when reduce-motion is
/// active. Pure for the same reason as [`expansion_width`].
pub(super) fn step_expansion(progress: f32, target: f32, dt: f32, reduce_motion: bool) -> f32 {
if reduce_motion {
return target;
}
let max_step = dt / crate::ui_theme::MOTION_SLIDE_SECS;
progress + (target - progress).clamp(-max_step, max_step)
}
/// Spawns the floating glass tab bar: one full-width invisible flex row
/// (carrying the `HudActionBar` marker so chrome show/hide keeps working)
/// centring the glass pill and the detached circular Menu button.
///
/// `icons: None` (rasteriser unavailable, e.g. `MinimalPlugins` tests, or
/// an SVG regression) switches every button to text-fallback: labels stay
/// expanded so the bar remains fully usable without textures.
pub(super) fn spawn_glass_tab_bar(
commands: &mut Commands,
font: &TextFont,
icons: Option<&TabIcons>,
) {
let text_fallback = icons.is_none();
commands
.spawn((
Node {
position_type: PositionType::Absolute,
bottom: Val::Px(TAB_BAR_MARGIN),
left: Val::Px(0.0),
width: Val::Percent(100.0),
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_3,
..default()
},
ZIndex(Z_HUD),
SafeAreaAnchoredBottom {
base_bottom: TAB_BAR_MARGIN,
},
HudActionBar,
TabBarDuck { progress: 0.0 },
UiTransform::IDENTITY,
))
.with_children(|bar| {
// The main pill: Undo · [Draw] · Hint · Pause.
bar.spawn((
Node {
height: Val::Px(TAB_BAR_HEIGHT),
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
padding: UiRect::axes(Val::Px(SPACE_2), Val::Px(0.0)),
column_gap: Val::Px(4.0),
border: UiRect::all(Val::Px(GLASS_BORDER_PX)),
border_radius: BorderRadius::all(Val::Px(TAB_BAR_HEIGHT / 2.0)),
..default()
},
glass_surface(),
))
.with_children(|pill| {
spawn_tab_button(
pill,
UndoButton,
"Undo",
"Take back your last move. Hold to keep undoing. Costs points and blocks No Undo.",
font,
0,
icons.map(|i| i.undo.clone()),
false,
text_fallback,
);
spawn_tab_button(
pill,
DrawButton,
"Draw",
"Draw from the stock — same as tapping the deck.",
font,
1,
icons.map(|i| i.draw.clone()),
true,
text_fallback,
);
spawn_tab_button(
pill,
HintButton,
TOUCH_HINT_LABEL,
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
font,
2,
icons.map(|i| i.hint.clone()),
false,
text_fallback,
);
spawn_tab_button(
pill,
PauseButton,
"Pause",
"Pause the game and freeze the timer.",
font,
3,
icons.map(|i| i.pause.clone()),
false,
text_fallback,
);
});
// The detached circular Menu button, same glass material.
bar.spawn((
MenuButton,
ActionButton,
Button,
GlassTabButton {
idle: GLASS_BTN_IDLE,
hovered: GLASS_BTN_HOVER,
pressed: GLASS_BTN_PRESSED,
},
Tooltip::new(
"Open Home, Stats, Achievements, Profile, Settings, or Leaderboard.",
),
Focusable {
group: FocusGroup::Hud,
order: 4,
},
Node {
width: Val::Px(TAB_BAR_HEIGHT),
height: Val::Px(TAB_BAR_HEIGHT),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border: UiRect::all(Val::Px(GLASS_BORDER_PX)),
border_radius: BorderRadius::all(Val::Px(TAB_BAR_HEIGHT / 2.0)),
..default()
},
BackgroundColor(GLASS_BTN_IDLE),
glass_surface(),
))
.with_children(|circle| {
if let Some(icons) = icons {
spawn_tab_icon(circle, icons.menu.clone(), TEXT_PRIMARY);
} else {
// Text fallback: a menu glyph-free label keeps the
// button discoverable without any texture.
circle.spawn((
Text::new("Menu"),
TextLayout::new_with_linebreak(LineBreak::NoWrap),
font.clone(),
TextColor(TEXT_PRIMARY),
));
}
});
});
}
/// Spawns one button inside the pill: icon, then the width-animated label
/// wrapper. `active` gives the accent-filled persistent pill (Draw);
/// `text_fallback` pins every label open when icons are unavailable.
#[allow(clippy::too_many_arguments)]
fn spawn_tab_button<M: Component>(
pill: &mut ChildSpawnerCommands,
marker: M,
label: &str,
tooltip: &'static str,
font: &TextFont,
order: i32,
icon: Option<Handle<Image>>,
active: bool,
text_fallback: bool,
) {
let (idle, hovered, pressed) = if active {
(ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, GLASS_ACTIVE_PRESSED)
} else {
(GLASS_BTN_IDLE, GLASS_BTN_HOVER, GLASS_BTN_PRESSED)
};
let icon_tint = if active { TEXT_PRIMARY } else { TEXT_SECONDARY };
let persistent = active || text_fallback;
let start = if persistent { 1.0 } else { 0.0 };
let chars = label.chars().count();
pill.spawn((
marker,
ActionButton,
Button,
GlassTabButton {
idle,
hovered,
pressed,
},
Tooltip::new(tooltip),
Focusable {
group: FocusGroup::Hud,
order,
},
Node {
height: Val::Px(TAB_BUTTON_SIZE),
min_width: Val::Px(TAB_BUTTON_SIZE),
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
padding: UiRect::axes(Val::Px(SPACE_3), Val::Px(0.0)),
border_radius: BorderRadius::all(Val::Px(TAB_BUTTON_SIZE / 2.0)),
..default()
},
BackgroundColor(idle),
))
.with_children(|b| {
if let Some(handle) = icon {
spawn_tab_icon(b, handle, icon_tint);
}
// The clipping wrapper the animation drives. Label text sits
// left-anchored inside, so growing the width reveals it
// left-to-right and shrinking clips it from the right.
b.spawn((
TabLabelWrap {
progress: start,
target: start,
persistent,
label_chars: chars,
last_width: f32::NAN,
},
Node {
overflow: Overflow::clip(),
width: Val::Px(expansion_width(chars, font.font_size, start)),
height: Val::Percent(100.0),
align_items: AlignItems::Center,
justify_content: JustifyContent::FlexEnd,
..default()
},
))
.with_children(|wrap| {
let mut color = TEXT_PRIMARY;
color.set_alpha(start);
wrap.spawn((
TabLabel,
ActionButtonLabel,
Text::new(label),
TextLayout::new_with_linebreak(LineBreak::NoWrap),
font.clone(),
TextColor(color),
));
});
});
}
/// One fixed-size tinted icon node.
fn spawn_tab_icon(parent: &mut ChildSpawnerCommands, handle: Handle<Image>, tint: Color) {
parent.spawn((
ImageNode {
color: tint,
..ImageNode::new(handle)
},
Node {
width: Val::Px(TAB_ICON_PX),
height: Val::Px(TAB_ICON_PX),
flex_shrink: 0.0,
..default()
},
));
}
/// Sets each label wrapper's animation target from its button's
/// interaction state: persistent wrappers pin to 1, the rest expand while
/// pressed or hovered and collapse on release.
pub(super) fn drive_tab_expansion(
buttons: Query<(&Interaction, &Children), With<GlassTabButton>>,
mut wraps: Query<&mut TabLabelWrap>,
) {
for (interaction, children) in &buttons {
for child in children.iter() {
let Ok(mut wrap) = wraps.get_mut(child) else {
continue;
};
let target = if wrap.persistent || !matches!(interaction, Interaction::None) {
1.0
} else {
0.0
};
// Bypass change detection on no-ops so `animate_tab_expansion`
// and layout stay idle while nothing moves.
if wrap.target != target {
wrap.target = target;
}
}
}
}
/// Advances every label reveal towards its target and writes the wrapper
/// width + label alpha. Width is derived from the label's *live* font size,
/// so the Fold's posture-change font rescale re-widens expanded labels
/// automatically. Node writes are skipped when the width is unchanged to
/// avoid dirtying UI layout every frame.
pub(super) fn animate_tab_expansion(
time: Res<Time>,
settings: Option<Res<SettingsResource>>,
mut wraps: Query<(&mut TabLabelWrap, &mut Node, &Children)>,
mut labels: Query<(&TextFont, &mut TextColor), With<TabLabel>>,
) {
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
let dt = time.delta_secs();
for (mut wrap, mut node, children) in &mut wraps {
for child in children.iter() {
let Ok((font, mut text_color)) = labels.get_mut(child) else {
continue;
};
let next = step_expansion(wrap.progress, wrap.target, dt, reduce_motion);
let width = expansion_width(wrap.label_chars, font.font_size, next);
// `!=` (not epsilon) is deliberate: last_width starts NaN so the
// first frame always writes, and equal floats mean a true no-op.
if next != wrap.progress || width != wrap.last_width {
wrap.progress = next;
wrap.last_width = width;
node.width = Val::Px(width);
text_color.0.set_alpha(next);
}
break;
}
}
}
/// Slides the bar off the bottom edge while a card drag is committed and
/// back once it ends. Effectively touch-only: the desktop docked bar
/// never carries [`TabBarDuck`], so the query is empty there.
///
/// The slide animates `UiTransform::translation` (a render-time offset,
/// +y = down) rather than `Node::bottom`, for two reasons: layout is
/// never dirtied mid-drag (the whole point is not to cost frames while
/// the player is dragging), and `Node::bottom` is owned by the safe-area
/// anchor system — two writers on one field would fight.
pub(super) fn duck_tab_bar_during_drag(
time: Res<Time>,
settings: Option<Res<SettingsResource>>,
drag: Option<Res<DragState>>,
insets: Option<Res<SafeAreaInsets>>,
windows: Query<&Window>,
mut bars: Query<(&mut TabBarDuck, &mut UiTransform), With<HudActionBar>>,
) {
let target = if drag.as_deref().is_some_and(|d| d.committed) {
1.0
} else {
0.0
};
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
let dt = time.delta_secs();
// Physical-px inset → logical, same conversion the safe-area anchors
// apply (CLAUDE.md §10): the bar sits `inset` above its base position,
// so the slide must cover that extra travel to fully clear the edge.
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
let inset_logical = insets.as_deref().map_or(0.0, |i| i.bottom / scale);
let slide_px = TAB_BAR_CLEARANCE_PX + inset_logical + DUCK_SHADOW_ALLOWANCE_PX;
for (mut duck, mut transform) in &mut bars {
let next = step_expansion(duck.progress, target, dt, reduce_motion);
if next == duck.progress && duck.progress == target {
continue;
}
duck.progress = next;
transform.translation = Val2::px(0.0, next * slide_px);
}
}
/// Idle / hover / pressed feedback for glass tab buttons — the glass
/// counterpart of `paint_action_buttons`, using each button's own colour
/// triple so the accent-filled active pill and the transparent icon
/// buttons share one system.
pub(super) fn paint_glass_tab_buttons(
mut buttons: Query<(&Interaction, &GlassTabButton, &mut BackgroundColor), Changed<Interaction>>,
) {
for (interaction, style, mut bg) in &mut buttons {
bg.0 = match interaction {
Interaction::Pressed => style.pressed,
Interaction::Hovered => style.hovered,
Interaction::None => style.idle,
};
}
}
+12 -322
View File
@@ -40,16 +40,9 @@ fn read_hud_text<M: Component>(app: &mut App) -> String {
#[test] #[test]
fn score_reflects_game_state() { fn score_reflects_game_state() {
let mut app = headless_app(); let mut app = headless_app();
let score = app let score = app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(20);
.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_score(20);
app.update(); app.update();
assert_eq!( assert_eq!(read_hud_text::<HudScore>(&mut app), format!("Score: {score}"));
read_hud_text::<HudScore>(&mut app),
format!("Score: {score}")
);
} }
#[test] #[test]
@@ -199,9 +192,7 @@ fn challenge_time_color_zero_is_danger() {
fn challenge_hud_empty_when_no_daily_resource() { fn challenge_hud_empty_when_no_daily_resource() {
// No DailyChallengeResource inserted → HudChallenge must be empty. // No DailyChallengeResource inserted → HudChallenge must be empty.
let mut app = headless_app(); let mut app = headless_app();
app.world_mut() app.world_mut().resource_mut::<GameStateResource>().set_changed();
.resource_mut::<GameStateResource>()
.set_changed();
app.update(); app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), ""); assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
} }
@@ -216,9 +207,7 @@ fn challenge_hud_shows_time_limit_when_resource_present() {
target_score: None, target_score: None,
max_time_secs: Some(300), max_time_secs: Some(300),
}); });
app.world_mut() app.world_mut().resource_mut::<GameStateResource>().set_changed();
.resource_mut::<GameStateResource>()
.set_changed();
app.update(); app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00"); assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00");
} }
@@ -233,9 +222,7 @@ fn challenge_hud_shows_score_goal_when_resource_present() {
target_score: Some(4000), target_score: Some(4000),
max_time_secs: None, max_time_secs: None,
}); });
app.world_mut() app.world_mut().resource_mut::<GameStateResource>().set_changed();
.resource_mut::<GameStateResource>()
.set_changed();
app.update(); app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts"); assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts");
} }
@@ -251,10 +238,7 @@ fn challenge_hud_clears_on_win() {
max_time_secs: Some(300), max_time_secs: Some(300),
}); });
// Mark the game as won — HudChallenge should be empty. // Mark the game as won — HudChallenge should be empty.
app.world_mut() app.world_mut().resource_mut::<GameStateResource>().0.set_test_won(true);
.resource_mut::<GameStateResource>()
.0
.set_test_won(true);
app.update(); app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), ""); assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
} }
@@ -400,10 +384,7 @@ fn score_increase_above_threshold_spawns_floater_in_accent_primary() {
set_manual_time_step(&mut app, 0.0); set_manual_time_step(&mut app, 0.0);
// Initial state has score=0; bumping by 50 (the threshold) // Initial state has score=0; bumping by 50 (the threshold)
// is the smallest jump that triggers the floater. // is the smallest jump that triggers the floater.
app.world_mut() app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
.resource_mut::<GameStateResource>()
.0
.force_test_score(50);
app.update(); app.update();
// One floater should now exist. // One floater should now exist.
@@ -424,10 +405,7 @@ fn score_increase_above_threshold_spawns_floater_in_accent_primary() {
#[test] #[test]
fn score_floater_despawns_after_full_lifetime() { fn score_floater_despawns_after_full_lifetime() {
let mut app = headless_app(); let mut app = headless_app();
app.world_mut() app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
.resource_mut::<GameStateResource>()
.0
.force_test_score(50);
app.update(); app.update();
assert_eq!(count_with::<ScoreFloater>(&mut app), 1); assert_eq!(count_with::<ScoreFloater>(&mut app), 1);
@@ -453,10 +431,7 @@ fn score_increase_below_threshold_does_not_spawn_floater() {
let mut app = headless_app(); let mut app = headless_app();
// +5 mirrors a single tableau-to-foundation move; well below // +5 mirrors a single tableau-to-foundation move; well below
// the 50-point threshold so the floater path stays dormant. // the 50-point threshold so the floater path stays dormant.
app.world_mut() app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(5);
.resource_mut::<GameStateResource>()
.0
.force_test_score(5);
app.update(); app.update();
assert_eq!( assert_eq!(
count_with::<ScoreFloater>(&mut app), count_with::<ScoreFloater>(&mut app),
@@ -532,10 +507,7 @@ fn score_change_skips_pulse_and_floater_under_reduce_motion() {
..Settings::default() ..Settings::default()
})); }));
// +100 would normally create both a ScorePulse and a ScoreFloater. // +100 would normally create both a ScorePulse and a ScoreFloater.
app.world_mut() app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
.resource_mut::<GameStateResource>()
.0
.force_test_score(50);
app.update(); app.update();
assert_eq!( assert_eq!(
count_with::<ScorePulse>(&mut app), count_with::<ScorePulse>(&mut app),
@@ -670,7 +642,7 @@ fn hud_elements_carry_expected_tooltip_strings() {
); );
assert_eq!( assert_eq!(
tooltip_for::<UndoButton>(&mut app), tooltip_for::<UndoButton>(&mut app),
"Take back your last move. Hold to keep undoing. Costs points and blocks No Undo." "Take back your last move. Costs points and blocks No Undo."
); );
assert_eq!( assert_eq!(
tooltip_for::<PauseButton>(&mut app), tooltip_for::<PauseButton>(&mut app),
@@ -786,8 +758,8 @@ fn popover_rows_carry_tooltip_strings() {
menu_tooltips.len() menu_tooltips.len()
); );
for expected in [ for expected in [
"Pick a mode, continue, or start a new game.",
"Show controls, rules, and keyboard shortcuts.", "Show controls, rules, and keyboard shortcuts.",
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
"Lifetime totals: wins, streaks, fastest time, best score.", "Lifetime totals: wins, streaks, fastest time, best score.",
"Browse unlocked achievements and the rewards still ahead.", "Browse unlocked achievements and the rewards still ahead.",
"Your level, XP progress, and sync status.", "Your level, XP progress, and sync status.",
@@ -899,285 +871,3 @@ fn hud_focus_only_engages_when_button_hovered() {
"Hud-engaged Tab must focus a Hud-grouped entity" "Hud-engaged Tab must focus a Hud-grouped entity"
); );
} }
// ---------------------------------------------------------------------------
// Phase F: touch action bar + hold-to-repeat undo
// ---------------------------------------------------------------------------
/// Bare app (no `HudPlugin`) so bar-content assertions count only the
/// buttons the tested spawn function creates — `headless_app` would
/// pre-spawn the platform-default bar and pollute the counts.
///
/// The touch branch spawns the glass tab bar with `icons: None`
/// (`MinimalPlugins` has no `Assets<Image>`), which exercises the
/// text-fallback path — same markers, labels pinned expanded.
fn bar_only_app(touch: bool) -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.update();
let font = TextFont::default();
let world = app.world_mut();
let mut commands = world.commands();
if touch {
spawn_glass_tab_bar(&mut commands, &font, None);
} else {
commands.spawn(Node::default()).with_children(|row| {
spawn_desktop_action_bar(row, &font);
});
}
app.update();
app
}
fn count_buttons<C: Component>(app: &mut App) -> usize {
app.world_mut()
.query_filtered::<(), With<C>>()
.iter(app.world())
.count()
}
#[test]
fn touch_tab_bar_has_thumb_trio_plus_menu_and_pause() {
let mut app = bar_only_app(true);
assert_eq!(count_buttons::<UndoButton>(&mut app), 1);
assert_eq!(count_buttons::<DrawButton>(&mut app), 1);
assert_eq!(count_buttons::<HintButton>(&mut app), 1);
assert_eq!(count_buttons::<MenuButton>(&mut app), 1);
assert_eq!(count_buttons::<PauseButton>(&mut app), 1);
// Utility actions live elsewhere on touch: Help in Menu → System,
// modes on the Home screen, New Game on Home's hero button.
assert_eq!(count_buttons::<HelpButton>(&mut app), 0);
assert_eq!(count_buttons::<ModesButton>(&mut app), 0);
assert_eq!(count_buttons::<NewGameButton>(&mut app), 0);
}
/// The glass bar floats: its container must carry a bottom margin (not be
/// docked at 0) both before and after safe-area insets arrive.
#[test]
fn glass_tab_bar_floats_above_bottom_edge() {
let mut app = bar_only_app(true);
let world = app.world_mut();
let mut q = world.query_filtered::<(&Node, &SafeAreaAnchoredBottom), With<HudActionBar>>();
let (node, anchor) = q.single(world).expect("glass bar container exists");
assert!(anchor.base_bottom > 0.0, "bar must float, not dock");
assert_eq!(node.bottom, Val::Px(anchor.base_bottom));
}
/// Draw is the persistently-expanded active pill (user decision
/// 2026-07-15); with `icons: None` every wrapper is pinned expanded via
/// text fallback, so instead assert on the *persistent* flag's effect:
/// all wrappers spawn at full reveal and target full reveal.
#[test]
fn glass_tab_bar_text_fallback_pins_labels_expanded() {
let mut app = bar_only_app(true);
let world = app.world_mut();
let mut q = world.query::<&TabLabelWrap>();
let wraps: Vec<_> = q.iter(world).collect();
assert_eq!(wraps.len(), 4, "Undo/Draw/Hint/Pause each carry a wrapper");
for wrap in wraps {
assert_eq!(wrap.progress, 1.0);
assert_eq!(wrap.target, 1.0);
}
}
/// Focus order must match visual reading order: pill buttons left to
/// right, then the detached Menu circle.
#[test]
fn glass_tab_bar_focus_order_reads_left_to_right() {
let mut app = bar_only_app(true);
for (f, expected) in [
(focusable_for::<UndoButton>(&mut app), 0),
(focusable_for::<DrawButton>(&mut app), 1),
(focusable_for::<HintButton>(&mut app), 2),
(focusable_for::<PauseButton>(&mut app), 3),
(focusable_for::<MenuButton>(&mut app), 4),
] {
assert_eq!(f.group, FocusGroup::Hud);
assert_eq!(f.order, expected);
}
}
/// `expansion_width` is the single source of truth for the label reveal:
/// zero when collapsed, lead + monospace advance when expanded, linear
/// in between.
#[test]
fn expansion_width_is_linear_in_progress() {
assert_eq!(expansion_width(4, 16.0, 0.0), 0.0);
let full = expansion_width(4, 16.0, 1.0);
assert!(full > 4.0 * 16.0 * 0.62, "full width includes the lead gap");
let half = expansion_width(4, 16.0, 0.5);
assert!((half - full / 2.0).abs() < f32::EPSILON * 100.0);
// Out-of-range progress clamps rather than extrapolating.
assert_eq!(expansion_width(4, 16.0, 1.5), full);
}
/// While a committed drag is active the bar must slide down (positive
/// `UiTransform` y — off-screen) and return to identity once the drag
/// ends. Manual time steps make the tween deterministic.
#[test]
fn tab_bar_ducks_during_committed_drag_and_returns() {
use crate::resources::DragState;
use bevy::ui::UiTransform;
let mut app = bar_only_app(true);
set_manual_time_step(&mut app, 0.05);
app.add_systems(Update, duck_tab_bar_during_drag);
app.insert_resource(DragState {
committed: true,
..default()
});
let translation_y_px = |app: &mut App| -> f32 {
let world = app.world_mut();
let mut q = world.query_filtered::<&UiTransform, With<HudActionBar>>();
let t = q.single(world).expect("bar carries UiTransform");
match t.translation.y {
Val::Px(px) => px,
other => panic!("duck must write Val::Px, got {other:?}"),
}
};
// 0.18s slide at 0.05s/frame: fully ducked within 5 frames.
app.update();
app.update();
let mid = translation_y_px(&mut app);
assert!(mid > 0.0, "bar must start sliding down: {mid}");
for _ in 0..5 {
app.update();
}
let ducked = translation_y_px(&mut app);
assert!(
ducked > TAB_BAR_CLEARANCE_PX,
"fully ducked bar must clear its own floating footprint: {ducked}"
);
// Drag ends → bar slides back to identity.
app.world_mut().resource_mut::<DragState>().committed = false;
for _ in 0..8 {
app.update();
}
assert_eq!(translation_y_px(&mut app), 0.0);
}
/// The tween must move at the `MOTION_SLIDE_SECS` rate, never overshoot,
/// and snap instantly under reduce-motion.
#[test]
fn step_expansion_tweens_and_respects_reduce_motion() {
// One 60 fps frame covers 1/(0.18*60) ≈ 9.3% of the reveal.
let one_frame = step_expansion(0.0, 1.0, 1.0 / 60.0, false);
assert!(one_frame > 0.0 && one_frame < 0.2);
// A huge dt clamps at the target instead of overshooting.
assert_eq!(step_expansion(0.0, 1.0, 10.0, false), 1.0);
// Collapse works symmetrically.
assert!(step_expansion(1.0, 0.0, 1.0 / 60.0, false) < 1.0);
// Reduce-motion snaps regardless of dt.
assert_eq!(step_expansion(0.0, 1.0, 0.0, true), 1.0);
}
#[test]
fn desktop_action_bar_keeps_all_seven_and_no_draw() {
let mut app = bar_only_app(false);
for (count, name) in [
(count_buttons::<MenuButton>(&mut app), "Menu"),
(count_buttons::<UndoButton>(&mut app), "Undo"),
(count_buttons::<PauseButton>(&mut app), "Pause"),
(count_buttons::<HelpButton>(&mut app), "Help"),
(count_buttons::<HintButton>(&mut app), "Hint"),
(count_buttons::<ModesButton>(&mut app), "Modes"),
(count_buttons::<NewGameButton>(&mut app), "New Game"),
] {
assert_eq!(count, 1, "desktop bar must keep its {name} button");
}
assert_eq!(
count_buttons::<DrawButton>(&mut app),
0,
"Draw is touch-only (decision 5); desktop draws via stock click / D / Space"
);
}
#[test]
fn draw_button_press_fires_draw_request() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<Messages<DrawRequestEvent>>()
.clear();
app.world_mut().spawn((DrawButton, Interaction::Pressed));
app.update();
let events = app.world().resource::<Messages<DrawRequestEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(
cursor.read(events).count(),
1,
"pressing the Draw button must fire exactly one DrawRequestEvent"
);
}
#[test]
fn draw_button_press_is_noop_while_paused() {
let mut app = headless_app();
app.insert_resource(PausedResource(true));
app.world_mut()
.resource_mut::<Messages<DrawRequestEvent>>()
.clear();
app.world_mut().spawn((DrawButton, Interaction::Pressed));
app.update();
let events = app.world().resource::<Messages<DrawRequestEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(
cursor.read(events).count(),
0,
"the Draw button must be inert while paused (mirrors the stock-click guard)"
);
}
/// The timing contract for hold-to-repeat undo, exercised through the
/// pure boundary function so no wall-clock is involved. Delay = 0.45 s,
/// interval = 0.18 s → boundaries at 0.45, 0.63, 0.81, …
#[test]
fn undo_hold_boundary_math() {
// A tap never repeats.
assert!(!undo_hold_crossed_fire_boundary(0.0, 0.05));
assert!(!undo_hold_crossed_fire_boundary(0.2, 0.44));
// Crossing the initial delay fires the first repeat.
assert!(undo_hold_crossed_fire_boundary(0.44, 0.46));
// Inside the first repeat interval: quiet.
assert!(!undo_hold_crossed_fire_boundary(0.46, 0.62));
// Each interval boundary fires exactly once.
assert!(undo_hold_crossed_fire_boundary(0.62, 0.64));
assert!(!undo_hold_crossed_fire_boundary(0.64, 0.80));
assert!(undo_hold_crossed_fire_boundary(0.80, 0.82));
}
#[test]
fn undo_hold_state_accumulates_and_resets_on_release() {
let mut app = headless_app();
let button = app
.world_mut()
.spawn((UndoButton, Interaction::Pressed))
.id();
app.update();
app.update();
app.update();
assert!(
app.world().resource::<UndoHoldState>().held_secs > 0.0,
"held time must accumulate while the Undo button stays pressed"
);
app.world_mut().entity_mut(button).insert(Interaction::None);
app.update();
assert_eq!(
app.world().resource::<UndoHoldState>().held_secs,
0.0,
"releasing the Undo button must reset the hold state"
);
}
+7 -6
View File
@@ -3,9 +3,9 @@
use super::*; use super::*;
use bevy::window::WindowResized; use bevy::window::WindowResized;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::Suit; use solitaire_core::Suit;
use solitaire_core::{DrawStockConfig, game_state::GameMode}; use solitaire_core::{DrawStockConfig, game_state::GameMode};
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::auto_complete_plugin::AutoCompleteState; use crate::auto_complete_plugin::AutoCompleteState;
@@ -554,11 +554,11 @@ pub(super) fn action_bar_font_size(window_width: f32) -> f32 {
pub(super) fn action_button_metrics() -> (UiRect, Val, Val) { pub(super) fn action_button_metrics() -> (UiRect, Val, Val) {
if USE_TOUCH_UI_LAYOUT { if USE_TOUCH_UI_LAYOUT {
// Tight 3 px horizontal padding keeps the compact buttons narrow; // Tight 3 px horizontal padding (down from 4) trims 14 px off the row
// Phase F trimmed the touch bar to 5 buttons (2 compact + 3 // total across 7 buttons, and a 44 px min_width (down from 52) lets the
// primary), so width pressure is lower than the old 7-button row. // shortest labels ("New", "Help") shrink to their text rather than
// min_height stays at 44 px to preserve the comfortable touch // padding the row out past the 900 logical-px viewport. min_height
// target on the compact buttons. // stays at 44 px to preserve the comfortable touch target.
( (
UiRect::axes(Val::Px(3.0), Val::Px(4.0)), UiRect::axes(Val::Px(3.0), Val::Px(4.0)),
Val::Px(44.0), Val::Px(44.0),
@@ -609,3 +609,4 @@ pub(super) fn resize_action_bar_labels(
font.font_size = new_size; font.font_size = new_size;
} }
} }
+105 -156
View File
@@ -22,15 +22,15 @@ use std::collections::HashMap;
use bevy::ecs::system::SystemParam; use bevy::ecs::system::SystemParam;
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::input::touch::{TouchInput, TouchPhase, Touches}; use bevy::input::touch::{TouchInput, TouchPhase, Touches};
use bevy::math::Vec2; use bevy::math::{Vec2, Vec3};
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::PrimaryWindow; use bevy::window::PrimaryWindow;
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
use bevy::window::{MonitorSelection, WindowMode}; use bevy::window::{MonitorSelection, WindowMode};
use solitaire_core::game_state::GameState;
use solitaire_core::{Card, Suit};
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau}; use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::{Card, Suit};
use solitaire_core::game_state::GameState;
use crate::auto_complete_plugin::AutoCompleteState; use crate::auto_complete_plugin::AutoCompleteState;
use crate::card_animation::tuning::AnimationTuning; use crate::card_animation::tuning::AnimationTuning;
@@ -58,14 +58,6 @@ use crate::touch_selection_plugin::TouchSelectionState;
use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_SUCCESS, STATE_WARNING}; use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_SUCCESS, STATE_WARNING};
use solitaire_core::DrawStockConfig; use solitaire_core::DrawStockConfig;
/// Set wrapping the core pointer/keyboard input chain (clicks, drags, touch,
/// hotkeys). Runs between [`crate::game_plugin::PreMutation`] (it reads the
/// ticked game state) and [`crate::selection_plugin::SelectionKeySet`] (the
/// selection keys check `DragState::is_idle`); request events it emits are
/// consumed at `GameMutation` in the same frame (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct PointerInput;
/// System-set labels used to anchor external systems relative to the touch /// System-set labels used to anchor external systems relative to the touch
/// drag pipeline without duplicating the internal chain ordering. /// drag pipeline without duplicating the internal chain ordering.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
@@ -144,28 +136,11 @@ impl Plugin for InputPlugin {
.add_systems( .add_systems(
Update, Update,
( (
// The three `DrawRequestEvent` writers join the handle_keyboard_core,
// self-ambiguous `DrawRequestWriters` set so the HUD's
// touch Draw button (hud_plugin, Phase F) can write the
// same queue without tripping the ambiguity gate. The
// `.chain()` still orders them relative to each other.
handle_keyboard_core
.in_set(crate::game_plugin::DrawRequestWriters)
.ambiguous_with(crate::game_plugin::DrawRequestWriters)
// N/Z/X/… and U also feed the shared new-game and
// undo request streams (#143).
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
.in_set(crate::game_plugin::UndoRequestWriters)
.ambiguous_with(crate::game_plugin::UndoRequestWriters),
handle_keyboard_hint, handle_keyboard_hint,
handle_keyboard_forfeit, handle_keyboard_forfeit,
handle_stock_click handle_stock_click,
.in_set(crate::game_plugin::DrawRequestWriters) handle_touch_stock_tap,
.ambiguous_with(crate::game_plugin::DrawRequestWriters),
handle_touch_stock_tap
.in_set(crate::game_plugin::DrawRequestWriters)
.ambiguous_with(crate::game_plugin::DrawRequestWriters),
handle_double_click, handle_double_click,
// Mouse drag pipeline. // Mouse drag pipeline.
start_drag, start_drag,
@@ -173,61 +148,29 @@ impl Plugin for InputPlugin {
end_drag.before(GameMutation), end_drag.before(GameMutation),
// Touch drag pipeline (parallel path through DragState). // Touch drag pipeline (parallel path through DragState).
touch_start_drag.in_set(TouchDragSet::AfterStartDrag), touch_start_drag.in_set(TouchDragSet::AfterStartDrag),
// Moves the dragged card's Transform; UiTextFx members touch_follow_drag,
// animate chrome entities only — disjoint domains Bevy
// can't prove (#143).
touch_follow_drag.ambiguous_with(crate::ui_theme::UiTextFx),
handle_double_tap, // before touch_end_drag: reads drag state pre-clear handle_double_tap, // before touch_end_drag: reads drag state pre-clear
touch_end_drag touch_end_drag
.after(TouchDragSet::BeforeEndDrag) .after(TouchDragSet::BeforeEndDrag)
.before(GameMutation), .before(GameMutation),
// Reads StateChangedEvent via cursor — a reset one frame
// after the mutation is invisible (the cycle index only
// matters on the next H press), so it rides at the end
// of the input chain instead of chasing every
// StateChanged writer (#143).
// It also drains the new-game request stream via its own
// cursor, so it joins the writer club the same way
// enqueue_toasts does (#143).
reset_hint_cycle_on_state_change
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
) )
.chain() .chain(),
.in_set(PointerInput),
) )
.configure_sets( .add_systems(Update, reset_hint_cycle_on_state_change);
Update,
PointerInput
.after(crate::game_plugin::PreMutation)
.before(crate::selection_plugin::SelectionKeySet)
.before(crate::ui_focus::FocusKeys),
);
// F11 fullscreen toggle is desktop-only; Android windows are always full-screen. // F11 fullscreen toggle is desktop-only; Android windows are always full-screen.
// It writes only `Window.mode`; no system reads the mode, and the
// geometry change it triggers arrives via WindowResized events on a
// later frame — order against Window readers is provably irrelevant
// (#143).
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
app.add_systems(Update, handle_fullscreen.ambiguous_with_all()); app.add_systems(Update, handle_fullscreen);
app app
// Async hint pipeline: state-change drop runs before the // Async hint pipeline: state-change drop runs before the
// poll system so a move applied this frame cancels any // poll system so a move applied this frame cancels any
// in-flight task before its result can be surfaced. The poll // in-flight task before its result can be surfaced.
// emits the hint toast and the pile-highlight event the board
// painters consume this frame (#143).
.add_systems( .add_systems(
Update, Update,
( (
crate::pending_hint::drop_pending_hint_on_state_change, crate::pending_hint::drop_pending_hint_on_state_change,
crate::pending_hint::poll_pending_hint_task crate::pending_hint::poll_pending_hint_task,
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
) )
.chain() .chain(),
.in_set(crate::pending_hint::PendingHint)
.after(GameMutation)
.before(crate::auto_complete_plugin::AutoComplete),
); );
} }
} }
@@ -261,7 +204,6 @@ fn handle_keyboard_core(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
progress: Option<Res<ProgressResource>>, progress: Option<Res<ProgressResource>>,
drag: Res<DragState>,
mut ev: CoreKeyboardMessages<'_>, mut ev: CoreKeyboardMessages<'_>,
mut time_attack: Option<ResMut<TimeAttackResource>>, mut time_attack: Option<ResMut<TimeAttackResource>>,
selection: Option<Res<SelectionState>>, selection: Option<Res<SelectionState>>,
@@ -274,19 +216,6 @@ fn handle_keyboard_core(
return; return;
} }
// Mutual exclusion with pointer drags — mirrors `handle_selection_keys`.
// Undo / draw / new-game during an active mouse or touch drag would fire
// a StateChangedEvent whose card re-sync inserts a `CardAnim` on the
// dragged cards, fighting `follow_drag`'s per-frame Transform writes and
// leaving `DragState` origin indices stale against the mutated state.
// The keyboard-drag sentinel may proceed: `clear_selection_on_state_change`
// already drops that lift cleanly when the state moves.
if !drag.is_idle()
&& drag.active_touch_id != Some(crate::selection_plugin::KEYBOARD_DRAG_TOUCH_ID)
{
return;
}
// During replay playback (Playing or Completed) all game-input shortcuts // During replay playback (Playing or Completed) all game-input shortcuts
// are suppressed. The replay overlay owns Space (pause/resume) and the // are suppressed. The replay overlay owns Space (pause/resume) and the
// arrow keys (step). Letting game input through would mutate // arrow keys (step). Letting game input through would mutate
@@ -465,10 +394,7 @@ pub fn emit_hint_visuals(
// Find the top face-up card in the source pile and highlight it. // Find the top face-up card in the source pile and highlight it.
let source_cards = pile_cards(game, from); let source_cards = pile_cards(game, from);
let top_card = source_cards let top_card = source_cards.last().filter(|(_, face_up)| *face_up).map(|(c, _)| c.clone());
.last()
.filter(|(_, face_up)| *face_up)
.map(|(c, _)| c.clone());
if let Some(card) = top_card { if let Some(card) = top_card {
for (entity, card_entity, mut sprite) in card_entities.iter_mut() { for (entity, card_entity, mut sprite) in card_entities.iter_mut() {
if card_entity.card == card { if card_entity.card == card {
@@ -533,19 +459,11 @@ pub fn emit_hint_visuals(
fn handle_keyboard_forfeit( fn handle_keyboard_forfeit(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
drag: Res<DragState>,
mut requests: MessageWriter<ForfeitRequestEvent>, mut requests: MessageWriter<ForfeitRequestEvent>,
) { ) {
if paused.is_some_and(|p| p.0) { if paused.is_some_and(|p| p.0) {
return; return;
} }
// Same pointer-drag exclusion as `handle_keyboard_core` — forfeiting
// mid-drag would mutate the game state under the dragged cards.
if !drag.is_idle()
&& drag.active_touch_id != Some(crate::selection_plugin::KEYBOARD_DRAG_TOUCH_ID)
{
return;
}
if !keys.just_pressed(KeyCode::KeyG) { if !keys.just_pressed(KeyCode::KeyG) {
return; return;
} }
@@ -913,7 +831,9 @@ fn end_drag(
let origin_cards = pile_cards(&game.0, &origin); let origin_cards = pile_cards(&game.0, &origin);
if !origin_cards.is_empty() { if !origin_cards.is_empty() {
for card in &drag.cards { for card in &drag.cards {
let Some(stack_index) = origin_cards.iter().position(|(c, _)| c == card) else { let Some(stack_index) =
origin_cards.iter().position(|(c, _)| c == card)
else {
continue; continue;
}; };
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index); let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
@@ -1150,7 +1070,8 @@ fn touch_end_drag(
let origin_cards = pile_cards(&game.0, &origin); let origin_cards = pile_cards(&game.0, &origin);
if !origin_cards.is_empty() { if !origin_cards.is_empty() {
for card in &drag.cards { for card in &drag.cards {
let Some(stack_index) = origin_cards.iter().position(|(c, _)| c == card) let Some(stack_index) =
origin_cards.iter().position(|(c, _)| c == card)
else { else {
continue; continue;
}; };
@@ -1253,8 +1174,7 @@ fn card_position(
y_offset -= layout.card_size.y * step; y_offset -= layout.card_size.y * step;
} }
Vec2::new(base.x, base.y + y_offset) Vec2::new(base.x, base.y + y_offset)
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree } else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree {
{
// In Draw-Three mode the top 3 waste cards are fanned in X to match // In Draw-Three mode the top 3 waste cards are fanned in X to match
// card_plugin::card_positions(). Hit-testing uses the same `waste_fan_step` // card_plugin::card_positions(). Hit-testing uses the same `waste_fan_step`
// so clicking the visually rightmost (top) card actually registers — a // so clicking the visually rightmost (top) card actually registers — a
@@ -1328,10 +1248,7 @@ fn find_draggable_at(
} }
(i, i + 1) (i, i + 1)
}; };
let cards: Vec<Card> = pile_cards[start..end] let cards: Vec<Card> = pile_cards[start..end].iter().map(|(c, _)| c.clone()).collect();
.iter()
.map(|(c, _)| c.clone())
.collect();
return Some((pile, start, cards)); return Some((pile, start, cards));
} }
} }
@@ -1449,37 +1366,17 @@ pub fn best_tableau_destination_for_stack(
None None
} }
/// Decide the auto-move for the face-up run headed by the clicked/tapped card.
///
/// The move covers **exactly** `run_len` cards — the run from the clicked
/// card to the top of its pile. A lone top card goes to its best foundation
/// (or tableau) destination; a multi-card run goes whole to the best tableau
/// column. Runs larger or smaller than the clicked one are never considered.
///
/// Returns `(destination, count)`, or `None` when the clicked run has no
/// legal destination.
pub fn auto_move_for_run(
clicked_card: &Card,
pile: &KlondikePile,
game: &GameState,
run_len: usize,
) -> Option<(KlondikePile, usize)> {
if run_len == 1 {
best_destination(clicked_card, game).map(|dest| (dest, 1))
} else {
best_tableau_destination_for_stack(clicked_card, pile, game, run_len)
}
}
/// System that detects double-clicks on face-up cards and fires `MoveRequestEvent` /// System that detects double-clicks on face-up cards and fires `MoveRequestEvent`
/// to the best legal destination. /// to the best legal destination.
/// ///
/// The move covers exactly the face-up run headed by the clicked card — /// Move priority:
/// see [`auto_move_for_run`]. /// 1. Move the single **top** card to its best foundation (or tableau) destination.
/// 2. If no single-card move exists and the clicked card is the base of a
/// multi-card face-up stack, move the whole stack to the best tableau column.
/// ///
/// When the clicked run has no legal destination, fires `MoveRejectedEvent` /// When a multi-card stack double-click finds no legal destination (Priority 2
/// with `from == to == pile` so the invalid-move sound plays and the source /// returns `None`), fires `MoveRejectedEvent` with `from == to == pile` so the
/// pile cards shake as feedback. /// invalid-move sound plays and the source pile cards shake as feedback.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn handle_double_click( fn handle_double_click(
buttons: Res<ButtonInput<MouseButton>>, buttons: Res<ButtonInput<MouseButton>>,
@@ -1510,11 +1407,7 @@ fn handle_double_click(
return; return;
}; };
// The clicked card heads the run and keys the double-click: two clicks // The topmost card in the draggable run — used as the double-click key.
// on different cards of the same stack are not a double-click.
let Some(clicked_card) = card_ids.first() else {
return;
};
let Some(top_card) = card_ids.last() else { let Some(top_card) = card_ids.last() else {
return; return;
}; };
@@ -1529,15 +1422,31 @@ fn handle_double_click(
let now = time.elapsed_secs(); let now = time.elapsed_secs();
let prev = last_click let prev = last_click
.get(clicked_card) .get(top_card)
.copied() .copied()
.unwrap_or(f32::NEG_INFINITY); .unwrap_or(f32::NEG_INFINITY);
if now - prev <= DOUBLE_CLICK_WINDOW { if now - prev <= DOUBLE_CLICK_WINDOW {
// Double-click confirmed. // Double-click confirmed.
last_click.remove(clicked_card); last_click.remove(top_card);
if let Some((dest, count)) = auto_move_for_run(clicked_card, &pile, &game.0, card_ids.len()) // Priority 1: move the single top card (foundation preferred, then tableau).
if let Some(dest) = best_destination(top_card, &game.0) {
moves.write(MoveRequestEvent {
from: pile,
to: dest,
count: 1,
});
return;
}
// Priority 2: if the player clicked the base of a multi-card face-up
// stack (card_ids.len() > 1), try moving the whole stack to another
// tableau column.
if card_ids.len() > 1
&& let Some((bottom_card, _)) = pile_cards.get(stack_index)
&& let Some((dest, count)) =
best_tableau_destination_for_stack(bottom_card, &pile, &game.0, card_ids.len())
{ {
moves.write(MoveRequestEvent { moves.write(MoveRequestEvent {
from: pile, from: pile,
@@ -1547,10 +1456,14 @@ fn handle_double_click(
return; return;
} }
// No legal destination for the clicked run — play the invalid-move // Both priorities failed — play the invalid-move sound and shake
// sound and shake the source pile as feedback. `MoveRejectedEvent` // the source pile as feedback. `MoveRejectedEvent` with
// with `from == to` routes the shake to the source pile (which // `from == to` routes the shake to the source pile (which
// `start_shake_anim` reads from `ev.to`). // `start_shake_anim` reads from `ev.to`). Pre-fix, this branch
// only fired for multi-card stacks, so a double-click on a
// single card with no legal destination did nothing — no
// sound, no shake. Now both single-card and stack misses get
// the same feedback.
rejected.write(MoveRejectedEvent { rejected.write(MoveRejectedEvent {
from: pile, from: pile,
to: pile, to: pile,
@@ -1558,7 +1471,7 @@ fn handle_double_click(
}); });
} else { } else {
// Single click — record the time. // Single click — record the time.
last_click.insert(clicked_card.clone(), now); last_click.insert(top_card.clone(), now);
} }
} }
@@ -1574,9 +1487,10 @@ fn handle_double_click(
/// `cards`, and `origin_pile`; once `touch_end_drag` fires those fields /// `cards`, and `origin_pile`; once `touch_end_drag` fires those fields
/// are cleared and the tap/drag distinction is permanently lost. /// are cleared and the tap/drag distinction is permanently lost.
/// ///
/// The move covers exactly the face-up run headed by the tapped card — /// Move priority:
/// see [`auto_move_for_run`]. Fires `MoveRejectedEvent` for audio + shake /// 1. Single top card to its best foundation (or tableau).
/// feedback when the tapped run has no legal destination. /// 2. Whole face-up run to best tableau column when no single-card move exists.
/// 3. `MoveRejectedEvent` for audio + shake feedback when no legal move found.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn handle_double_tap( fn handle_double_tap(
mut touch_events: MessageReader<TouchInput>, mut touch_events: MessageReader<TouchInput>,
@@ -1636,7 +1550,9 @@ fn handle_double_tap(
return; return;
} }
let Some((_, found_face_up)) = pile_cards.iter().find(|(c, _)| c == top_card) else { let Some((found_card, found_face_up)) =
pile_cards.iter().find(|(c, _)| c == top_card)
else {
return; return;
}; };
if !*found_face_up { if !*found_face_up {
@@ -1672,27 +1588,53 @@ fn handle_double_tap(
// --- One-tap auto-move (original behaviour) --- // --- One-tap auto-move (original behaviour) ---
// Move exactly the run headed by the tapped card. // Priority 1: move single top card.
if let Some(tapped_card) = drag.cards.first() if let Some(dest) = best_destination(found_card, &game.0) {
&& let Some((dest, count)) =
auto_move_for_run(tapped_card, tapped_pile, &game.0, drag.cards.len())
{
for (entity, ce, mut sprite) in card_sprites.iter_mut() { for (entity, ce, mut sprite) in card_sprites.iter_mut() {
if drag.cards.contains(&ce.card) { if ce.card == *top_card {
sprite.color = STATE_SUCCESS; sprite.color = STATE_SUCCESS;
commands.entity(entity).insert(HintHighlight { commands.entity(entity).insert(HintHighlight {
remaining: DOUBLE_TAP_FLASH_SECS, remaining: DOUBLE_TAP_FLASH_SECS,
}); });
break;
} }
} }
moves.write(MoveRequestEvent { moves.write(MoveRequestEvent {
from: *tapped_pile, from: *tapped_pile,
to: dest, to: dest,
count, count: 1,
}); });
return; return;
} }
// Priority 2: move whole face-up stack to best tableau column.
if drag.cards.len() > 1 {
let stack_index = pile_cards.len() - drag.cards.len();
if let Some((bottom_card, _)) = pile_cards.get(stack_index)
&& let Some((dest, count)) = best_tableau_destination_for_stack(
bottom_card,
tapped_pile,
&game.0,
drag.cards.len(),
)
{
for (entity, ce, mut sprite) in card_sprites.iter_mut() {
if drag.cards.contains(&ce.card) {
sprite.color = STATE_SUCCESS;
commands.entity(entity).insert(HintHighlight {
remaining: DOUBLE_TAP_FLASH_SECS,
});
}
}
moves.write(MoveRequestEvent {
from: *tapped_pile,
to: dest,
count,
});
return;
}
}
rejected.write(MoveRejectedEvent { rejected.write(MoveRejectedEvent {
from: *tapped_pile, from: *tapped_pile,
to: *tapped_pile, to: *tapped_pile,
@@ -1841,6 +1783,8 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
} }
} }
const fn tableau_number(tableau: Tableau) -> u8 { const fn tableau_number(tableau: Tableau) -> u8 {
match tableau { match tableau {
Tableau::Tableau1 => 1, Tableau::Tableau1 => 1,
@@ -1861,5 +1805,10 @@ pub fn find_hint(game: &GameState) -> Option<(KlondikePile, KlondikePile)> {
all_hints(game).into_iter().next() all_hints(game).into_iter().next()
} }
// `Vec3` is referenced only via the `DRAG_Z` constant; keep the import silenced
// when the compiler can't see it used.
#[allow(dead_code)]
const _VEC3_REFERENCED: Option<Vec3> = None;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
+35 -267
View File
@@ -89,11 +89,9 @@ fn find_draggable_picks_waste_top_with_multiple_cards() {
let mut game = GameState::new(42, DrawStockConfig::DrawOne); let mut game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
clear_test_piles(&mut game); clear_test_piles(&mut game);
let waste = vec![ let waste = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
Card::new(Deck::Deck1, Suit::Clubs, Rank::Two), Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five), Card::new(Deck::Deck1, Suit::Spades, Rank::Nine)];
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine),
];
game.set_test_waste_cards(waste.clone()); game.set_test_waste_cards(waste.clone());
let top_index = waste.len() - 1; // 2 = the visible top let top_index = waste.len() - 1; // 2 = the visible top
@@ -101,11 +99,7 @@ fn find_draggable_picks_waste_top_with_multiple_cards() {
let result = find_draggable_at(top_pos, &game, &layout).expect("waste top is draggable"); let result = find_draggable_at(top_pos, &game, &layout).expect("waste top is draggable");
assert_eq!(result.0, KlondikePile::Stock, "origin is the waste pile"); assert_eq!(result.0, KlondikePile::Stock, "origin is the waste pile");
assert_eq!(result.1, top_index, "picks the top index, not the buffer"); assert_eq!(result.1, top_index, "picks the top index, not the buffer");
assert_eq!( assert_eq!(result.2, vec![waste[top_index].clone()], "drags the top card only");
result.2,
vec![waste[top_index].clone()],
"drags the top card only"
);
} }
#[test] #[test]
@@ -182,7 +176,8 @@ fn find_draggable_skips_face_down_cards() {
// face-up card, but the iterator should skip face-down cards and // face-up card, but the iterator should skip face-down cards and
// the cursor sits above the face-up card's AABB, so the result // the cursor sits above the face-up card's AABB, so the result
// is None. // is None.
let face_down_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0); let face_down_pos =
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
let result = find_draggable_at(face_down_pos, &game, &layout); let result = find_draggable_at(face_down_pos, &game, &layout);
assert!(result.is_none(), "face-down cards should not be draggable"); assert!(result.is_none(), "face-down cards should not be draggable");
} }
@@ -200,7 +195,8 @@ fn find_draggable_hits_face_up_card_with_face_down_cards_above_it() {
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card // Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at // sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
// base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre. // base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre.
let face_up_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6); let face_up_pos =
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
let result = find_draggable_at(face_up_pos, &game, &layout) let result = find_draggable_at(face_up_pos, &game, &layout)
.expect("clicking the face-up card's visible centre must initiate a drag"); .expect("clicking the face-up card's visible centre must initiate a drag");
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7)); assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
@@ -217,14 +213,18 @@ fn find_draggable_returns_run_when_picking_mid_stack() {
let king = Card::new(D::Deck1, Suit::Spades, Rank::King); let king = Card::new(D::Deck1, Suit::Spades, Rank::King);
let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen); let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen);
let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack); let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack);
game.set_test_tableau_cards(Tableau::Tableau1, vec![king, queen.clone(), jack.clone()]); game.set_test_tableau_cards(
Tableau::Tableau1,
vec![king, queen.clone(), jack.clone()],
);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// The Queen's geometric center (index 1) is inside the Jack's bounding box // The Queen's geometric center (index 1) is inside the Jack's bounding box
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the // (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
// Queen we click in her visible strip: the 0.25h band above the Jack's top // Queen we click in her visible strip: the 0.25h band above the Jack's top
// edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h. // edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h.
let queen_center = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1); let queen_center =
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375); let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375);
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit"); let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1)); assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
@@ -322,11 +322,7 @@ fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() {
); );
let (pile, _start, ids) = result.unwrap(); let (pile, _start, ids) = result.unwrap();
assert_eq!(pile, KlondikePile::Stock); assert_eq!(pile, KlondikePile::Stock);
assert_eq!( assert_eq!(ids, vec![four_clubs], "only the top card is draggable from waste");
ids,
vec![four_clubs],
"only the top card is draggable from waste"
);
} }
#[test] #[test]
@@ -429,118 +425,6 @@ fn best_tableau_destination_for_stack_returns_none_when_no_legal_move() {
); );
} }
// -----------------------------------------------------------------------
// auto_move_for_run pure-function tests (issue #158)
// -----------------------------------------------------------------------
//
// These need real positions — `can_move_cards` validates against the live
// session, not the `set_test_*` overlays. Seeds 51 and 145 both deal an
// Ace and its Two on tableau tops plus an opposite-color Three elsewhere,
// letting two moves build a face-up [Three, Two] run whose top card is
// foundation-eligible (the bait the pre-#158 code would take).
/// Deal `seed`, send the Ace on tableau 1 to its foundation, then stack the
/// matching Two from `two_from` onto the opposite-color Three on `run_on`.
/// Returns the game with a 2-card face-up run on `run_on`.
fn deal_run_with_foundation_bait(seed: u64, two_from: Tableau, run_on: Tableau) -> GameState {
let mut game = GameState::new(seed, DrawStockConfig::DrawOne);
let (ace, _) = game
.pile(KlondikePile::Tableau(Tableau::Tableau1))
.last()
.cloned()
.expect("seed deals a card on tableau 1");
let foundation = best_destination(&ace, &game).expect("ace has a foundation home");
game.move_cards(KlondikePile::Tableau(Tableau::Tableau1), foundation, 1)
.expect("ace moves to foundation");
game.move_cards(
KlondikePile::Tableau(two_from),
KlondikePile::Tableau(run_on),
1,
)
.expect("two stacks onto three");
game
}
#[test]
fn auto_move_for_run_moves_exact_clicked_run_not_top_card() {
let game = deal_run_with_foundation_bait(51, Tableau::Tableau6, Tableau::Tableau5);
let run_pile = KlondikePile::Tableau(Tableau::Tableau5);
let cards = game.pile(run_pile);
let (top, _) = cards.last().cloned().expect("run pile has cards");
let (clicked, _) = cards[cards.len() - 2].clone();
// The bait: the lone top card has a foundation move available.
assert!(
matches!(
best_destination(&top, &game),
Some(KlondikePile::Foundation(_))
),
"precondition: run top card must be foundation-eligible"
);
// Clicking the run base must move exactly the 2-card run to a tableau.
match auto_move_for_run(&clicked, &run_pile, &game, 2) {
Some((KlondikePile::Tableau(dest), 2)) => assert_ne!(dest, Tableau::Tableau5),
other => panic!("expected a whole-run tableau move, got {other:?}"),
}
}
#[test]
fn auto_move_for_run_rejects_when_clicked_run_cannot_move() {
let game = deal_run_with_foundation_bait(145, Tableau::Tableau4, Tableau::Tableau7);
let run_pile = KlondikePile::Tableau(Tableau::Tableau7);
let cards = game.pile(run_pile);
let (top, _) = cards.last().cloned().expect("run pile has cards");
let (clicked, _) = cards[cards.len() - 2].clone();
assert!(
matches!(
best_destination(&top, &game),
Some(KlondikePile::Foundation(_))
),
"precondition: run top card must be foundation-eligible"
);
// The 2-card run has no legal home — the top card's foundation move
// must NOT be taken as a substitute.
assert_eq!(
auto_move_for_run(&clicked, &run_pile, &game, 2),
None,
"an immovable clicked run must not fall back to a top-card move"
);
}
#[test]
fn auto_move_for_run_single_card_prefers_foundation() {
// Seed 51 after the Ace reaches the foundation: the Two on tableau 6
// is a lone face-up card that could go to the foundation OR onto the
// Three on tableau 5. Foundation must win.
let mut game = GameState::new(51, DrawStockConfig::DrawOne);
let (ace, _) = game
.pile(KlondikePile::Tableau(Tableau::Tableau1))
.last()
.cloned()
.expect("seed 51 deals a card on tableau 1");
let foundation = best_destination(&ace, &game).expect("ace has a foundation home");
game.move_cards(KlondikePile::Tableau(Tableau::Tableau1), foundation, 1)
.expect("ace moves to foundation");
let two_pile = KlondikePile::Tableau(Tableau::Tableau6);
let (two, _) = game.pile(two_pile).last().cloned().expect("two on top");
assert!(
game.can_move_cards(&two_pile, &KlondikePile::Tableau(Tableau::Tableau5), 1),
"precondition: a tableau destination also exists"
);
assert!(
matches!(
auto_move_for_run(&two, &two_pile, &game, 1),
Some((KlondikePile::Foundation(_), 1))
),
"a lone top card still prefers the foundation"
);
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Task #28 — find_hint pure-function tests // Task #28 — find_hint pure-function tests
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -674,7 +558,8 @@ fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
fn rejected_drag_animation_targets_origin_resting_position() { fn rejected_drag_animation_targets_origin_resting_position() {
let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen
let target_pos = Vec2::new(123.5, -50.0); // origin pile slot let target_pos = Vec2::new(123.5, -50.0); // origin pile slot
let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3); let anim =
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
assert!( assert!(
(anim.end - target_pos).length() < 1e-6, (anim.end - target_pos).length() < 1e-6,
@@ -692,7 +577,8 @@ fn rejected_drag_animation_targets_origin_resting_position() {
fn rejected_drag_animation_starts_from_drag_position() { fn rejected_drag_animation_starts_from_drag_position() {
let drag_pos = Vec2::new(640.0, 200.0); let drag_pos = Vec2::new(640.0, 200.0);
let target_pos = Vec2::new(80.0, -120.0); let target_pos = Vec2::new(80.0, -120.0);
let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0); let anim =
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
assert!( assert!(
(anim.start - drag_pos).length() < 1e-6, (anim.start - drag_pos).length() < 1e-6,
@@ -715,8 +601,12 @@ fn rejected_drag_animation_starts_from_drag_position() {
/// the call site honest. /// the call site honest.
#[test] #[test]
fn rejected_drag_animation_uses_correct_duration() { fn rejected_drag_animation_uses_correct_duration() {
let anim = let anim = build_drag_reject_animation(
build_drag_reject_animation(Vec2::new(640.0, 200.0), DRAG_Z, Vec2::new(80.0, -120.0), 0); Vec2::new(640.0, 200.0),
DRAG_Z,
Vec2::new(80.0, -120.0),
0,
);
assert!( assert!(
(anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6, (anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6,
"drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \ "drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \
@@ -730,8 +620,12 @@ fn rejected_drag_animation_uses_correct_duration() {
/// jittery rather than forgiving. /// jittery rather than forgiving.
#[test] #[test]
fn rejected_drag_animation_uses_responsive_curve() { fn rejected_drag_animation_uses_responsive_curve() {
let anim = let anim = build_drag_reject_animation(
build_drag_reject_animation(Vec2::new(640.0, 200.0), DRAG_Z, Vec2::new(80.0, -120.0), 0); Vec2::new(640.0, 200.0),
DRAG_Z,
Vec2::new(80.0, -120.0),
0,
);
assert_eq!( assert_eq!(
anim.curve, anim.curve,
MotionCurve::Responsive, MotionCurve::Responsive,
@@ -789,16 +683,10 @@ fn pressing_h_spawns_pending_hint_task() {
app.init_resource::<HintSolverConfig>(); app.init_resource::<HintSolverConfig>();
app.init_resource::<crate::pending_hint::PendingHintTask>(); app.init_resource::<crate::pending_hint::PendingHintTask>();
app.init_resource::<ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
app.insert_resource(LayoutResource(compute_layout( app.insert_resource(LayoutResource(
Vec2::new(1280.0, 800.0), compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true),
0.0, ));
0.0, app.insert_resource(GameStateResource(GameState::new(42, DrawStockConfig::DrawOne)));
true,
)));
app.insert_resource(GameStateResource(GameState::new(
42,
DrawStockConfig::DrawOne,
)));
app.add_systems(Update, handle_keyboard_hint); app.add_systems(Update, handle_keyboard_hint);
// Simulate the H key being pressed this frame. // Simulate the H key being pressed this frame.
@@ -817,123 +705,3 @@ fn pressing_h_spawns_pending_hint_task() {
"pressing H must spawn an async hint task", "pressing H must spawn an async hint task",
); );
} }
// -----------------------------------------------------------------------
// Pointer-drag / keyboard mutual exclusion.
//
// A state mutation fired while `follow_drag` is writing the dragged cards'
// transforms every frame would make the card re-sync insert a `CardAnim`
// on the same entities (two systems fighting over `Transform`) and leave
// `DragState`'s origin indices stale. `handle_keyboard_core` and
// `handle_keyboard_forfeit` therefore swallow game-mutating shortcuts
// while a mouse or touch drag is live, mirroring `handle_selection_keys`.
// The keyboard-lift sentinel is exempt: `clear_selection_on_state_change`
// drops that lift cleanly when the state moves.
// -----------------------------------------------------------------------
fn keyboard_core_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_message::<UndoRequestEvent>();
app.add_message::<NewGameRequestEvent>();
app.add_message::<InfoToastEvent>();
app.add_message::<DrawRequestEvent>();
app.add_message::<StartZenRequestEvent>();
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<DragState>();
app.add_systems(Update, handle_keyboard_core);
app
}
fn press_key(app: &mut App, key: KeyCode) {
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(key);
input.clear();
input.press(key);
}
fn message_count<M: Message>(app: &App) -> usize {
let messages = app.world().resource::<Messages<M>>();
let mut cursor = messages.get_cursor();
cursor.read(messages).count()
}
fn set_drag(app: &mut App, active_touch_id: Option<u64>) {
let mut drag = app.world_mut().resource_mut::<DragState>();
drag.cards = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two)];
drag.committed = true;
drag.active_touch_id = active_touch_id;
}
#[test]
fn u_key_fires_undo_when_no_drag_active() {
let mut app = keyboard_core_app();
press_key(&mut app, KeyCode::KeyU);
app.update();
assert_eq!(
message_count::<UndoRequestEvent>(&app),
1,
"U with an idle DragState must fire UndoRequestEvent",
);
}
#[test]
fn u_key_ignored_during_committed_mouse_drag() {
let mut app = keyboard_core_app();
set_drag(&mut app, None); // mouse drag
press_key(&mut app, KeyCode::KeyU);
app.update();
assert_eq!(
message_count::<UndoRequestEvent>(&app),
0,
"U during a mouse drag must be swallowed",
);
}
#[test]
fn draw_key_ignored_during_touch_drag() {
let mut app = keyboard_core_app();
set_drag(&mut app, Some(7)); // real touch id
press_key(&mut app, KeyCode::KeyD);
app.update();
assert_eq!(
message_count::<DrawRequestEvent>(&app),
0,
"D during a touch drag must be swallowed",
);
}
#[test]
fn u_key_allowed_during_keyboard_lift() {
let mut app = keyboard_core_app();
set_drag(
&mut app,
Some(crate::selection_plugin::KEYBOARD_DRAG_TOUCH_ID),
);
press_key(&mut app, KeyCode::KeyU);
app.update();
assert_eq!(
message_count::<UndoRequestEvent>(&app),
1,
"the keyboard-lift sentinel must not block core shortcuts",
);
}
#[test]
fn g_key_forfeit_ignored_during_mouse_drag() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_message::<ForfeitRequestEvent>();
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<DragState>();
app.add_systems(Update, handle_keyboard_forfeit);
set_drag(&mut app, None);
press_key(&mut app, KeyCode::KeyG);
app.update();
assert_eq!(
message_count::<ForfeitRequestEvent>(&app),
0,
"G during a mouse drag must be swallowed",
);
}
+41 -69
View File
@@ -24,13 +24,13 @@ use crate::font_plugin::FontResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath}; use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::sync_plugin::SyncProviderResource; use crate::sync_plugin::SyncProviderResource;
use crate::ui_modal::{ use crate::ui_modal::{
ButtonVariant, ModalScrim, ScrimDismissible, spawn_empty_state, spawn_modal, ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_actions, spawn_modal_button, spawn_modal_header, spawn_modal_button, spawn_modal_header,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, TEXT_DISABLED, TEXT_PRIMARY, ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, TEXT_DISABLED, TEXT_PRIMARY,
TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
Z_PAUSE_DIALOG, Z_MODAL_PANEL, Z_PAUSE_DIALOG,
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -158,38 +158,18 @@ impl Plugin for LeaderboardPlugin {
poll_leaderboard_fetch, poll_leaderboard_fetch,
update_leaderboard_panel, update_leaderboard_panel,
handle_opt_in_button, handle_opt_in_button,
// The polls share the warning-toast stream with the poll_opt_in_task,
// daily-expiry check (drained wholesale, #143).
poll_opt_in_task
.in_set(crate::game_plugin::WarningToastWriters)
.ambiguous_with(crate::game_plugin::WarningToastWriters),
handle_opt_out_button, handle_opt_out_button,
poll_opt_out_task poll_opt_out_task,
.in_set(crate::game_plugin::WarningToastWriters)
.ambiguous_with(crate::game_plugin::WarningToastWriters),
handle_set_display_name_button, handle_set_display_name_button,
handle_display_name_text_input, handle_display_name_text_input,
handle_display_name_confirm, handle_display_name_confirm,
handle_display_name_cancel, handle_display_name_cancel,
// Writes one marker-filtered Text label — disjoint from update_leaderboard_public_name_label,
// the HUD/stats text painters (UiTextFx club, #143).
update_leaderboard_public_name_label
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
) )
.chain() .chain(),
// Opt-in state and display name live in
// SettingsResource — the whole chain joins the
// screen-plugin settings club (#143).
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
) )
.add_systems( .add_systems(Update, scroll_leaderboard_panel);
Update,
scroll_leaderboard_panel
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
);
} }
} }
@@ -363,21 +343,13 @@ fn scroll_leaderboard_panel(
} }
} }
/// Done click or Esc dismisses the leaderboard (Phase C dismissal
/// audit). Esc only fires when the leaderboard is the topmost modal —
/// with the display-name dialog stacked on top, that dialog owns Esc.
fn handle_leaderboard_close_button( fn handle_leaderboard_close_button(
mut commands: Commands, mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
close_buttons: Query<&Interaction, (With<LeaderboardCloseButton>, Changed<Interaction>)>, close_buttons: Query<&Interaction, (With<LeaderboardCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<LeaderboardScreen>>, screens: Query<Entity, With<LeaderboardScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<LeaderboardScreen>)>,
mut closed_flag: ResMut<ClosedThisFrame>, mut closed_flag: ResMut<ClosedThisFrame>,
) { ) {
let clicked = close_buttons.iter().any(|i| *i == Interaction::Pressed); if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
let esc =
keys.just_pressed(KeyCode::Escape) && !screens.is_empty() && other_modal_scrims.is_empty();
if !clicked && !esc {
return; return;
} }
for entity in &screens { for entity in &screens {
@@ -556,6 +528,11 @@ fn spawn_leaderboard_screen(
font_size: TYPE_CAPTION, font_size: TYPE_CAPTION,
..default() ..default()
}; };
let font_status = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY_LG,
..default()
};
let font_row = TextFont { let font_row = TextFont {
font: font_handle.clone(), font: font_handle.clone(),
font_size: TYPE_BODY, font_size: TYPE_BODY,
@@ -662,31 +639,30 @@ fn spawn_leaderboard_screen(
.with_children(|body| { .with_children(|body| {
match data { match data {
LeaderboardResource::Idle => { LeaderboardResource::Idle => {
spawn_empty_state( body.spawn((
body, Text::new("Fetching\u{2026}"),
"\u{21BB}", font_status.clone(),
"Fetching the leaderboard\u{2026}", TextColor(STATE_INFO),
None, ));
font_res,
);
} }
LeaderboardResource::Error(_) => { LeaderboardResource::Error(_) => {
spawn_empty_state( body.spawn((
body, Text::new("Couldn't reach the leaderboard. Try again later."),
"!", font_status.clone(),
"Couldn't reach the leaderboard.", TextColor(TEXT_SECONDARY),
Some("Try again later."), ));
font_res,
);
} }
LeaderboardResource::Loaded(rows) if rows.is_empty() => { LeaderboardResource::Loaded(rows) if rows.is_empty() => {
spawn_empty_state( body.spawn((
body, Text::new("Be the first on the leaderboard."),
"\u{2660}", font_status.clone(),
"Be the first on the leaderboard.", TextColor(TEXT_PRIMARY),
Some("Win a game and opt in to appear here."), ));
font_res, body.spawn((
); Text::new("Win a game and opt in to appear here."),
font_row.clone(),
TextColor(TEXT_SECONDARY),
));
} }
LeaderboardResource::Loaded(rows) => { LeaderboardResource::Loaded(rows) => {
// Column headers // Column headers
@@ -886,8 +862,10 @@ fn handle_display_name_confirm(
.leaderboard_display_name .leaderboard_display_name
.clone() .clone()
.unwrap_or_else(|| { .unwrap_or_else(|| {
if let SyncBackend::SolitaireServer { ref username, .. } = if let SyncBackend::SolitaireServer {
settings.0.sync_backend ref username,
..
} = settings.0.sync_backend
{ {
username.chars().take(32).collect() username.chars().take(32).collect()
} else { } else {
@@ -912,18 +890,12 @@ fn handle_display_name_confirm(
} }
/// Discards any typed text and closes the display-name editor modal. /// Discards any typed text and closes the display-name editor modal.
/// Cancel click or Esc dismisses the display-name dialog without
/// saving (Phase C dismissal audit — same contract as the sync-setup
/// dialog's Cancel/Esc pair).
fn handle_display_name_cancel( fn handle_display_name_cancel(
button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameCancelButton>)>, button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameCancelButton>)>,
keys: Res<ButtonInput<KeyCode>>,
screens: Query<Entity, With<DisplayNameModal>>, screens: Query<Entity, With<DisplayNameModal>>,
mut commands: Commands, mut commands: Commands,
) { ) {
let clicked = button_q.iter().any(|i| *i == Interaction::Pressed); if !button_q.iter().any(|i| *i == Interaction::Pressed) {
let esc = keys.just_pressed(KeyCode::Escape) && !screens.is_empty();
if !clicked && !esc {
return; return;
} }
for entity in &screens { for entity in &screens {
+4 -19
View File
@@ -15,8 +15,6 @@ pub mod avatar_plugin;
pub mod card_animation; pub mod card_animation;
pub mod card_plugin; pub mod card_plugin;
pub mod challenge_plugin; pub mod challenge_plugin;
pub mod cheat_sheet_plugin;
pub mod contextual_tips_plugin;
pub mod core_game_plugin; pub mod core_game_plugin;
pub mod cursor_plugin; pub mod cursor_plugin;
pub mod daily_challenge_plugin; pub mod daily_challenge_plugin;
@@ -28,7 +26,6 @@ pub mod font_plugin;
pub mod game_plugin; pub mod game_plugin;
pub mod help_plugin; pub mod help_plugin;
pub mod home_plugin; pub mod home_plugin;
pub mod hotkeys;
pub mod hud_plugin; pub mod hud_plugin;
pub mod input_plugin; pub mod input_plugin;
pub mod layout; pub mod layout;
@@ -49,7 +46,6 @@ pub mod safe_area;
mod schedule_checks; mod schedule_checks;
pub mod selection_plugin; pub mod selection_plugin;
pub mod settings_plugin; pub mod settings_plugin;
pub mod solution_playback_plugin;
pub mod splash_plugin; pub mod splash_plugin;
pub mod stats_plugin; pub mod stats_plugin;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
@@ -58,19 +54,14 @@ pub mod sync_plugin;
pub mod sync_setup_plugin; pub mod sync_setup_plugin;
pub mod table_plugin; pub mod table_plugin;
pub mod theme; pub mod theme;
#[cfg(not(target_arch = "wasm32"))]
pub mod theme_store_plugin;
pub mod time_attack_plugin; pub mod time_attack_plugin;
pub mod touch_selection_plugin; pub mod touch_selection_plugin;
pub mod ui_focus; pub mod ui_focus;
pub mod ui_glass;
pub mod ui_modal; pub mod ui_modal;
pub mod ui_theme; pub mod ui_theme;
pub mod ui_tooltip; pub mod ui_tooltip;
pub mod weekly_goals_plugin; pub mod weekly_goals_plugin;
pub mod whats_new_plugin;
pub mod win_summary_plugin; pub mod win_summary_plugin;
pub mod you_hub_plugin;
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen}; pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
@@ -88,8 +79,9 @@ pub use avatar_plugin::{AvatarFetchEvent, AvatarPlugin, AvatarResource};
pub use card_animation::{ pub use card_animation::{
AnimationChain, AnimationTuning, BufferedInput, CardAnimation, CardAnimationPlugin, AnimationChain, AnimationTuning, BufferedInput, CardAnimation, CardAnimationPlugin,
DEAL_INTERVAL_SECS, DIAG_WINDOW_SIZE, FrameTimeDiagnostics, HoverState, InputBuffer, DEAL_INTERVAL_SECS, DIAG_WINDOW_SIZE, FrameTimeDiagnostics, HoverState, InputBuffer,
InputPlatform, MAX_DURATION_SECS, MIN_DURATION_SECS, MotionCurve, compute_duration, micro_vary, InputPlatform, MAX_DURATION_SECS, MIN_DURATION_SECS, MotionCurve, WIN_CASCADE_INTERVAL_SECS,
sample_curve, WinCascadePlugin, cascade_delay, compute_duration, micro_vary, retarget_animation,
sample_curve, win_scatter_targets,
}; };
pub use card_plugin::{ pub use card_plugin::{
CardEntity, CardImageSet, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer, CardEntity, CardImageSet, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer,
@@ -98,8 +90,6 @@ pub use card_plugin::{
pub use challenge_plugin::{ pub use challenge_plugin::{
CHALLENGE_UNLOCK_LEVEL, ChallengeAdvancedEvent, ChallengePlugin, challenge_progress_label, CHALLENGE_UNLOCK_LEVEL, ChallengeAdvancedEvent, ChallengePlugin, challenge_progress_label,
}; };
pub use cheat_sheet_plugin::{CheatSheetOverlay, CheatSheetPlugin};
pub use contextual_tips_plugin::ContextualTipsPlugin;
pub use core_game_plugin::CoreGamePlugin; pub use core_game_plugin::CoreGamePlugin;
pub use cursor_plugin::CursorPlugin; pub use cursor_plugin::CursorPlugin;
pub use daily_challenge_plugin::{ pub use daily_challenge_plugin::{
@@ -113,7 +103,7 @@ pub use events::{
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent, HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent, NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent, StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
StartTimeAttackRequestEvent, StartZenRequestEvent, StateChangedEvent, StartTimeAttackRequestEvent, StartZenRequestEvent, StateChangedEvent, SyncCompleteEvent,
ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent,
ToggleSettingsRequestEvent, ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent, ToggleSettingsRequestEvent, ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent,
XpAwardedEvent, XpAwardedEvent,
@@ -169,7 +159,6 @@ pub use settings_plugin::{
SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS, SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS,
}; };
pub use solitaire_data::SyncProvider; pub use solitaire_data::SyncProvider;
pub use solution_playback_plugin::{SolutionPlayback, SolutionPlaybackPlugin, SolutionSolveTask};
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot}; pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
pub use stats_plugin::{ pub use stats_plugin::{
LatestReplayPath, ReplayHistoryResource, ReplayNextButton, ReplayPrevButton, LatestReplayPath, ReplayHistoryResource, ReplayNextButton, ReplayPrevButton,
@@ -187,8 +176,6 @@ pub use theme::{
ActiveTheme, CardTheme, CardThemeLoader, ThemeEntry, ThemePlugin, ThemeRegistry, ActiveTheme, CardTheme, CardThemeLoader, ThemeEntry, ThemePlugin, ThemeRegistry,
ThemeRegistryPlugin, set_theme, ThemeRegistryPlugin, set_theme,
}; };
#[cfg(not(target_arch = "wasm32"))]
pub use theme_store_plugin::{ThemeStorePlugin, ThemeStoreScreen};
pub use time_attack_plugin::{ pub use time_attack_plugin::{
TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource, TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource,
}; };
@@ -201,8 +188,6 @@ pub use ui_modal::{
}; };
pub use ui_tooltip::{Tooltip, UiTooltipPlugin}; pub use ui_tooltip::{Tooltip, UiTooltipPlugin};
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin}; pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
pub use whats_new_plugin::{WhatsNewPending, WhatsNewPlugin, WhatsNewScreen};
pub use win_summary_plugin::{ pub use win_summary_plugin::{
ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin, format_win_time, ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin, format_win_time,
}; };
pub use you_hub_plugin::{ActiveYouTab, YouHubPlugin, YouHubScreen, YouTab};
+91 -56
View File
@@ -27,7 +27,6 @@ use solitaire_data::{Settings, save_settings_to};
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath}; use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::splash_plugin::SplashRoot;
use crate::ui_modal::{ use crate::ui_modal::{
ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button, ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
spawn_modal_header, spawn_modal_header,
@@ -37,6 +36,7 @@ use crate::ui_theme::{
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION,
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
}; };
use crate::splash_plugin::SplashRoot;
use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING}; use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -84,11 +84,66 @@ struct OnboardingSkipButton;
pub struct OnboardingSlideIndex(pub u8); pub struct OnboardingSlideIndex(pub u8);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Slide data — the hotkey slide renders the essential subset of the // Slide data — hotkey rows are taken verbatim from `help_plugin.rs` so the
// shared table in `crate::hotkeys` (Phase J unified the previously // two screens stay in sync without a shared abstraction.
// diverging copies here and in help_plugin).
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// A single `key — description` pair shown on slide 3.
#[cfg(not(target_os = "android"))]
struct HotkeyRow {
keys: &'static str,
description: &'static str,
}
/// Most-used shortcuts from the `help_plugin` canonical list.
///
/// Updating the list in `help_plugin.rs` should be mirrored here. The
/// ARCHITECTURE.md decision log calls out that we copy values rather than
/// refactor the help plugin.
#[cfg(not(target_os = "android"))]
const HOTKEYS: &[HotkeyRow] = &[
HotkeyRow {
keys: "D / Space",
description: "Draw from stock",
},
HotkeyRow {
keys: "U",
description: "Undo last move",
},
HotkeyRow {
keys: "Tab → Enter",
description: "Pick a card; arrows pick where; Enter to drop",
},
HotkeyRow {
keys: "N",
description: "New Classic game",
},
HotkeyRow {
keys: "M",
description: "Open Mode Launcher (then 15 to pick)",
},
HotkeyRow {
keys: "S",
description: "Stats & progression",
},
HotkeyRow {
keys: "A",
description: "Achievements",
},
HotkeyRow {
keys: "O",
description: "Settings",
},
HotkeyRow {
keys: "Esc",
description: "Pause / resume",
},
HotkeyRow {
keys: "F1",
description: "Help / controls",
},
];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Plugin // Plugin
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -98,26 +153,12 @@ pub struct OnboardingPlugin;
impl Plugin for OnboardingPlugin { impl Plugin for OnboardingPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<OnboardingSlideIndex>().add_systems( app.init_resource::<OnboardingSlideIndex>()
Update, .add_systems(Update, spawn_if_first_run)
// One chain: all three touch the slide index and the .add_systems(
// first-run flag in SettingsResource. Club membership Update,
// declares the settings access disjoint from the other (handle_onboarding_buttons, handle_onboarding_keyboard).chain(),
// screen plugins'; the keyboard handler runs before );
// FocusKeys like every other Esc consumer (#143).
(
spawn_if_first_run,
handle_onboarding_buttons,
handle_onboarding_keyboard.before(crate::ui_focus::FocusKeys),
)
.chain()
// Before the settings spine: the first-run flag write
// lands before every same-frame settings reader (they
// all sit at or after SettingsMutation transitively).
.before(crate::settings_plugin::SettingsMutation)
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
);
} }
} }
@@ -272,10 +313,6 @@ fn complete_onboarding(
despawn_screen(commands, screens); despawn_screen(commands, screens);
if let Some(s) = settings { if let Some(s) = settings {
s.0.first_run_complete = true; s.0.first_run_complete = true;
// A fresh install has nothing "new" to announce — stamp the
// running release so the What's-new card (Phase I) only ever
// fires after an actual upgrade.
s.0.last_seen_whats_new = crate::whats_new_plugin::current_release_version();
persist(path.map(|p| &p.0), &s.0); persist(path.map(|p| &p.0), &s.0);
} }
} }
@@ -345,27 +382,20 @@ fn spawn_slide_welcome(commands: &mut Commands, font_res: Option<&FontResource>)
}); });
} }
/// How-to-play body copy, phrased for the platform's input vocabulary —
/// a touch player never left-clicks (Phase H polish; spotted in the
/// emulator smoke of v0.44.0).
#[cfg(target_os = "android")]
const HOW_TO_PLAY_BODY: &str = "Drag any face-up card to move it between piles. \
You can drag a whole column at once by grabbing the topmost card \
you want to move. Double-tap a face-up card to send it to a \
foundation pile automatically (when the move is legal). \
Tap Hint in the bottom bar for a suggested move.";
#[cfg(not(target_os = "android"))]
const HOW_TO_PLAY_BODY: &str = "Left-click and drag any face-up card to move it between piles. \
You can drag a whole column at once by grabbing the topmost card \
you want to move. Double-click a face-up card to send it to a \
foundation pile automatically (when the move is legal). \
Right-click a card for a hint valid destinations will highlight.";
/// Slide 2 — How to play. /// Slide 2 — How to play.
fn spawn_slide_how_to_play(commands: &mut Commands, font_res: Option<&FontResource>) { fn spawn_slide_how_to_play(commands: &mut Commands, font_res: Option<&FontResource>) {
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| { spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
spawn_modal_header(card, "Drag cards to play", font_res); spawn_modal_header(card, "Drag cards to play", font_res);
spawn_modal_body_text(card, HOW_TO_PLAY_BODY, TEXT_SECONDARY, font_res); spawn_modal_body_text(
card,
"Left-click and drag any face-up card to move it between piles. \
You can drag a whole column at once by grabbing the topmost card \
you want to move. Double-click a face-up card to send it to a \
foundation pile automatically (when the move is legal). \
Right-click a card for a hint valid destinations will highlight.",
TEXT_SECONDARY,
font_res,
);
spawn_modal_actions(card, |actions| { spawn_modal_actions(card, |actions| {
spawn_modal_button( spawn_modal_button(
actions, actions,
@@ -405,10 +435,8 @@ fn spawn_slide_hotkeys(commands: &mut Commands, font_res: Option<&FontResource>)
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| { spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
spawn_modal_header(card, "Keyboard shortcuts", font_res); spawn_modal_header(card, "Keyboard shortcuts", font_res);
// Vertical list of `key — description` rows, same chip style as // Vertical list of `key — description` rows, same chip style as HelpScreen.
// HelpScreen. Essential subset only — the full table lives on for row in HOTKEYS {
// the hold-`/` cheat sheet.
for row in crate::hotkeys::HOTKEYS.iter().filter(|r| r.essential) {
card.spawn(Node { card.spawn(Node {
flex_direction: FlexDirection::Row, flex_direction: FlexDirection::Row,
align_items: AlignItems::Center, align_items: AlignItems::Center,
@@ -787,15 +815,22 @@ mod tests {
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Hotkey slide renders a non-empty essential subset (the table's own // Hotkey list is non-empty (guards against accidental truncation)
// integrity tests live in `crate::hotkeys`)
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
#[test] #[test]
fn hotkey_slide_subset_is_non_empty() { fn hotkey_list_is_non_empty() {
assert!( assert!(!HOTKEYS.is_empty(), "HOTKEYS must not be empty");
crate::hotkeys::HOTKEYS.iter().any(|r| r.essential), }
"the onboarding slide needs at least one essential hotkey"
); #[test]
fn all_hotkey_rows_have_non_empty_fields() {
for row in HOTKEYS {
assert!(!row.keys.is_empty(), "hotkey key field must not be empty");
assert!(
!row.description.is_empty(),
"hotkey description must not be empty"
);
}
} }
} }
+14 -85
View File
@@ -71,12 +71,6 @@ struct PauseResumeButton;
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct PauseForfeitButton; struct PauseForfeitButton;
/// Marker on the "Show solution" secondary button on the pause modal.
/// A click resumes the game and fires `ShowSolutionRequestEvent`;
/// `solution_playback_plugin` takes it from there.
#[derive(Component, Debug)]
struct PauseSolutionButton;
/// Marker on the forfeit-confirm modal scrim. /// Marker on the forfeit-confirm modal scrim.
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct ForfeitConfirmScreen; pub struct ForfeitConfirmScreen;
@@ -104,15 +98,6 @@ pub fn draw_mode_label(mode: DrawStockConfig) -> &'static str {
/// to disk, and routes the Forfeit confirm-modal flow. /// to disk, and routes the Forfeit confirm-modal flow.
pub struct PausePlugin; pub struct PausePlugin;
/// Set wrapping every system that mutates [`PausedResource`] or drives the
/// pause/forfeit modal flow. Runs as one deterministic chain between
/// [`crate::settings_plugin::SettingsMutation`] (the pause overlay's
/// draw-mode toggle writes `SettingsResource`) and
/// [`crate::game_plugin::GameMutation`], so every system ordered after
/// `GameMutation` observes this frame's pause state transitively (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct PauseMutation;
impl Plugin for PausePlugin { impl Plugin for PausePlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
// add_message is idempotent — other plugins may register these // add_message is idempotent — other plugins may register these
@@ -122,58 +107,29 @@ impl Plugin for PausePlugin {
.add_message::<PauseRequestEvent>() .add_message::<PauseRequestEvent>()
.add_message::<ForfeitRequestEvent>() .add_message::<ForfeitRequestEvent>()
.add_message::<ForfeitEvent>() .add_message::<ForfeitEvent>()
.add_message::<crate::events::ShowSolutionRequestEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.init_resource::<PausedResource>() .init_resource::<PausedResource>()
.configure_sets(
Update,
PauseMutation
.after(crate::settings_plugin::SettingsMutation)
// Before the pre-mutation game-state spine: the elapsed
// timer must observe this frame's pause transition, and
// the draw-mode toggle's settings write must land before
// sync_settings_to_game forwards it.
.before(crate::game_plugin::PreMutation)
.before(crate::game_plugin::GameMutation)
// toggle_pause must see SelectionState *before*
// handle_selection_keys clears it, so it can skip Escape
// when a card is selected. The other members mutate
// PausedResource, which the selection keys read.
.before(SelectionKeySet)
// HUD buttons read PausedResource (e.g. hint is inert
// while paused) — order them after this frame's pause
// transition.
.before(crate::hud_plugin::HudButtons),
)
.add_systems( .add_systems(
Update, Update,
( (
// toggle_pause must run *before* handle_forfeit_keyboard // toggle_pause must see SelectionState *before* handle_selection_keys
// so the ForfeitConfirmScreen is still alive when // clears it, so it can skip Escape when a card is selected.
// toggle_pause's early-return guard checks for it — // It must also run *before* handle_forfeit_keyboard so the
// otherwise an Esc that closes the forfeit modal would // ForfeitConfirmScreen is still alive when toggle_pause's
// also open pause in the same frame. The chain preserves // early-return guard checks for it — otherwise an Esc that
// that edge and makes the whole pause/forfeit flow // closes the forfeit modal would also open pause in the
// deterministic (all members share PausedResource and // same frame.
// the forfeit message streams).
// Reads only motion/display settings fields — disjoint
// from the screen-plugin club's writes (#143).
toggle_pause toggle_pause
.in_set(crate::settings_plugin::SettingsAccess) .before(SelectionKeySet)
.ambiguous_with(crate::settings_plugin::SettingsAccess), .before(handle_forfeit_keyboard),
auto_resume_on_overlay, handle_pause_draw_buttons,
handle_pause_draw_buttons
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
handle_pause_resume_button, handle_pause_resume_button,
handle_pause_forfeit_button, handle_pause_forfeit_button,
handle_pause_solution_button,
handle_forfeit_request, handle_forfeit_request,
handle_forfeit_keyboard,
handle_forfeit_confirm_buttons, handle_forfeit_confirm_buttons,
) handle_forfeit_keyboard,
.chain() auto_resume_on_overlay,
.in_set(PauseMutation), ),
); );
} }
} }
@@ -348,22 +304,6 @@ fn handle_pause_resume_button(
} }
} }
/// Translates a click on the pause modal's "Show solution" button into
/// a resume (`PauseRequestEvent` — playback can't run while paused)
/// plus a `ShowSolutionRequestEvent` for `solution_playback_plugin`.
fn handle_pause_solution_button(
interaction_query: Query<&Interaction, (Changed<Interaction>, With<PauseSolutionButton>)>,
mut pause: MessageWriter<PauseRequestEvent>,
mut solution: MessageWriter<crate::events::ShowSolutionRequestEvent>,
) {
for interaction in &interaction_query {
if *interaction == Interaction::Pressed {
pause.write(PauseRequestEvent);
solution.write(crate::events::ShowSolutionRequestEvent);
}
}
}
/// Translates a click on the pause modal's Forfeit button into a /// Translates a click on the pause modal's Forfeit button into a
/// `ForfeitRequestEvent` so `handle_forfeit_request` can spawn the /// `ForfeitRequestEvent` so `handle_forfeit_request` can spawn the
/// confirm modal — same code path as the `G` accelerator. /// confirm modal — same code path as the `G` accelerator.
@@ -558,14 +498,6 @@ fn spawn_pause_screen(
ButtonVariant::Tertiary, ButtonVariant::Tertiary,
font_res, font_res,
); );
spawn_modal_button(
actions,
PauseSolutionButton,
"Show solution",
None,
ButtonVariant::Secondary,
font_res,
);
spawn_modal_button( spawn_modal_button(
actions, actions,
PauseResumeButton, PauseResumeButton,
@@ -1037,10 +969,7 @@ mod tests {
let mut app = App::new(); let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin); app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
app.init_resource::<ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
app.insert_resource(GameStateResource(GameState::new( app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
1,
DrawStockConfig::DrawOne,
)));
app.update(); app.update();
app app
} }
+17 -29
View File
@@ -39,13 +39,6 @@ use crate::resources::{GameStateResource, HintCycleIndex};
/// emits the hint visuals once the task completes. At most one task /// emits the hint visuals once the task completes. At most one task
/// is ever in flight: a fresh H press while a previous task is /// is ever in flight: a fresh H press while a previous task is
/// running drops the previous task and queues the new one. /// running drops the previous task and queues the new one.
/// Set wrapping the async hint pipeline (state-change drop → poll). The
/// HUD hint button orders `.before` it so a press cancels/spawns before
/// this frame's poll; the drag-highlight painters order `.after` it so
/// the hint sprite writes are settled (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct PendingHint;
#[derive(Resource, Default)] #[derive(Resource, Default)]
pub struct PendingHintTask { pub struct PendingHintTask {
/// `Some` while the solver is still working on a verdict. /// `Some` while the solver is still working on a verdict.
@@ -185,9 +178,9 @@ mod tests {
use super::*; use super::*;
use crate::events::HintVisualEvent; use crate::events::HintVisualEvent;
use crate::input_plugin::HintSolverConfig; use crate::input_plugin::HintSolverConfig;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{Card, Deck, Rank, Suit}; use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState}; use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::{Foundation, KlondikePile, Tableau};
/// Build a minimal Bevy app exercising only the polling system /// Build a minimal Bevy app exercising only the polling system
/// and the resources/messages it touches. /// and the resources/messages it touches.
@@ -256,7 +249,10 @@ mod tests {
.into_iter() .into_iter()
.zip(suits.iter()) .zip(suits.iter())
{ {
game.set_test_tableau_cards(tableau, vec![Card::new(Deck::Deck1, *suit, Rank::King)]); game.set_test_tableau_cards(
tableau,
vec![Card::new(Deck::Deck1, *suit, Rank::King)],
);
} }
game game
} }
@@ -271,11 +267,9 @@ mod tests {
let mut app = pending_hint_app(); let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state())); app.insert_resource(GameStateResource(near_finished_state()));
let cfg = *app.world().resource::<HintSolverConfig>(); let cfg = *app.world().resource::<HintSolverConfig>();
app.world_mut().resource_mut::<PendingHintTask>().spawn( app.world_mut()
near_finished_state(), .resource_mut::<PendingHintTask>()
cfg.moves_budget, .spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
cfg.states_budget,
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
while app.world().resource::<PendingHintTask>().is_pending() { while app.world().resource::<PendingHintTask>().is_pending() {
@@ -312,11 +306,9 @@ mod tests {
let mut app = pending_hint_app(); let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state())); app.insert_resource(GameStateResource(near_finished_state()));
let cfg = *app.world().resource::<HintSolverConfig>(); let cfg = *app.world().resource::<HintSolverConfig>();
app.world_mut().resource_mut::<PendingHintTask>().spawn( app.world_mut()
near_finished_state(), .resource_mut::<PendingHintTask>()
cfg.moves_budget, .spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
cfg.states_budget,
);
assert!( assert!(
app.world().resource::<PendingHintTask>().is_pending(), app.world().resource::<PendingHintTask>().is_pending(),
"task is in flight after spawn", "task is in flight after spawn",
@@ -352,22 +344,18 @@ mod tests {
let cfg = *app.world().resource::<HintSolverConfig>(); let cfg = *app.world().resource::<HintSolverConfig>();
// First spawn. // First spawn.
app.world_mut().resource_mut::<PendingHintTask>().spawn( app.world_mut()
near_finished_state(), .resource_mut::<PendingHintTask>()
cfg.moves_budget, .spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
cfg.states_budget,
);
let first_handle_present = app.world().resource::<PendingHintTask>().is_pending(); let first_handle_present = app.world().resource::<PendingHintTask>().is_pending();
assert!(first_handle_present); assert!(first_handle_present);
// Second spawn. The `spawn` helper drops the prior task // Second spawn. The `spawn` helper drops the prior task
// before assigning the new one — at no point are two tasks // before assigning the new one — at no point are two tasks
// in flight. // in flight.
app.world_mut().resource_mut::<PendingHintTask>().spawn( app.world_mut()
near_finished_state(), .resource_mut::<PendingHintTask>()
cfg.moves_budget, .spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
cfg.states_budget,
);
// Resource still pending (the second task), but the first // Resource still pending (the second task), but the first
// is gone. We can't directly observe the first handle once // is gone. We can't directly observe the first handle once
// it's been overwritten — what we *can* assert is that the // it's been overwritten — what we *can* assert is that the
+4 -21
View File
@@ -115,27 +115,10 @@ impl Plugin for PlayBySeedPlugin {
Update, Update,
( (
handle_open_dialog, handle_open_dialog,
// Writes the marker-filtered seed-dialog Text nodes — handle_text_input,
// disjoint from the other text painters (UiTextFx tick_debounce_and_spawn_solver_task,
// club, #143). poll_solver_task,
handle_text_input handle_confirm,
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
tick_debounce_and_spawn_solver_task
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx)
// Reads deal settings for the solver spawn; a frame
// of lag is invisible (#143).
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
poll_solver_task
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
// Shares the new-game request stream with the other
// writers (drained wholesale at GameMutation, #143).
handle_confirm
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
handle_cancel, handle_cancel,
) )
.chain() .chain()
+98 -28
View File
@@ -4,6 +4,7 @@
//! summary in a single scrollable panel. Spawned on the first `P` keypress and //! summary in a single scrollable panel. Spawned on the first `P` keypress and
//! despawned on the second. //! despawned on the second.
use bevy::input::ButtonInput;
use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*; use bevy::prelude::*;
use chrono::{Duration, Local, NaiveDate}; use chrono::{Duration, Local, NaiveDate};
@@ -12,19 +13,23 @@ use solitaire_data::SyncBackend;
use crate::achievement_plugin::AchievementsResource; use crate::achievement_plugin::AchievementsResource;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub(crate) use crate::avatar_plugin::AvatarResource; use crate::avatar_plugin::AvatarResource;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
#[derive(bevy::prelude::Resource)] #[derive(bevy::prelude::Resource)]
pub(crate) struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>); struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
use crate::events::ToggleProfileRequestEvent; use crate::events::ToggleProfileRequestEvent;
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
use crate::resources::{SyncStatus, SyncStatusResource}; use crate::resources::{SyncStatus, SyncStatusResource};
use crate::settings_plugin::SettingsResource; use crate::settings_plugin::SettingsResource;
use crate::stats_plugin::{StatsResource, format_fastest_win, format_win_rate}; use crate::stats_plugin::{StatsResource, format_fastest_win, format_win_rate};
use crate::ui_modal::{
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, SPACE_1, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY, ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, SPACE_1, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY,
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, Z_MODAL_PANEL,
}; };
/// Number of days surfaced in the daily-challenge calendar row. /// Number of days surfaced in the daily-challenge calendar row.
@@ -55,18 +60,12 @@ pub struct DailyCalendarDot {
pub is_today: bool, pub is_today: bool,
} }
/// Registers the Profile body's scroll handling. Opening/closing moved /// Registers the `P` key toggle for the profile overlay.
/// to the You hub (`you_hub_plugin`), which calls
/// [`spawn_profile_body`] for this tab's content.
pub struct ProfilePlugin; pub struct ProfilePlugin;
/// Self-ambiguous club for writers of /// Marker on the "Done" button inside the Profile modal.
/// [`crate::events::ToggleProfileRequestEvent`] (HUD avatar button, home #[derive(Component, Debug)]
/// profile chip, HUD menu option). The You-hub reader drains the whole pub struct ProfileCloseButton;
/// queue, so writer append order is irrelevant; `you_hub_plugin` orders
/// its open/tab handling after the club (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct ProfileToggleWriters;
/// Marker on the scrollable body Node inside the Profile modal. /// Marker on the scrollable body Node inside the Profile modal.
/// ///
@@ -88,13 +87,13 @@ impl Plugin for ProfilePlugin {
// profile-scroll system also runs cleanly under // profile-scroll system also runs cleanly under
// `MinimalPlugins` in tests. // `MinimalPlugins` in tests.
.add_message::<MouseWheel>() .add_message::<MouseWheel>()
// Open/close/tab handling moved to `you_hub_plugin`
// (Phase E) — this plugin now owns body content + scroll.
.add_systems( .add_systems(
Update, Update,
scroll_profile_panel (
.in_set(crate::ui_modal::PanelScroll) toggle_profile_screen,
.ambiguous_with(crate::ui_modal::PanelScroll), handle_profile_close_button,
scroll_profile_panel,
),
); );
} }
} }
@@ -125,12 +124,70 @@ fn scroll_profile_panel(
} }
} }
/// Builds the Profile tab body inside the You hub's card. All markers fn handle_profile_close_button(
/// (`ProfileScrollable`, `DailyCalendarDot`, …) are unchanged, so the mut commands: Commands,
/// scroll and test queries keep working. close_buttons: Query<&Interaction, (With<ProfileCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<ProfileScreen>>,
) {
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub(crate) fn spawn_profile_body( fn toggle_profile_screen(
card: &mut ChildSpawnerCommands, mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleProfileRequestEvent>,
settings: Option<Res<SettingsResource>>,
sync_status: Option<Res<SyncStatusResource>>,
progress: Option<Res<ProgressResource>>,
achievements: Option<Res<AchievementsResource>>,
stats: Option<Res<StatsResource>>,
font_res: Option<Res<FontResource>>,
avatar: Option<Res<AvatarResource>>,
screens: Query<Entity, With<ProfileScreen>>,
scrims: Query<(), With<ModalScrim>>,
) {
let button_clicked = requests.read().count() > 0;
let p_pressed = keys.just_pressed(KeyCode::KeyP);
let esc_pressed = keys.just_pressed(KeyCode::Escape);
let already_open = !screens.is_empty();
// P / button toggles open-or-close. Esc only ever closes — when
// Profile is layered over Home (clicking the new Home stats chip
// opens this on top), Esc must dismiss the *topmost* modal.
// Without this branch, Esc fell through to Home's cancel handler
// and closed the wrong modal.
let want_open = !already_open && (p_pressed || button_clicked);
let want_close = already_open && (p_pressed || button_clicked || esc_pressed);
if !want_open && !want_close {
return;
}
if want_open && !scrims.is_empty() {
return;
}
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else {
spawn_profile_screen(
&mut commands,
settings.as_deref(),
sync_status.as_deref(),
progress.as_deref(),
achievements.as_deref(),
stats.as_deref(),
font_res.as_deref(),
avatar.as_deref(),
);
}
}
#[allow(clippy::too_many_arguments)]
fn spawn_profile_screen(
commands: &mut Commands,
settings: Option<&SettingsResource>, settings: Option<&SettingsResource>,
sync_status: Option<&SyncStatusResource>, sync_status: Option<&SyncStatusResource>,
progress: Option<&ProgressResource>, progress: Option<&ProgressResource>,
@@ -151,7 +208,9 @@ pub(crate) fn spawn_profile_body(
..default() ..default()
}; };
{ let scrim = spawn_modal(commands, ProfileScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Profile", font_res);
// Scrollable body — the Profile panel renders sync info, // Scrollable body — the Profile panel renders sync info,
// progression (incl. a 14-day calendar), every unlocked // progression (incl. a 14-day calendar), every unlocked
// achievement (up to ~18), and a stats summary, which can // achievement (up to ~18), and a stats summary, which can
@@ -413,7 +472,20 @@ pub(crate) fn spawn_profile_body(
)); ));
} }
}); });
}
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
ProfileCloseButton,
"Done",
Some("P"),
ButtonVariant::Primary,
font_res,
);
});
});
// Profile is read-only — opt into click-outside-to-dismiss.
commands.entity(scrim).insert(ScrimDismissible);
} }
/// Spawn a fixed-height vertical spacer node. /// Spawn a fixed-height vertical spacer node.
@@ -555,7 +627,6 @@ mod tests {
use crate::settings_plugin::SettingsPlugin; use crate::settings_plugin::SettingsPlugin;
use crate::stats_plugin::StatsPlugin; use crate::stats_plugin::StatsPlugin;
use crate::table_plugin::TablePlugin; use crate::table_plugin::TablePlugin;
use crate::ui_modal::ModalScrim;
fn headless_app() -> App { fn headless_app() -> App {
let mut app = App::new(); let mut app = App::new();
@@ -566,8 +637,7 @@ mod tests {
.add_plugins(ProgressPlugin::headless()) .add_plugins(ProgressPlugin::headless())
.add_plugins(AchievementPlugin::headless()) .add_plugins(AchievementPlugin::headless())
.add_plugins(SettingsPlugin::headless()) .add_plugins(SettingsPlugin::headless())
.add_plugins(ProfilePlugin) .add_plugins(ProfilePlugin);
.add_plugins(crate::you_hub_plugin::YouHubPlugin);
app.init_resource::<ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
app.update(); app.update();
app app
+1 -36
View File
@@ -37,21 +37,6 @@ pub struct LevelUpEvent {
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct ProgressUpdate; pub struct ProgressUpdate;
/// Set for `challenge_plugin::advance_on_challenge_win`. See the
/// mode-completion chain configured in `ProgressPlugin::build` (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct ChallengeCompletion;
/// Set for `daily_challenge_plugin::handle_daily_completion`. See the
/// mode-completion chain configured in `ProgressPlugin::build` (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct DailyCompletion;
/// Set for `weekly_goals_plugin::evaluate_weekly_goals`. See the
/// mode-completion chain configured in `ProgressPlugin::build` (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct WeeklyGoalsEval;
/// Bevy plugin that awards XP on `GameWonEvent`, persists `PlayerProgress`, /// Bevy plugin that awards XP on `GameWonEvent`, persists `PlayerProgress`,
/// and emits `LevelUpEvent` whenever a win crosses a level boundary. /// and emits `LevelUpEvent` whenever a win crosses a level boundary.
/// ///
@@ -89,27 +74,7 @@ impl Plugin for ProgressPlugin {
.add_message::<GameWonEvent>() .add_message::<GameWonEvent>()
.add_systems( .add_systems(
Update, Update,
award_xp_on_win award_xp_on_win.after(GameMutation).in_set(ProgressUpdate),
.after(GameMutation)
.in_set(ProgressUpdate)
// Base win XP lands before the mode bonuses so the
// level-up attribution is stable.
.before(ChallengeCompletion),
)
// Mode-completion evaluators, pinned in one explicit chain
// INSIDE ProgressUpdate — every `.after(ProgressUpdate)`
// reader then sees fully-settled progress. Each evaluator
// drains GameWonEvent via its own cursor and writes disjoint
// ProgressResource fields plus XP, but a daily win also counts
// toward weekly goals, so two members can fire on the same
// frame — we pin an order rather than argue the XP/level-up
// event stream is commutative (#143).
.configure_sets(
Update,
(ChallengeCompletion, DailyCompletion, WeeklyGoalsEval)
.chain()
.in_set(ProgressUpdate)
.after(GameMutation),
); );
} }
} }
+10 -24
View File
@@ -47,10 +47,10 @@ use bevy::input::touch::Touches;
use bevy::math::Vec2; use bevy::math::Vec2;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::PrimaryWindow; use bevy::window::PrimaryWindow;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::Card; use solitaire_core::Card;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC; use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC;
use crate::events::{MoveRejectedEvent, MoveRequestEvent}; use crate::events::{MoveRejectedEvent, MoveRequestEvent};
@@ -185,31 +185,16 @@ impl Plugin for RadialMenuPlugin {
// Tests inject `RadialCursorOverride` themselves; production // Tests inject `RadialCursorOverride` themselves; production
// never touches it. We do not `init_resource` here so the // never touches it. We do not `init_resource` here so the
// cursor-from-window path is the default. // cursor-from-window path is the default.
// The radial gesture reacts to pointer state the drag pipeline
// settles, and its release handler feeds the shared move
// request stream consumed at GameMutation this frame, so the
// chain sits between PointerInput and GameMutation. The redraw
// reads settings (reduce-motion) where a frame of lag is
// invisible — SettingsAccess club (#143).
.add_systems( .add_systems(
Update, Update,
( (
radial_open_on_right_click, radial_open_on_right_click,
radial_open_on_long_press, radial_open_on_long_press,
radial_track_cursor, radial_track_cursor,
radial_handle_release_or_cancel radial_handle_release_or_cancel,
.in_set(crate::game_plugin::MoveRequestWriters) radial_redraw_overlay,
.ambiguous_with(crate::game_plugin::MoveRequestWriters),
radial_redraw_overlay
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
) )
.chain() .chain(),
.after(crate::input_plugin::PointerInput)
// Selection keys also touch DragState (mutual pointer/
// keyboard exclusion) — keys settle first.
.after(crate::selection_plugin::SelectionKeySet)
.before(crate::game_plugin::GameMutation),
); );
} }
} }
@@ -375,6 +360,9 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
} }
} }
/// Builds the `(destination, anchor)` list for a fresh radial open. /// Builds the `(destination, anchor)` list for a fresh radial open.
/// ///
/// `half_extents` is the window half-size in world space — icons are clamped /// `half_extents` is the window half-size in world space — icons are clamped
@@ -393,10 +381,8 @@ fn build_radial_destinations(
.map(|(i, d)| { .map(|(i, d)| {
let raw = radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX); let raw = radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX);
let clamped = Vec2::new( let clamped = Vec2::new(
raw.x raw.x.clamp(-half_extents.x + margin, half_extents.x - margin),
.clamp(-half_extents.x + margin, half_extents.x - margin), raw.y.clamp(-half_extents.y + margin, half_extents.y - margin),
raw.y
.clamp(-half_extents.y + margin, half_extents.y - margin),
); );
(d, clamped) (d, clamped)
}) })
+13 -13
View File
@@ -122,8 +122,12 @@ pub(crate) fn format_move_body(instruction: &KlondikeInstruction) -> String {
/// `▌ MOVE LOG · COMPLETE` in `Completed`. /// `▌ MOVE LOG · COMPLETE` in `Completed`.
pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String { pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String {
match state { match state {
ReplayPlaybackState::Playing { moves, cursor, .. } => { ReplayPlaybackState::Playing { replay, cursor, .. } => {
format!("\u{258C} MOVE LOG \u{00B7} {}/{}", cursor, moves.len()) format!(
"\u{258C} MOVE LOG \u{00B7} {}/{}",
cursor,
replay.moves.len()
)
} }
ReplayPlaybackState::Completed => "\u{258C} MOVE LOG \u{00B7} COMPLETE".to_string(), ReplayPlaybackState::Completed => "\u{258C} MOVE LOG \u{00B7} COMPLETE".to_string(),
ReplayPlaybackState::Inactive => String::new(), ReplayPlaybackState::Inactive => String::new(),
@@ -131,7 +135,7 @@ pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String {
} }
/// Pure helper — formats the kth-most-recently-applied move's row /// Pure helper — formats the kth-most-recently-applied move's row
/// text. `k = 1` is the active row (`moves[cursor - 1]`, /// text. `k = 1` is the active row (`replay.moves[cursor - 1]`,
/// displayed as `"{cursor} │ {body}"`). `k = 2` is the row above /// displayed as `"{cursor} │ {body}"`). `k = 2` is the row above
/// that (`moves[cursor - 2]` displayed as `"{cursor - 1} │ {body}"`), /// that (`moves[cursor - 2]` displayed as `"{cursor - 1} │ {body}"`),
/// and so on. /// and so on.
@@ -143,14 +147,14 @@ pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String {
/// for k=1 and k=2 only, k=3 returns empty). /// for k=1 and k=2 only, k=3 returns empty).
/// - The move list is shorter than expected (defensive guard). /// - The move list is shorter than expected (defensive guard).
pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> String { pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> String {
let ReplayPlaybackState::Playing { moves, cursor, .. } = state else { let ReplayPlaybackState::Playing { replay, cursor, .. } = state else {
return String::new(); return String::new();
}; };
if k == 0 || k > *cursor { if k == 0 || k > *cursor {
return String::new(); return String::new();
} }
let zero_idx = *cursor - k; let zero_idx = *cursor - k;
let Some(m) = moves.get(zero_idx) else { let Some(m) = replay.moves.get(zero_idx) else {
return String::new(); return String::new();
}; };
let display_idx = *cursor - k + 1; let display_idx = *cursor - k + 1;
@@ -158,7 +162,7 @@ pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> St
} }
/// Pure helper — formats the kth-NEXT move's row text. `k = 1` /// Pure helper — formats the kth-NEXT move's row text. `k = 1`
/// is the move that will apply next (`moves[cursor]`, /// is the move that will apply next (`replay.moves[cursor]`,
/// displayed as `cursor + 1`); `k = 2` is the move after that, /// displayed as `cursor + 1`); `k = 2` is the move after that,
/// and so on. /// and so on.
/// ///
@@ -170,14 +174,14 @@ pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> St
/// replay — late in the move list, the trailing next rows /// replay — late in the move list, the trailing next rows
/// stay empty). /// stay empty).
pub(crate) fn format_kth_next_row(state: &ReplayPlaybackState, k: usize) -> String { pub(crate) fn format_kth_next_row(state: &ReplayPlaybackState, k: usize) -> String {
let ReplayPlaybackState::Playing { moves, cursor, .. } = state else { let ReplayPlaybackState::Playing { replay, cursor, .. } = state else {
return String::new(); return String::new();
}; };
if k == 0 { if k == 0 {
return String::new(); return String::new();
} }
let zero_idx = *cursor + k - 1; let zero_idx = *cursor + k - 1;
let Some(m) = moves.get(zero_idx) else { let Some(m) = replay.moves.get(zero_idx) else {
return String::new(); return String::new();
}; };
let display_idx = *cursor + k; let display_idx = *cursor + k;
@@ -242,11 +246,7 @@ pub(crate) fn format_suit_glyph(suit: Suit) -> &'static str {
/// known card, or `"--"` for an absent top card (empty pile). /// known card, or `"--"` for an absent top card (empty pile).
pub(crate) fn format_card_short(card: Option<&(Card, bool)>) -> String { pub(crate) fn format_card_short(card: Option<&(Card, bool)>) -> String {
match card { match card {
Some((c, _)) => format!( Some((c, _)) => format!("{}{}", format_rank_short(c.rank()), format_suit_glyph(c.suit())),
"{}{}",
format_rank_short(c.rank()),
format_suit_glyph(c.suit())
),
None => "--".to_string(), None => "--".to_string(),
} }
} }
@@ -1,3 +1,5 @@
#![allow(dead_code)]
use bevy::prelude::*; use bevy::prelude::*;
use super::*; use super::*;
@@ -25,6 +27,960 @@ pub(crate) struct ReplayScrubKeyHold {
pub(crate) right_held_secs: f32, pub(crate) right_held_secs: f32,
} }
/// Marker on the keybind-hint footer row at the bottom edge of the
/// banner. Carries two `Text` children: a vim-style mode indicator
/// (`▌ NORMAL │ replay`) on the left and the keybind hint
/// (`[SPACE] pause/resume`) on the right. 1 px top border in
/// [`BORDER_SUBTLE`] separates it from the notch-label row above.
///
/// Surfaces the existing Space-key accelerator visually so the
/// UI-first contract from CLAUDE.md §3.3 (every player action has
/// a visible UI control) holds for keyboard accelerators too.
/// Future commits that wire ESC for stop or ← / → for scrub will
/// extend the right-hand text in lockstep — the footer always
/// reflects what's actually wired, never aspirational.
#[derive(Component, Debug)]
pub struct ReplayOverlayKeybindFooter;
/// Marker on the bottom-edge **Move Log** panel — a separate root
/// UI entity (not a child of the banner) that sits anchored to the
/// viewport's bottom edge. Carries a header (`▌ MOVE LOG · N/M`)
/// plus a row showing the most-recently-applied move.
///
/// Spawned by `spawn_overlay` alongside the banner and the
/// floating progress chip; despawned by `react_to_state_change`
/// on the same `Playing → Inactive` transition. Same lifecycle
/// pattern as `ReplayFloatingProgressChip` — a sibling root, not
/// a banner child, because it lives at a different screen anchor.
///
/// First slice of the move-log mockup at
/// `docs/ui-mockups/replay-overlay-mobile.html` § "Move Log Card".
/// Subsequent commits add prev/next rows and scrolling.
#[derive(Component, Debug)]
pub struct ReplayOverlayMoveLogPanel;
/// Marker on the move-log panel's header `Text`. Carries
/// `▌ MOVE LOG · N/M` while a replay is playing; the
/// `update_move_log_header` system repaints it as the cursor
/// advances.
#[derive(Component, Debug)]
pub struct ReplayOverlayMoveLogHeader;
/// Marker on the move-log panel's active-row `Text`. Carries the
/// most-recently-applied move's text (`47 │ waste → tableau 5`)
/// when `cursor > 0`; empty when no moves have been applied yet
/// (initial spawn) or in `Completed`/`Inactive` states. The
/// `update_move_log_active_row` system repaints it as the cursor
/// advances.
#[derive(Component, Debug)]
pub struct ReplayOverlayMoveLogActiveRow;
/// Marker on a "previous move" row above the active row.
/// `offset` is the 1-based distance backwards from the active
/// row: `offset = 1` is the move applied just before the active
/// one (e.g. cursor=47 → row reads "46 │ ..."), `offset = 2` is
/// the one before that, and so on. Up to [`MOVE_LOG_PREV_ROWS`]
/// rows render above the active row.
///
/// Empty text when there isn't enough history (`offset >= cursor`,
/// e.g. cursor=1 has no prev rows; cursor=2 has only the
/// `offset = 1` row populated).
#[derive(Component, Debug)]
pub struct ReplayOverlayMoveLogPrevRow {
/// Distance backwards from the active row (1-based).
pub offset: u8,
}
/// Marker on a "next move" row below the active row. `offset`
/// is the 1-based distance forward from the active row:
/// `offset = 1` is the move that will apply next
/// (`replay.moves[cursor]`, displayed as `cursor + 1`),
/// `offset = 2` is the one after that, and so on. Up to
/// [`MOVE_LOG_NEXT_ROWS`] rows render below the active row.
///
/// Empty text when there isn't enough remaining replay
/// (`cursor + offset - 1 >= moves.len()`, e.g. cursor=99 of
/// a 100-move replay shows offset 1 but offset 2 stays empty).
#[derive(Component, Debug)]
pub struct ReplayOverlayMoveLogNextRow {
/// Distance forward from the active row (1-based).
pub offset: u8,
}
/// Marker added to every top-level entity spawned by [`spawn_overlay`].
/// `react_to_state_change` uses a single `Query<Entity, With<DespawnWithReplay>>`
/// to despawn all of them, rather than keeping a separate query per
/// entity type. Future sibling overlay surfaces just need this marker
/// at spawn time — no changes to the despawn logic required.
#[derive(Component, Debug)]
pub struct DespawnWithReplay;
/// Marker on the mini-tableau preview panel root. A right-edge-anchored
/// panel that shows a compact summary of the live game state during
/// replay: the four foundation tops and the stock / waste heads.
/// Spawned as a sibling root entity (same lifecycle pattern as
/// [`ReplayOverlayMoveLogPanel`]) at `right: 0`, `top: MINI_TABLEAU_TOP_OFFSET`.
#[derive(Component, Debug)]
pub struct ReplayMiniTableauPanel;
/// Marker on the foundations row `Text` inside the mini-tableau panel.
/// Carries `F: A♠ 7♥ 5♦ K♣` (or `--` for empty slots); repainted by
/// `update_mini_tableau` whenever [`GameStateResource`] changes.
#[derive(Component, Debug)]
pub struct ReplayMiniTableauFoundations;
/// Marker on the stock/waste row `Text` inside the mini-tableau panel.
/// Carries `STK:14 WST:7♥`; repainted by `update_mini_tableau` whenever
/// [`GameStateResource`] changes.
#[derive(Component, Debug)]
pub struct ReplayMiniTableauStockWaste;
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
/// Bevy plugin that registers every system needed to drive the replay
/// overlay's lifecycle.
///
/// The plugin is independent of [`crate::replay_playback::ReplayPlaybackPlugin`]
/// — it only reads the shared `ReplayPlaybackState` resource. Tests insert
/// the resource manually and exercise the overlay in isolation.
pub struct ReplayOverlayPlugin;
impl Plugin for ReplayOverlayPlugin {
fn build(&self, app: &mut App) {
// The systems are ordered so that, on a single frame:
// 1. The state-watcher spawns or despawns the overlay if the
// `ReplayPlaybackState` resource changed.
// 2. The completion-text update swaps the banner label when the
// state is `Completed`.
// 3. The progress-text update writes the latest "Move N of M".
// 4. The Stop-button click handler reads `Interaction::Pressed`
// and calls `stop_replay_playback` (which mutates the state).
// 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.
// 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.init_resource::<ReplayScrubKeyHold>()
.add_message::<MoveRequestEvent>()
.add_message::<DrawRequestEvent>()
.add_message::<UndoRequestEvent>()
.add_message::<StateChangedEvent>()
.add_systems(
Update,
(
react_to_state_change,
update_banner_label,
update_progress_text,
update_floating_progress_chip,
update_scrub_fill,
update_move_log_header,
update_move_log_active_row,
update_move_log_prev_rows,
update_move_log_next_rows,
update_mini_tableau_foundations,
update_mini_tableau_stock_waste,
update_pause_button_label,
handle_pause_button,
handle_step_button,
handle_pause_keyboard,
handle_stop_keyboard,
handle_arrow_keyboard,
handle_stop_button,
)
.chain(),
);
}
}
// ---------------------------------------------------------------------------
// Spawning
// ---------------------------------------------------------------------------
/// Reads [`ReplayPlaybackState`] every time the resource changes and either
/// spawns or despawns the overlay accordingly. Treats the resource as the
/// single source of truth — the spawn / despawn decision is derived from
/// `is_playing() || is_completed()` rather than tracking previous-state
/// transitions explicitly, which keeps the system stateless.
pub(crate) fn react_to_state_change(
mut commands: Commands,
state: Res<ReplayPlaybackState>,
roots: Query<Entity, With<ReplayOverlayRoot>>,
despawnable: Query<Entity, With<DespawnWithReplay>>,
font_res: Option<Res<FontResource>>,
) {
if !state.is_changed() {
return;
}
let should_be_visible = state.is_playing() || state.is_completed();
let already_spawned = roots.iter().next().is_some();
if should_be_visible && !already_spawned {
spawn_overlay(&mut commands, font_res.as_deref(), &state);
} else if !should_be_visible && already_spawned {
// Despawn all sibling root entities in one loop — every entity
// spawned by `spawn_overlay` carries `DespawnWithReplay` for
// exactly this purpose.
for entity in &despawnable {
commands.entity(entity).despawn();
}
}
// The `should_be_visible && already_spawned` branch is a no-op here —
// the per-frame text update systems below repaint the banner label
// and progress readout in place without a respawn.
}
/// Spawns the banner — a flex-row Node anchored to the top edge of the
/// window with three children: the "▌ replay" / "▌ replay complete" label,
/// the centred progress text, and the right-aligned Stop button.
pub(crate) fn spawn_overlay(
commands: &mut Commands,
font_res: Option<&FontResource>,
state: &ReplayPlaybackState,
) {
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
// Clone for the floating chip spawn that runs *after* the
// banner's `.with_children(|banner| { ... })` closure consumes
// the original `font_handle`. Cheap — Bevy's `Handle<Font>` is
// `Arc`-backed, the clone bumps a refcount.
let font_handle_for_floating = font_handle.clone();
// Second clone for the scrub-bar label row and keybind footer
// inside the outer banner closure. The inner top-row closure
// consumes the original `font_handle` for the progress-chip
// text, so by the time the outer closure reaches the
// label-row / footer spawns the original is gone.
// `font_handle_for_labels` is `.clone()`'d (never moved) inside
// the labels closure, so it's still alive for the footer
// spawn afterwards — single shared clone covers both.
let font_handle_for_labels = font_handle.clone();
// Third clone for the move-log panel — a separate root
// entity spawned after the banner closure closes. Mirrors the
// floating-chip clone reasoning.
let font_handle_for_move_log = font_handle.clone();
// Fourth clone for the mini-tableau preview panel.
let font_handle_for_mini_tableau = font_handle.clone();
let banner_label = if state.is_completed() {
"\u{258C} replay complete" // ▌ — cursor-block prefix; matches the splash boot-screen convention.
} else {
"\u{258C} replay" // ▌
};
let progress_label = format_progress(state);
// Tableau dim layer — full-screen scrim at z = Z_REPLAY_DIM (= 54).
// Spawned first so it sits behind the banner (z=55) and move-log (z=55)
// in the UI stacking context. World-space sprites (cards, badges) are
// always below any UI node, so the dim layer darkens the entire
// gameplay scene without needing to touch card_plugin. No Interaction
// component — purely visual.
commands.spawn((
ReplayTableauDimLayer,
DespawnWithReplay,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
top: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, TABLEAU_DIM_ALPHA)),
ZIndex(Z_REPLAY_DIM),
GlobalZIndex(Z_REPLAY_DIM),
));
let banner_bg = Color::srgba(
BG_ELEVATED_HI.to_srgba().red,
BG_ELEVATED_HI.to_srgba().green,
BG_ELEVATED_HI.to_srgba().blue,
BANNER_ALPHA,
);
commands
.spawn((
ReplayOverlayRoot,
DespawnWithReplay,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
top: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Px(BANNER_HEIGHT),
// Column outer so the content row sits above the 1px
// scrub bar at the bottom edge.
flex_direction: FlexDirection::Column,
..default()
},
BackgroundColor(banner_bg),
// Pin the banner to its z layer in both the local and the
// global stacking context — `GlobalZIndex` matters because
// the overlay is a top-level Node (no parent), and Bevy 0.18
// has historically had subtle stacking-context drift here.
ZIndex(Z_REPLAY_OVERLAY),
GlobalZIndex(Z_REPLAY_OVERLAY),
))
.with_children(|banner| {
// Top row: the existing content (label / progress / Stop).
banner
.spawn(Node {
flex_grow: 1.0,
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
justify_content: JustifyContent::SpaceBetween,
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
column_gap: VAL_SPACE_4,
..default()
})
.with_children(|row| {
// Left: column with the accent "▌ replay" headline
// above and a small `GAME #YYYY-DDD` caption below.
// The caption mirrors the mockup's right-anchored
// game identifier but stays visually grouped with
// the headline so the two pieces of "this is a
// replay of game X" read as a single unit.
row.spawn(Node {
flex_direction: FlexDirection::Column,
align_items: AlignItems::FlexStart,
row_gap: Val::Px(2.0),
..default()
})
.with_children(|left| {
left.spawn((
ReplayOverlayBannerText,
Text::new(banner_label),
TextFont {
font: font_handle.clone(),
font_size: TYPE_HEADLINE,
..default()
},
TextColor(ACCENT_PRIMARY),
));
left.spawn((
ReplayOverlayGameCaption,
Text::new(format_game_caption(state).unwrap_or_default()),
TextFont {
font: font_handle.clone(),
font_size: TYPE_CAPTION,
..default()
},
TextColor(TEXT_SECONDARY),
));
});
// Centre: progress readout, wrapped in a 1 px
// ACCENT_PRIMARY-bordered chip so it reads as a
// discrete callout rather than free-floating
// text. No fill — the Terminal aesthetic gets
// depth from borders + tonal layering, not
// shadows. The marker stays on the inner Text so
// `update_progress_text` keeps working unchanged.
row.spawn((
Node {
border: UiRect::all(Val::Px(1.0)),
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
..default()
},
BorderColor::all(ACCENT_PRIMARY),
))
.with_children(|chip| {
chip.spawn((
ReplayOverlayProgressText,
Text::new(progress_label),
TextFont {
font: font_handle,
font_size: TYPE_BODY,
..default()
},
TextColor(TEXT_PRIMARY),
));
});
// Right: Stop button. Tertiary variant — the
// action is available but not the loudest element
// in the banner; the "Replay" primary accent owns
// that slot. `spawn_modal_button` gives us hover /
// press paint and focus rings for free via the
// existing `UiModalPlugin` paint system.
row.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_2,
..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,
"Stop",
None,
ButtonVariant::Tertiary,
font_res,
);
});
});
// Bottom edge: 1px-tall scrub bar. Track in `BORDER_SUBTLE`,
// fill in `ACCENT_PRIMARY`. The fill width is rewritten by
// [`update_scrub_fill`] every tick the cursor advances.
// Initial fill width matches the spawn-time progress so the
// 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 {
width: Val::Percent(100.0),
height: Val::Px(1.0),
..default()
},
BackgroundColor(BORDER_SUBTLE),
// HC marker: bumps the 1 px track from #505050
// → #a0a0a0 under high-contrast mode. The track
// paints via BackgroundColor (it's a 1 px Node,
// not a border on a wider container) so the
// BorderColor-targeting HighContrastBorder marker
// doesn't apply — HighContrastBackground is the
// parallel primitive for this case.
HighContrastBackground::with_default(BORDER_SUBTLE),
))
.with_children(|track| {
track.spawn((
ReplayOverlayScrubFill,
Node {
width: Val::Percent(initial_scrub_pct),
height: Val::Percent(100.0),
..default()
},
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),
// HC bump: lime → brighter lime so the win
// marker reads clearly above the bumped
// notch ticks (BORDER_SUBTLE_HC gray) under
// high-contrast mode.
HighContrastBackground::with_hc(STATE_SUCCESS, STATE_SUCCESS_HC),
));
}
// Fixed quarter-mark notches: five 1px vertical
// ticks at 0 / 25 / 50 / 75 / 100 % that give the
// player visual anchor points without needing to
// mentally bisect the bar. Painted in
// BORDER_SUBTLE — same colour as the unfilled
// track — so visibility comes from extending past
// the 1px track height (5px tall, anchored 2px
// above the track top) rather than colour
// contrast. Spawned *after* the WIN MOVE marker
// so a notch and the marker landing on the same
// percentage paint the marker on top.
for pct in scrub_notch_positions() {
track.spawn((
ReplayOverlayScrubNotch,
Node {
position_type: PositionType::Absolute,
left: Val::Percent(pct),
top: Val::Px(-2.0),
width: Val::Px(1.0),
height: Val::Px(5.0),
..default()
},
BackgroundColor(BORDER_SUBTLE),
// Same HC-paint reasoning as the track
// above: 5 px tall × 1 px wide tick mark
// paints via BackgroundColor, so
// HighContrastBackground (not -Border) is
// the right marker.
HighContrastBackground::with_default(BORDER_SUBTLE),
));
}
});
// Third banner row: percentage labels (`0%` / `25%` /
// `50%` / `75%` / `100%`) under each scrub-bar notch.
// Sibling of (not child of) the 1px track because labels
// need their own vertical real estate (TYPE_CAPTION text
// doesn't fit inside a 1px container). Position math:
// track Node has `Val::Percent(p)` referencing the
// banner's full width; this label row also has the
// banner's full width, so labels at the same
// percentages line up vertically with their notches.
let labels = scrub_notch_labels();
let positions = scrub_notch_positions();
banner
.spawn(Node {
width: Val::Percent(100.0),
height: Val::Px(SCRUB_LABEL_ROW_HEIGHT),
position_type: PositionType::Relative,
..default()
})
.with_children(|row| {
for (i, (label, pct)) in labels.iter().zip(positions.iter()).enumerate() {
// Endpoints flush to the row's edges; middle
// three labels use the `translateX(-50%)`
// pattern for Bevy 0.18 UI: a fixed-width
// container is placed at `left: Percent(pct)`
// then shifted left by half its own width via
// `margin.left: Px(-SCRUB_LABEL_CENTER_WIDTH/2)`.
// `Justify::Center` renders the text centred
// within the container so the text's visual
// centre coincides with the notch line.
let (node, justify) = if i == 0 {
(
Node {
position_type: PositionType::Absolute,
top: Val::Px(2.0),
left: Val::Px(0.0),
..default()
},
Justify::Left,
)
} else if i == labels.len() - 1 {
(
Node {
position_type: PositionType::Absolute,
top: Val::Px(2.0),
right: Val::Px(0.0),
..default()
},
Justify::Right,
)
} else {
(
Node {
position_type: PositionType::Absolute,
top: Val::Px(2.0),
left: Val::Percent(*pct),
width: Val::Px(SCRUB_LABEL_CENTER_WIDTH),
margin: UiRect {
left: Val::Px(-SCRUB_LABEL_CENTER_WIDTH / 2.0),
..default()
},
..default()
},
Justify::Center,
)
};
row.spawn((
ReplayOverlayScrubNotchLabel,
node,
Text::new(*label),
TextLayout::new_with_justify(justify),
TextFont {
font: font_handle_for_labels.clone(),
font_size: TYPE_CAPTION,
..default()
},
// TEXT_SECONDARY keeps the subdued visual
// hierarchy (caption, not headline) while
// staying readable against BG_ELEVATED_HI.
TextColor(TEXT_SECONDARY),
));
}
});
// Fourth banner row: keybind-hint footer. Vim-style
// mode line on the left (`▌ NORMAL │ replay`), keybind
// hint on the right (`[SPACE] pause/resume`), 1px top
// border in BORDER_SUBTLE separating it from the
// labels row above. Surfaces the existing Space
// accelerator visually so CLAUDE.md §3.3's UI-first
// contract holds for keyboard accelerators too.
banner
.spawn((
ReplayOverlayKeybindFooter,
Node {
width: Val::Percent(100.0),
height: Val::Px(KEYBIND_FOOTER_HEIGHT),
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::SpaceBetween,
align_items: AlignItems::Center,
padding: UiRect::horizontal(VAL_SPACE_4),
border: UiRect::top(Val::Px(1.0)),
..default()
},
BorderColor::all(BORDER_SUBTLE),
// Marker for `apply_high_contrast_borders`: bumps
// the 1 px top border from BORDER_SUBTLE (#505050)
// to BORDER_SUBTLE_HC (#a0a0a0) when
// `Settings::high_contrast_mode` is on. Without
// this the footer reads as floating loose under
// HC because the border that visually anchors it
// to the labels row above is near-invisible.
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|footer| {
footer.spawn((
Text::new(keybind_footer_mode_text()),
TextFont {
font: font_handle_for_labels.clone(),
font_size: TYPE_CAPTION,
..default()
},
TextColor(TEXT_SECONDARY),
));
if SHOW_KEYBOARD_ACCELERATORS {
footer.spawn((
Text::new(keybind_footer_hint_text()),
TextFont {
font: font_handle_for_labels.clone(),
font_size: TYPE_CAPTION,
..default()
},
TextColor(TEXT_SECONDARY),
));
}
});
});
// Floating progress chip — a 2D world-space `Text2d` rendered
// above the destination pile of the most-recently-applied move.
// Sibling of (not child of) the banner overlay because it lives
// in world-space coordinates, not the UI tree. Spawned hidden;
// `update_floating_progress_chip` shows + positions it on the
// first frame the cursor advances past 0. Lifecycle matches
// the banner overlay — `react_to_state_change` despawns both
// when the replay state transitions back to `Inactive`.
commands.spawn((
ReplayFloatingProgressChip,
DespawnWithReplay,
Text2d::new(format_progress(state)),
TextFont {
font: font_handle_for_floating,
font_size: TYPE_BODY,
..default()
},
TextColor(TEXT_PRIMARY),
// High Z keeps the chip above every card stack
// (Z_DROP_OVERLAY = 50, Z_STOCK_BADGE = 30, regular cards
// stack to the low double digits at most).
Transform::from_xyz(0.0, 0.0, 100.0),
Visibility::Hidden,
));
// Move-log panel — a separate root UI entity anchored to the
// viewport's bottom edge. Carries a `▌ MOVE LOG · N/M` header
// plus a row showing the most-recently-applied move.
// Sibling-of-banner pattern (not a banner child) because the
// panel lives at a different screen anchor and has its own
// spawn/despawn lifecycle synced via `react_to_state_change`.
let banner_bg = Color::srgba(
BG_ELEVATED_HI.to_srgba().red,
BG_ELEVATED_HI.to_srgba().green,
BG_ELEVATED_HI.to_srgba().blue,
BANNER_ALPHA,
);
commands
.spawn((
ReplayOverlayMoveLogPanel,
DespawnWithReplay,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
bottom: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Px(MOVE_LOG_PANEL_HEIGHT),
flex_direction: FlexDirection::Column,
align_items: AlignItems::FlexStart,
justify_content: JustifyContent::Center,
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
row_gap: VAL_SPACE_1,
border: UiRect::top(Val::Px(1.0)),
..default()
},
BackgroundColor(banner_bg),
BorderColor::all(BORDER_SUBTLE),
// Same z-stack rationale as the banner — above gameplay,
// below modals.
ZIndex(Z_REPLAY_OVERLAY),
GlobalZIndex(Z_REPLAY_OVERLAY),
// HC marker so the top border bumps under HC mode.
// Without it the panel reads as floating loose because
// the border that anchors it to the gameplay area above
// is near-invisible at #505050.
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|panel| {
// Header row: `▌ MOVE LOG · N/M` in ACCENT_PRIMARY for
// the cursor-block prefix consistency with the banner
// headline.
panel.spawn((
ReplayOverlayMoveLogHeader,
Text::new(format_move_log_header(state)),
TextFont {
font: font_handle_for_move_log.clone(),
font_size: TYPE_CAPTION,
..default()
},
TextColor(ACCENT_PRIMARY),
));
// Prev rows — render above the active row in display
// order (oldest first), so the active row sits at the
// bottom of the visible window. Spawn from
// MOVE_LOG_PREV_ROWS down to 1 (offset 2, then 1) so
// the highest-offset (oldest) row is topmost in the
// panel's flex column. Each carries
// ReplayOverlayMoveLogPrevRow { offset } — the
// per-frame system reads `offset` and recomputes the
// text on cursor advance. Painted in TEXT_SECONDARY
// so the active row stands out from context rows.
for offset in (1..=MOVE_LOG_PREV_ROWS as u8).rev() {
panel.spawn((
ReplayOverlayMoveLogPrevRow { offset },
Text::new(format_kth_recent_row(state, offset as usize + 1)),
TextFont {
font: font_handle_for_move_log.clone(),
font_size: TYPE_BODY,
..default()
},
TextColor(TEXT_SECONDARY),
));
}
// Active move row. Wrapped in a Node with an
// ACCENT_PRIMARY background so the row reads as
// "current focus" — the player can scan vertically
// and the highlighted row is the move that just
// applied. Empty text at spawn time when cursor=0;
// the per-frame update system populates it as the
// cursor advances. Text colour is TEXT_PRIMARY_HC
// (near-white) for contrast against the brick-red
// background — same trick as the modal-button
// primary-variant paint.
panel
.spawn((
Node {
width: Val::Percent(100.0),
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
..default()
},
BackgroundColor(ACCENT_PRIMARY),
))
.with_children(|active| {
active.spawn((
ReplayOverlayMoveLogActiveRow,
Text::new(format_active_move_row(state)),
TextFont {
font: font_handle_for_move_log.clone(),
font_size: TYPE_BODY,
..default()
},
TextColor(TEXT_PRIMARY_HC),
));
});
// Next rows — render below the active row in display
// order (offset 1 directly below active, then offset
// 2). Same TEXT_SECONDARY de-emphasis as prev rows so
// the active row stays the focal point. Empty text
// late in the replay (when cursor + offset exceeds
// moves.len()) — the panel under-fills gracefully.
for offset in 1..=MOVE_LOG_NEXT_ROWS as u8 {
panel.spawn((
ReplayOverlayMoveLogNextRow { offset },
Text::new(format_kth_next_row(state, offset as usize)),
TextFont {
font: font_handle_for_move_log.clone(),
font_size: TYPE_BODY,
..default()
},
TextColor(TEXT_SECONDARY),
));
}
});
// Mini-tableau preview panel — right-edge anchor, just below the banner.
// Compact two-row readout: foundation tops then stock/waste head.
// Sibling-of-banner pattern (separate root entity, own spawn/despawn).
let banner_bg = Color::srgba(
BG_ELEVATED_HI.to_srgba().red,
BG_ELEVATED_HI.to_srgba().green,
BG_ELEVATED_HI.to_srgba().blue,
BANNER_ALPHA,
);
commands
.spawn((
ReplayMiniTableauPanel,
DespawnWithReplay,
Node {
position_type: PositionType::Absolute,
right: Val::Px(0.0),
top: Val::Px(MINI_TABLEAU_TOP_OFFSET),
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
flex_direction: FlexDirection::Column,
align_items: AlignItems::FlexStart,
row_gap: VAL_SPACE_1,
border: UiRect::left(Val::Px(1.0)),
..default()
},
BackgroundColor(banner_bg),
BorderColor::all(BORDER_SUBTLE),
ZIndex(Z_REPLAY_OVERLAY),
GlobalZIndex(Z_REPLAY_OVERLAY),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|panel| {
panel.spawn((
Text::new("\u{258C} BOARD"),
TextFont {
font: font_handle_for_mini_tableau.clone(),
font_size: TYPE_CAPTION,
..default()
},
TextColor(ACCENT_PRIMARY),
));
panel.spawn((
ReplayMiniTableauFoundations,
Text::new("F: -- -- -- --"),
TextFont {
font: font_handle_for_mini_tableau.clone(),
font_size: TYPE_CAPTION,
..default()
},
TextColor(TEXT_PRIMARY),
));
panel.spawn((
ReplayMiniTableauStockWaste,
Text::new("STK:-- WST:--"),
TextFont {
font: font_handle_for_mini_tableau,
font_size: TYPE_CAPTION,
..default()
},
TextColor(TEXT_SECONDARY),
));
});
}
/// Pure helper — returns the scrub-fill width as a percentage of the
/// track for the given playback state. `Completed` reads as 100 %;
/// `Inactive` and `Playing` with no progress read as 0 %.
pub(crate) fn scrub_pct(state: &ReplayPlaybackState) -> f32 {
if state.is_completed() {
return 100.0;
}
match state.progress() {
Some((_, 0)) | None => 0.0,
Some((cursor, total)) => {
let frac = (cursor as f32 / total as f32).clamp(0.0, 1.0);
frac * 100.0
}
}
}
/// Pure helper — returns the fixed scrub-bar notch positions as
/// percentages along the track. Five evenly-spaced notches at the
/// quarter-marks: `[0, 25, 50, 75, 100]`. Function (rather than
/// const) so the unit-test surface is obvious and a future
/// regression — e.g. someone simplifying to three notches — fails
/// at the helper test rather than at visual review.
pub(crate) fn scrub_notch_positions() -> [f32; 5] {
[0.0, 25.0, 50.0, 75.0, 100.0]
}
/// Pure helper — returns the percentage-label text for each notch,
/// in left-to-right order. Paired with [`scrub_notch_positions`] so
/// `labels[i]` belongs at `positions[i]`. Lifted to a function for
/// the same reason as the positions helper: a clean unit-test
/// surface that fails at a regression (e.g. someone simplifying
/// `100%` → `MAX`) rather than at visual review.
pub(crate) fn scrub_notch_labels() -> [&'static str; 5] {
["0%", "25%", "50%", "75%", "100%"]
}
/// Pure helper — returns the vim-style mode indicator text shown on
/// the left side of the keybind-hint footer row. `▌ NORMAL │ replay`
/// matches the `▌replay.tsx` motif from the splash boot-screen and
/// the screen-takeover mockup. The cursor block (`▌`) matches the
/// banner-label prefix; "NORMAL" is the vim mode (mockup parity);
/// "replay" identifies the surface.
pub(crate) fn keybind_footer_mode_text() -> &'static str {
"\u{258C} NORMAL \u{2502} replay" // ▌ NORMAL │ replay
}
/// Pure helper — returns the keybind-hint text shown on the right
/// side of the keybind-hint footer row. Lists only the keys that
/// are *actually wired* today: the Space accelerator for
/// pause/resume, the ESC accelerator for stop, and the ← / →
/// accelerators for paused single-move stepping. The footer never
/// lists unimplemented keybinds (would lie to users).
pub(crate) fn keybind_footer_hint_text() -> &'static str {
if SHOW_KEYBOARD_ACCELERATORS {
"[SPACE] pause/resume \u{00B7} [ESC] stop \u{00B7} [\u{2190}\u{2192}] step" // · separator
} else {
""
}
}
/// 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.
pub(crate) 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)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Playback-control button handlers // Playback-control button handlers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+17 -63
View File
@@ -383,7 +383,7 @@ pub struct ReplayOverlayMoveLogPrevRow {
/// Marker on a "next move" row below the active row. `offset` /// Marker on a "next move" row below the active row. `offset`
/// is the 1-based distance forward from the active row: /// is the 1-based distance forward from the active row:
/// `offset = 1` is the move that will apply next /// `offset = 1` is the move that will apply next
/// (`moves[cursor]`, displayed as `cursor + 1`), /// (`replay.moves[cursor]`, displayed as `cursor + 1`),
/// `offset = 2` is the one after that, and so on. Up to /// `offset = 2` is the one after that, and so on. Up to
/// [`MOVE_LOG_NEXT_ROWS`] rows render below the active row. /// [`MOVE_LOG_NEXT_ROWS`] rows render below the active row.
/// ///
@@ -464,71 +464,25 @@ impl Plugin for ReplayOverlayPlugin {
Update, Update,
( (
react_to_state_change, react_to_state_change,
// Marker-filtered Text/Node repaints — each owns one update_banner_label,
// disjoint overlay marker, so they join the UiTextFx update_progress_text,
// club alongside the HUD text painters (#143). update_floating_progress_chip,
update_banner_label update_scrub_fill,
.in_set(crate::ui_theme::UiTextFx) update_move_log_header,
.ambiguous_with(crate::ui_theme::UiTextFx), update_move_log_active_row,
update_progress_text update_move_log_prev_rows,
.in_set(crate::ui_theme::UiTextFx) update_move_log_next_rows,
.ambiguous_with(crate::ui_theme::UiTextFx), update_mini_tableau_foundations,
update_floating_progress_chip update_mini_tableau_stock_waste,
.in_set(crate::ui_theme::UiTextFx) update_pause_button_label,
.ambiguous_with(crate::ui_theme::UiTextFx),
update_scrub_fill
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
update_move_log_header
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
update_move_log_active_row
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
update_move_log_prev_rows
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
update_move_log_next_rows
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
update_mini_tableau_foundations
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
update_mini_tableau_stock_waste
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
update_pause_button_label
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
handle_pause_button, handle_pause_button,
// Step/scrub dispatch into the shared move/draw/undo handle_step_button,
// request streams (drained wholesale at GameMutation),
// so they join the writer clubs (#143).
handle_step_button
.in_set(crate::game_plugin::MoveRequestWriters)
.ambiguous_with(crate::game_plugin::MoveRequestWriters)
.in_set(crate::game_plugin::DrawRequestWriters)
.ambiguous_with(crate::game_plugin::DrawRequestWriters),
handle_pause_keyboard, handle_pause_keyboard,
handle_stop_keyboard, handle_stop_keyboard,
handle_arrow_keyboard handle_arrow_keyboard,
.in_set(crate::game_plugin::MoveRequestWriters)
.ambiguous_with(crate::game_plugin::MoveRequestWriters)
.in_set(crate::game_plugin::UndoRequestWriters)
.ambiguous_with(crate::game_plugin::UndoRequestWriters)
.in_set(crate::game_plugin::DrawRequestWriters)
.ambiguous_with(crate::game_plugin::DrawRequestWriters),
handle_stop_button, handle_stop_button,
) )
.chain() .chain(),
// After the playback driver so painters render the
// post-tick state; before GameMutation so the step /
// scrub events are consumed this frame. Click handlers
// writing ReplayPlaybackState stay at the end of the
// chain — a press is observed by react_to_state_change
// next frame (state-driven loop, see above) (#143).
.after(crate::replay_playback::ReplayPlayback)
.before(crate::game_plugin::GameMutation),
); );
} }
} }
@@ -1304,11 +1258,11 @@ fn keybind_footer_hint_text() -> &'static str {
/// `win_move_index >= total` (defensive — shouldn't happen) doesn't /// `win_move_index >= total` (defensive — shouldn't happen) doesn't
/// position the marker outside the track. /// position the marker outside the track.
fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option<f32> { fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option<f32> {
let ReplayPlaybackState::Playing { replay, moves, .. } = state else { let ReplayPlaybackState::Playing { replay, .. } = state else {
return None; return None;
}; };
let idx = replay.win_move_index?; let idx = replay.win_move_index?;
let total = moves.len(); let total = replay.moves.len();
if total == 0 { if total == 0 {
return None; return None;
} }
+352 -109
View File
@@ -1,7 +1,7 @@
use super::*; use super::*;
use chrono::NaiveDate; use chrono::NaiveDate;
use solitaire_core::{DrawStockConfig, game_state::GameMode}; use solitaire_core::{DrawStockConfig, game_state::GameMode};
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, SessionRecording, Tableau}; use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
use solitaire_core::{Rank, Suit}; use solitaire_core::{Rank, Suit};
use solitaire_data::Replay; use solitaire_data::Replay;
@@ -17,11 +17,9 @@ fn synthetic_replay(move_count: usize) -> Replay {
120, 120,
1_000, 1_000,
NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"), NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
SessionRecording::from_instructions_unchecked( (0..move_count)
42, .map(|_| KlondikeInstruction::RotateStock)
DrawStockConfig::DrawOne, .collect(),
(0..move_count).map(|_| KlondikeInstruction::RotateStock),
),
) )
} }
@@ -95,7 +93,12 @@ fn overlay_spawns_when_playback_starts() {
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
@@ -114,7 +117,12 @@ fn overlay_progress_text_reflects_cursor() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 5,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
@@ -130,7 +138,12 @@ fn overlay_stop_button_click_clears_playback() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!(overlay_root_count(&mut app), 1); assert_eq!(overlay_root_count(&mut app), 1);
@@ -182,7 +195,12 @@ fn floating_chip_spawns_and_despawns_with_overlay() {
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(5),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -214,7 +232,12 @@ fn overlay_despawns_when_playback_returns_to_inactive() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(3), 1, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(3),
cursor: 1,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!(overlay_root_count(&mut app), 1); assert_eq!(overlay_root_count(&mut app), 1);
@@ -237,7 +260,12 @@ fn overlay_text_changes_on_completed() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(7), 7, 0.0, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(7),
cursor: 7,
secs_to_next: 0.0,
paused: false,
},
); );
app.update(); app.update();
assert_eq!(banner_text(&mut app), "\u{258C} replay"); assert_eq!(banner_text(&mut app), "\u{258C} replay");
@@ -286,30 +314,30 @@ fn scrub_pct_covers_state_corners() {
assert_eq!(scrub_pct(&ReplayPlaybackState::Inactive), 0.0); assert_eq!(scrub_pct(&ReplayPlaybackState::Inactive), 0.0);
assert_eq!(scrub_pct(&ReplayPlaybackState::Completed), 100.0); assert_eq!(scrub_pct(&ReplayPlaybackState::Completed), 100.0);
assert_eq!( assert_eq!(
scrub_pct(&ReplayPlaybackState::playing( scrub_pct(&ReplayPlaybackState::Playing {
synthetic_replay(10), replay: synthetic_replay(10),
0, cursor: 0,
0.5, secs_to_next: 0.5,
false, paused: false,
)), }),
0.0, 0.0,
); );
assert_eq!( assert_eq!(
scrub_pct(&ReplayPlaybackState::playing( scrub_pct(&ReplayPlaybackState::Playing {
synthetic_replay(10), replay: synthetic_replay(10),
5, cursor: 5,
0.5, secs_to_next: 0.5,
false, paused: false,
)), }),
50.0, 50.0,
); );
assert_eq!( assert_eq!(
scrub_pct(&ReplayPlaybackState::playing( scrub_pct(&ReplayPlaybackState::Playing {
synthetic_replay(10), replay: synthetic_replay(10),
10, cursor: 10,
0.5, secs_to_next: 0.5,
false, paused: false,
)), }),
100.0, 100.0,
); );
} }
@@ -339,12 +367,12 @@ fn format_game_caption_covers_state_corners() {
// Mar = 31, Apr = 30, May 2 = 122). Synthetic_replay always // Mar = 31, Apr = 30, May 2 = 122). Synthetic_replay always
// uses this date so the assertion is stable. // uses this date so the assertion is stable.
assert_eq!( assert_eq!(
format_game_caption(&ReplayPlaybackState::playing( format_game_caption(&ReplayPlaybackState::Playing {
synthetic_replay(10), replay: synthetic_replay(10),
5, cursor: 5,
0.5, secs_to_next: 0.5,
false, paused: false,
)), }),
Some("GAME #2026-122".to_string()), Some("GAME #2026-122".to_string()),
); );
@@ -353,7 +381,12 @@ fn format_game_caption_covers_state_corners() {
let mut early_january = synthetic_replay(10); let mut early_january = synthetic_replay(10);
early_january.recorded_at = NaiveDate::from_ymd_opt(2026, 1, 5).expect("valid date"); early_january.recorded_at = NaiveDate::from_ymd_opt(2026, 1, 5).expect("valid date");
assert_eq!( assert_eq!(
format_game_caption(&ReplayPlaybackState::playing(early_january, 0, 0.5, false,)), format_game_caption(&ReplayPlaybackState::Playing {
replay: early_january,
cursor: 0,
secs_to_next: 0.5,
paused: false,
}),
Some("GAME #2026-005".to_string()), Some("GAME #2026-005".to_string()),
); );
} }
@@ -366,7 +399,12 @@ fn overlay_game_caption_shows_replay_date() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!(game_caption_text(&mut app), "GAME #2026-122"); assert_eq!(game_caption_text(&mut app), "GAME #2026-122");
@@ -393,7 +431,12 @@ fn overlay_scrub_fill_tracks_cursor() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(8), 2, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(8),
cursor: 2,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -404,7 +447,12 @@ fn overlay_scrub_fill_tracks_cursor() {
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(8), 6, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(8),
cursor: 6,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -449,7 +497,12 @@ fn win_move_marker_pct_is_none_for_completed() {
fn win_move_marker_pct_is_none_when_replay_lacks_field() { fn win_move_marker_pct_is_none_when_replay_lacks_field() {
// Synthetic replay constructor leaves win_move_index as None // Synthetic replay constructor leaves win_move_index as None
// (legacy / pre-`ab857bb` path). // (legacy / pre-`ab857bb` path).
let state = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false); 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); assert_eq!(win_move_marker_pct(&state), None);
} }
@@ -458,12 +511,12 @@ fn win_move_marker_pct_is_some_at_correct_position() {
// 10 moves, win at index 9 → marker sits at 90 % of the track. // 10 moves, win at index 9 → marker sits at 90 % of the track.
// Matches the recording semantic: cursor reaches the marker // Matches the recording semantic: cursor reaches the marker
// exactly when the about-to-apply move IS the win move. // exactly when the about-to-apply move IS the win move.
let state = ReplayPlaybackState::playing( let state = ReplayPlaybackState::Playing {
synthetic_replay(10).with_win_move_index(Some(9)), replay: synthetic_replay(10).with_win_move_index(Some(9)),
0, cursor: 0,
0.5, secs_to_next: 0.5,
false, paused: false,
); };
assert_eq!(win_move_marker_pct(&state), Some(90.0)); assert_eq!(win_move_marker_pct(&state), Some(90.0));
} }
@@ -471,12 +524,12 @@ fn win_move_marker_pct_is_some_at_correct_position() {
fn win_move_marker_pct_clamps_to_track_bounds() { fn win_move_marker_pct_clamps_to_track_bounds() {
// Defensive: if a malformed replay carried `win_move_index >= // Defensive: if a malformed replay carried `win_move_index >=
// total`, the marker must still sit on the track, not past it. // total`, the marker must still sit on the track, not past it.
let state = ReplayPlaybackState::playing( let state = ReplayPlaybackState::Playing {
synthetic_replay(5).with_win_move_index(Some(99)), replay: synthetic_replay(5).with_win_move_index(Some(99)),
0, cursor: 0,
0.5, secs_to_next: 0.5,
false, paused: false,
); };
assert_eq!(win_move_marker_pct(&state), Some(100.0)); assert_eq!(win_move_marker_pct(&state), Some(100.0));
} }
@@ -485,12 +538,12 @@ fn marker_spawned_when_replay_has_win_move_index() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing( ReplayPlaybackState::Playing {
synthetic_replay(8).with_win_move_index(Some(7)), replay: synthetic_replay(8).with_win_move_index(Some(7)),
0, cursor: 0,
0.5, secs_to_next: 0.5,
false, paused: false,
), },
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -506,7 +559,12 @@ fn marker_not_spawned_when_replay_lacks_win_move_index() {
// Default constructor → win_move_index: None (legacy replay). // Default constructor → win_move_index: None (legacy replay).
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(8), 0, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(8),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -521,12 +579,12 @@ fn marker_despawns_when_replay_state_returns_to_inactive() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing( ReplayPlaybackState::Playing {
synthetic_replay(8).with_win_move_index(Some(7)), replay: synthetic_replay(8).with_win_move_index(Some(7)),
0, cursor: 0,
0.5, secs_to_next: 0.5,
false, paused: false,
), },
); );
app.update(); app.update();
assert_eq!(win_marker_count(&mut app), 1); assert_eq!(win_marker_count(&mut app), 1);
@@ -550,12 +608,12 @@ fn win_move_marker_carries_hc_background_marker() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing( ReplayPlaybackState::Playing {
synthetic_replay(8).with_win_move_index(Some(7)), replay: synthetic_replay(8).with_win_move_index(Some(7)),
0, cursor: 0,
0.5, secs_to_next: 0.5,
false, paused: false,
), },
); );
app.update(); app.update();
@@ -611,7 +669,12 @@ fn scrub_notches_spawn_with_overlay() {
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -630,7 +693,12 @@ fn scrub_notches_carry_high_contrast_background_marker() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
@@ -655,7 +723,12 @@ fn scrub_track_carries_high_contrast_background_marker() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
@@ -686,7 +759,12 @@ fn scrub_notches_despawn_with_overlay() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!(scrub_notch_count(&mut app), 5); assert_eq!(scrub_notch_count(&mut app), 5);
@@ -743,7 +821,12 @@ fn scrub_notch_labels_spawn_with_overlay() {
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -761,7 +844,12 @@ fn scrub_notch_labels_carry_helper_strings() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
@@ -782,7 +870,12 @@ fn scrub_notch_labels_despawn_with_overlay() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!(scrub_notch_label_count(&mut app), 5); assert_eq!(scrub_notch_label_count(&mut app), 5);
@@ -854,7 +947,12 @@ fn keybind_footer_spawns_with_overlay() {
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -872,7 +970,12 @@ fn keybind_footer_paints_helper_strings() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
@@ -896,7 +999,12 @@ fn keybind_footer_carries_high_contrast_border_marker() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
@@ -917,7 +1025,12 @@ fn keybind_footer_despawns_with_overlay() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!(keybind_footer_count(&mut app), 1); assert_eq!(keybind_footer_count(&mut app), 1);
@@ -941,7 +1054,12 @@ fn scrub_notches_spawn_even_without_win_marker() {
// Default constructor → win_move_index: None. // Default constructor → win_move_index: None.
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(8), 0, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(8),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -1026,7 +1144,12 @@ fn format_move_body_handles_stock_cycle() {
/// `Inactive` → empty. /// `Inactive` → empty.
#[test] #[test]
fn format_move_log_header_covers_state_branches() { fn format_move_log_header_covers_state_branches() {
let playing = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false); let playing = ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 3,
secs_to_next: 0.5,
paused: false,
};
assert_eq!( assert_eq!(
format_move_log_header(&playing), format_move_log_header(&playing),
"\u{258C} MOVE LOG \u{00B7} 3/10" "\u{258C} MOVE LOG \u{00B7} 3/10"
@@ -1044,14 +1167,24 @@ fn format_move_log_header_covers_state_branches() {
/// `replay.moves[N - 1]` and the row reads `"N | ..."`. /// `replay.moves[N - 1]` and the row reads `"N | ..."`.
#[test] #[test]
fn format_active_move_row_handles_cursor_zero_and_positive() { fn format_active_move_row_handles_cursor_zero_and_positive() {
let cursor_zero = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false); let cursor_zero = ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
};
assert_eq!( assert_eq!(
format_active_move_row(&cursor_zero), format_active_move_row(&cursor_zero),
"", "",
"cursor=0 means no move applied yet; row stays empty", "cursor=0 means no move applied yet; row stays empty",
); );
let cursor_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false); let cursor_three = ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 3,
secs_to_next: 0.5,
paused: false,
};
// synthetic_replay produces all StockClicks, so the body // synthetic_replay produces all StockClicks, so the body
// is "stock cycle". The displayed index is 3 (cursor), // is "stock cycle". The displayed index is 3 (cursor),
// matching the most-recently-applied move at moves[2]. // matching the most-recently-applied move at moves[2].
@@ -1075,7 +1208,12 @@ fn move_log_panel_spawns_with_overlay() {
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -1094,7 +1232,12 @@ fn move_log_panel_header_paints_helper_string() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(8), 2, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(8),
cursor: 2,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -1111,7 +1254,12 @@ fn move_log_active_row_repaints_on_cursor_advance() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -1123,7 +1271,12 @@ fn move_log_active_row_repaints_on_cursor_advance() {
// Advance cursor to 2 (most-recently-applied move is moves[1]). // Advance cursor to 2 (most-recently-applied move is moves[1]).
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(10), 2, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 2,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -1139,7 +1292,12 @@ fn move_log_active_row_repaints_on_cursor_advance() {
/// early in a replay don't paint stale text. /// early in a replay don't paint stale text.
#[test] #[test]
fn format_kth_recent_row_handles_in_range_and_out_of_range() { fn format_kth_recent_row_handles_in_range_and_out_of_range() {
let state_at_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false); let state_at_three = ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 3,
secs_to_next: 0.5,
paused: false,
};
// k=1 → active (most recent applied). cursor=3 → display=3. // k=1 → active (most recent applied). cursor=3 → display=3.
assert_eq!( assert_eq!(
format_kth_recent_row(&state_at_three, 1), format_kth_recent_row(&state_at_three, 1),
@@ -1190,7 +1348,12 @@ fn move_log_prev_rows_spawn_with_panel() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 3,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -1208,7 +1371,12 @@ fn move_log_prev_rows_paint_helper_strings_at_spawn() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 5,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
@@ -1233,7 +1401,12 @@ fn move_log_prev_rows_repaint_on_cursor_advance() {
// Start at cursor=2: offset 1 → k=2 → display=1, offset 2 → k=3 → empty (k > cursor). // Start at cursor=2: offset 1 → k=2 → display=1, offset 2 → k=3 → empty (k > cursor).
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(10), 2, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 2,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -1249,7 +1422,12 @@ fn move_log_prev_rows_repaint_on_cursor_advance() {
// Advance to cursor=5 — both offsets now have history. // Advance to cursor=5 — both offsets now have history.
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 5,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -1287,7 +1465,12 @@ fn move_log_next_row_text_at_offset(app: &mut App, offset: u8) -> String {
/// alongside in-range correctness. /// alongside in-range correctness.
#[test] #[test]
fn format_kth_next_row_handles_in_range_and_out_of_range() { fn format_kth_next_row_handles_in_range_and_out_of_range() {
let state_at_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false); let state_at_three = ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 3,
secs_to_next: 0.5,
paused: false,
};
// k=1 → moves[3], display=4 // k=1 → moves[3], display=4
assert_eq!( assert_eq!(
format_kth_next_row(&state_at_three, 1), format_kth_next_row(&state_at_three, 1),
@@ -1315,7 +1498,12 @@ fn move_log_next_rows_spawn_with_panel() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 3,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -1332,7 +1520,12 @@ fn move_log_next_rows_paint_helper_strings_at_spawn() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 5,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
@@ -1356,7 +1549,12 @@ fn move_log_next_rows_underfill_at_replay_end() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(10), 9, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 9,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -1380,7 +1578,12 @@ fn active_row_wrapper_carries_accent_primary_background() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 3,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
@@ -1418,7 +1621,12 @@ fn active_row_text_uses_high_contrast_color_for_highlight() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 3,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
@@ -1439,14 +1647,24 @@ fn active_row_text_uses_high_contrast_color_for_highlight() {
/// dropping it has to also update this test. /// dropping it has to also update this test.
#[test] #[test]
fn active_row_format_includes_focus_prefix() { fn active_row_format_includes_focus_prefix() {
let state = ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false); let state = ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 5,
secs_to_next: 0.5,
paused: false,
};
let row = format_active_move_row(&state); let row = format_active_move_row(&state);
assert!( assert!(
row.starts_with('\u{25B6}'), row.starts_with('\u{25B6}'),
"active-row format must start with ▶ focus marker; got {row:?}", "active-row format must start with ▶ focus marker; got {row:?}",
); );
// Cursor=0 still returns empty, never just the prefix. // Cursor=0 still returns empty, never just the prefix.
let cursor_zero = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false); let cursor_zero = ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
};
assert_eq!( assert_eq!(
format_active_move_row(&cursor_zero), format_active_move_row(&cursor_zero),
"", "",
@@ -1461,7 +1679,12 @@ fn move_log_panel_despawns_with_overlay() {
let mut app = headless_app(); let mut app = headless_app();
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!(move_log_panel_count(&mut app), 1); assert_eq!(move_log_panel_count(&mut app), 1);
@@ -1505,11 +1728,21 @@ fn unique_button<M: Component>(app: &mut App) -> Entity {
} }
fn pressed_paused_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState { fn pressed_paused_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState {
ReplayPlaybackState::playing(synthetic_replay(replay_len), cursor, 0.5, true) ReplayPlaybackState::Playing {
replay: synthetic_replay(replay_len),
cursor,
secs_to_next: 0.5,
paused: true,
}
} }
fn running_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState { fn running_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState {
ReplayPlaybackState::playing(synthetic_replay(replay_len), cursor, 0.5, false) ReplayPlaybackState::Playing {
replay: synthetic_replay(replay_len),
cursor,
secs_to_next: 0.5,
paused: false,
}
} }
#[test] #[test]
@@ -1958,7 +2191,12 @@ fn dim_layer_spawns_and_despawns_with_overlay() {
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(5),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -2017,7 +2255,12 @@ fn mini_tableau_panel_spawns_and_despawns_with_overlay() {
set_state( set_state(
&mut app, &mut app,
ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false), ReplayPlaybackState::Playing {
replay: synthetic_replay(5),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
); );
app.update(); app.update();
assert_eq!( assert_eq!(
@@ -83,11 +83,11 @@ pub(crate) fn update_floating_progress_chip(
// any). `cursor` is the index of the *next* move to apply, so // any). `cursor` is the index of the *next* move to apply, so
// the most-recently-applied move sits at `cursor - 1`. // the most-recently-applied move sits at `cursor - 1`.
let dest_pile = match state.as_ref() { let dest_pile = match state.as_ref() {
ReplayPlaybackState::Playing { moves, cursor, .. } if *cursor > 0 => { ReplayPlaybackState::Playing { replay, cursor, .. } if *cursor > 0 => {
// The destination pile is recoverable directly from the // The destination pile is recoverable directly from the
// instruction — no live state needed. `RotateStock` has no // instruction — no live state needed. `RotateStock` has no
// destination (the chip hides over the stock pile). // destination (the chip hides over the stock pile).
match &moves[cursor - 1] { match &replay.moves[cursor - 1] {
KlondikeInstruction::DstFoundation(dst) => { KlondikeInstruction::DstFoundation(dst) => {
Some(KlondikePile::Foundation(dst.foundation)) Some(KlondikePile::Foundation(dst.foundation))
} }
+35 -107
View File
@@ -1,12 +1,11 @@
//! In-engine replay playback core. //! In-engine replay playback core.
//! //!
//! When the player clicks "Watch replay" on the Stats overlay, the live //! When the player clicks "Watch replay" on the Stats overlay, the live
//! game state is reset to the recorded deal — rebuilt directly from //! game state is reset to the deal seeded from the replay's `seed` /
//! `replay.recording` via `GameState::from_recording`, never by //! `mode` / `draw_mode`, and the engine ticks through `replay.moves` at a
//! re-dealing the seed — and the engine ticks through the recording's //! steady cadence — firing the canonical [`MoveRequestEvent`] /
//! instruction list at a steady cadence, firing the canonical //! [`DrawRequestEvent`] for each one. The existing animation pipeline
//! [`MoveRequestEvent`] / [`DrawRequestEvent`] for each one. The //! plays back identically to a live game.
//! existing animation pipeline plays back identically to a live game.
//! //!
//! ## Public surface //! ## Public surface
//! //!
@@ -96,7 +95,7 @@ pub const REPLAY_COMPLETION_LINGER_SECS: f32 = 5.0;
/// 3. The tick system [`tick_replay_playback`] advances `cursor` once /// 3. The tick system [`tick_replay_playback`] advances `cursor` once
/// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event /// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event
/// for each [`KlondikeInstruction`]. /// for each [`KlondikeInstruction`].
/// 4. When `cursor == moves.len()`, the state transitions to /// 4. When `cursor == replay.moves.len()`, the state transitions to
/// [`Completed`](Self::Completed). It lingers for /// [`Completed`](Self::Completed). It lingers for
/// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by /// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by
/// [`auto_clear_completed_replay`]) before returning to /// [`auto_clear_completed_replay`]) before returning to
@@ -110,20 +109,14 @@ pub enum ReplayPlaybackState {
#[default] #[default]
Inactive, Inactive,
/// A replay is currently being played back. The overlay reads /// A replay is currently being played back. The overlay reads
/// `moves.len()` for the denominator of the progress /// `replay.moves.len()` for the denominator of the progress
/// indicator and `cursor` for the numerator. /// indicator and `cursor` for the numerator.
Playing { Playing {
/// The replay being played back. Owned so the state is the /// The replay being played back. Owned so the state is the
/// only place playback metadata lives — no separate resource /// only place playback metadata lives — no separate resource
/// needed. Boxed: the v4 recording embeds the dealt board and /// needed.
/// would otherwise dwarf the other variants replay: Replay,
/// (`clippy::large_enum_variant`). /// Index of the next move to apply, in `[0, replay.moves.len()]`.
replay: Box<Replay>,
/// Instruction list extracted once from `replay.recording` at
/// [`start_replay_playback`] time, so the per-frame tick indexes
/// a plain slice instead of re-walking the recording.
moves: Vec<KlondikeInstruction>,
/// Index of the next move to apply, in `[0, moves.len()]`.
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,
@@ -145,19 +138,6 @@ pub enum ReplayPlaybackState {
} }
impl ReplayPlaybackState { impl ReplayPlaybackState {
/// Construct a [`Self::Playing`] state, extracting the instruction
/// list from `replay.recording` once up front.
pub fn playing(replay: Replay, cursor: usize, secs_to_next: f32, paused: bool) -> Self {
let moves = replay.recording.instructions();
Self::Playing {
replay: Box::new(replay),
moves,
cursor,
secs_to_next,
paused,
}
}
/// Returns `true` when a replay is currently being played back. /// Returns `true` when a replay is currently being played back.
pub fn is_playing(&self) -> bool { pub fn is_playing(&self) -> bool {
matches!(self, Self::Playing { .. }) matches!(self, Self::Playing { .. })
@@ -176,7 +156,7 @@ impl ReplayPlaybackState {
/// the total is no longer available in `Completed`. /// the total is no longer available in `Completed`.
pub fn progress(&self) -> Option<(usize, usize)> { pub fn progress(&self) -> Option<(usize, usize)> {
match self { match self {
Self::Playing { moves, cursor, .. } => Some((*cursor, moves.len())), Self::Playing { replay, cursor, .. } => Some((*cursor, replay.moves.len())),
Self::Inactive | Self::Completed => None, Self::Inactive | Self::Completed => None,
} }
} }
@@ -211,11 +191,7 @@ pub fn start_replay_playback(
) { ) {
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
// The recording carries the dealt board and the session config let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode, replay.mode);
// (including draw mode), so the fresh game is bit-identical to the
// recorded deal regardless of how the current build maps seeds to
// deals — `replay.seed` is presentation metadata only.
let (fresh, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode);
commands.insert_resource(GameStateResource(fresh)); commands.insert_resource(GameStateResource(fresh));
// Initial `secs_to_next` uses the constant rather than reading // Initial `secs_to_next` uses the constant rather than reading
@@ -225,8 +201,7 @@ pub fn start_replay_playback(
// unusually short setting; subsequent ticks read the live setting // unusually short setting; subsequent ticks read the live setting
// every frame via [`tick_replay_playback`]. // every frame via [`tick_replay_playback`].
**state = ReplayPlaybackState::Playing { **state = ReplayPlaybackState::Playing {
replay: Box::new(replay), replay,
moves,
cursor: 0, cursor: 0,
secs_to_next: REPLAY_MOVE_INTERVAL_SECS, secs_to_next: REPLAY_MOVE_INTERVAL_SECS,
paused: false, paused: false,
@@ -281,7 +256,7 @@ pub fn step_replay_playback(
draws_writer: &mut MessageWriter<DrawRequestEvent>, draws_writer: &mut MessageWriter<DrawRequestEvent>,
) -> bool { ) -> bool {
let ReplayPlaybackState::Playing { let ReplayPlaybackState::Playing {
moves, replay,
cursor, cursor,
paused: true, paused: true,
.. ..
@@ -289,10 +264,10 @@ pub fn step_replay_playback(
else { else {
return false; return false;
}; };
if *cursor >= moves.len() { if *cursor >= replay.moves.len() {
return false; return false;
} }
let instruction = moves[*cursor]; let instruction = replay.moves[*cursor];
dispatch_instruction(instruction, *cursor, game, moves_writer, draws_writer); dispatch_instruction(instruction, *cursor, game, moves_writer, draws_writer);
*cursor += 1; *cursor += 1;
true true
@@ -387,7 +362,7 @@ pub fn step_backwards_replay_playback(
/// Drains `secs_to_next` by `time.delta_secs()`. When the countdown /// Drains `secs_to_next` by `time.delta_secs()`. When the countdown
/// expires, fires the canonical event for the move at `cursor`, /// expires, fires the canonical event for the move at `cursor`,
/// increments `cursor`, and resets `secs_to_next`. When `cursor` /// increments `cursor`, and resets `secs_to_next`. When `cursor`
/// reaches `moves.len()`, transitions to /// reaches `replay.moves.len()`, transitions to
/// [`ReplayPlaybackState::Completed`]. /// [`ReplayPlaybackState::Completed`].
/// ///
/// The advance loop is a `while`, not an `if`, so coarse time steps /// The advance loop is a `while`, not an `if`, so coarse time steps
@@ -409,11 +384,10 @@ fn tick_replay_playback(
let mut transition_to_completed = false; let mut transition_to_completed = false;
if let ReplayPlaybackState::Playing { if let ReplayPlaybackState::Playing {
moves, replay,
cursor, cursor,
secs_to_next, secs_to_next,
paused, paused,
..
} = state.as_mut() } = state.as_mut()
{ {
// While paused, the cursor and the timer freeze together — // While paused, the cursor and the timer freeze together —
@@ -423,8 +397,8 @@ fn tick_replay_playback(
// path. // path.
if !*paused { if !*paused {
*secs_to_next -= dt; *secs_to_next -= dt;
while *secs_to_next <= 0.0 && *cursor < moves.len() { while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
let instruction = moves[*cursor]; let instruction = replay.moves[*cursor];
dispatch_instruction( dispatch_instruction(
instruction, instruction,
*cursor, *cursor,
@@ -436,7 +410,7 @@ fn tick_replay_playback(
*secs_to_next += interval; *secs_to_next += interval;
} }
if *cursor >= moves.len() { if *cursor >= replay.moves.len() {
transition_to_completed = true; transition_to_completed = true;
} }
} }
@@ -550,14 +524,6 @@ fn fire_state_changed_on_completion(
*last_was_completed = now_completed; *last_was_completed = now_completed;
} }
/// Set wrapping the replay playback driver chain (tick → auto-clear →
/// completion `StateChangedEvent`). The tick feeds `MoveRequestEvent` /
/// `DrawRequestEvent` into this frame's [`GameMutation`], so the set sits
/// on the pre-mutation spine; the overlay UI (`replay_overlay`) orders
/// itself after this set so its painters render the post-tick state (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct ReplayPlayback;
/// Bevy plugin that initialises [`ReplayPlaybackState`] and drives /// Bevy plugin that initialises [`ReplayPlaybackState`] and drives
/// playback ticks, completion linger, and the recording-pause guard. /// playback ticks, completion linger, and the recording-pause guard.
/// ///
@@ -570,46 +536,18 @@ impl Plugin for ReplayPlaybackPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<ReplayPlaybackState>() app.init_resource::<ReplayPlaybackState>()
.add_message::<StateChangedEvent>() .add_message::<StateChangedEvent>()
.configure_sets(
Update,
ReplayPlayback
.after(crate::game_plugin::PreMutation)
// Input first: a stop/pause press or a drag-emitted
// request this frame is observed before the tick fires
// the next scripted move (#143).
.after(crate::input_plugin::PointerInput)
.before(GameMutation),
)
.add_systems( .add_systems(
Update, Update,
( (
// The tick shares the move/draw request streams with the tick_replay_playback,
// pointer/keyboard writers (drained wholesale at
// GameMutation) and reads playback speed from settings.
tick_replay_playback
.in_set(crate::game_plugin::MoveRequestWriters)
.ambiguous_with(crate::game_plugin::MoveRequestWriters)
.in_set(crate::game_plugin::DrawRequestWriters)
.ambiguous_with(crate::game_plugin::DrawRequestWriters)
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
auto_clear_completed_replay, auto_clear_completed_replay,
// Chained before GameMutation: explicit order against the
// mutation systems' StateChangedEvent writes; readers
// drain the whole queue after GameMutation.
fire_state_changed_on_completion, fire_state_changed_on_completion,
) )
.chain() .chain(),
.in_set(ReplayPlayback),
) )
.add_systems( .add_systems(
Update, Update,
record_replay_skip_during_playback record_replay_skip_during_playback.after(GameMutation),
.after(GameMutation)
// After AutoComplete so the stats overlay's watch-replay
// handler (which resets ReplayPlaybackState) and the
// win-path recorder have both settled (#143).
.after(crate::auto_complete_plugin::AutoComplete),
); );
} }
} }
@@ -624,7 +562,8 @@ mod tests {
use crate::game_plugin::GamePlugin; use crate::game_plugin::GamePlugin;
use bevy::time::TimeUpdateStrategy; use bevy::time::TimeUpdateStrategy;
use chrono::NaiveDate; use chrono::NaiveDate;
use solitaire_core::{DrawStockConfig, SessionRecording, game_state::GameMode}; use solitaire_core::KlondikeInstruction;
use solitaire_core::{DrawStockConfig, game_state::GameMode};
use std::time::Duration; use std::time::Duration;
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and /// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
@@ -673,11 +612,11 @@ mod tests {
60, 60,
500, 500,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"), NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
SessionRecording::from_instructions_unchecked( vec![
12345, KlondikeInstruction::RotateStock,
DrawStockConfig::DrawOne, KlondikeInstruction::RotateStock,
[KlondikeInstruction::RotateStock; 3], KlondikeInstruction::RotateStock,
), ],
) )
} }
@@ -734,14 +673,11 @@ mod tests {
let state = app.world().resource::<ReplayPlaybackState>(); let state = app.world().resource::<ReplayPlaybackState>();
match state { match state {
ReplayPlaybackState::Playing { ReplayPlaybackState::Playing {
cursor, cursor, replay: r, ..
replay: r,
moves,
..
} => { } => {
assert_eq!(*cursor, 0); assert_eq!(*cursor, 0);
assert_eq!(r.seed, replay.seed); assert_eq!(r.seed, replay.seed);
assert_eq!(moves.len(), 3); assert_eq!(r.moves.len(), 3);
} }
other => panic!("expected Playing, got {other:?}"), other => panic!("expected Playing, got {other:?}"),
} }
@@ -842,11 +778,7 @@ mod tests {
10, 10,
100, 100,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"), NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
SessionRecording::from_instructions_unchecked( vec![KlondikeInstruction::RotateStock],
42,
DrawStockConfig::DrawOne,
[KlondikeInstruction::RotateStock],
),
); );
start_playback(&mut app, one_move); start_playback(&mut app, one_move);
app.update(); app.update();
@@ -955,11 +887,7 @@ mod tests {
10, 10,
100, 100,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"), NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
SessionRecording::from_instructions_unchecked( vec![KlondikeInstruction::RotateStock; 10],
7,
DrawStockConfig::DrawOne,
[KlondikeInstruction::RotateStock; 10],
),
) )
} }
+1 -20
View File
@@ -6,8 +6,8 @@ use std::sync::Arc;
use bevy::math::Vec2; use bevy::math::Vec2;
use bevy::prelude::Resource; use bevy::prelude::Resource;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use solitaire_core::Card;
use solitaire_core::KlondikePile; use solitaire_core::KlondikePile;
use solitaire_core::Card;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game. /// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
@@ -102,25 +102,6 @@ pub enum SyncStatus {
#[derive(Resource, Debug, Clone, Default)] #[derive(Resource, Debug, Clone, Default)]
pub struct SyncStatusResource(pub SyncStatus); pub struct SyncStatusResource(pub SyncStatus);
/// Outcome of the most recent successful sync merge (Phase M — sync
/// transparency). `None` until the first pull of the session resolves.
///
/// The Account tab renders the conflict details; the sync poller emits a
/// one-line toast summary when `conflicts` is non-empty.
#[derive(Resource, Debug, Clone, Default)]
pub struct SyncConflictLog(pub Option<SyncMergeSummary>);
/// Timestamp + per-field conflict list captured from
/// [`solitaire_sync::merge`] after a pull resolves.
#[derive(Debug, Clone)]
pub struct SyncMergeSummary {
/// When the merge resolved.
pub at: DateTime<Utc>,
/// Fields that could not be merged deterministically (best-effort
/// resolution already applied — see `solitaire_sync::merge`).
pub conflicts: Vec<solitaire_sync::ConflictReport>,
}
/// Tracks which hint the player is currently cycling through. /// Tracks which hint the player is currently cycling through.
/// ///
/// Incremented on each H press so repeated presses reveal different moves. /// Incremented on each H press so repeated presses reveal different moves.
+13 -59
View File
@@ -74,34 +74,15 @@ impl Plugin for SafeAreaInsetsPlugin {
.add_systems( .add_systems(
Update, Update,
( (
// All three write Node padding on disjoint chrome roots apply_safe_area_anchors,
// (top anchors / bottom anchors / modal scrims); chained apply_safe_area_bottom_anchors,
// for a deterministic pass, and they join the UiTextFx apply_safe_area_to_modal_scrims,
// chrome club — anchored roots are never the label/ on_app_resumed,
// tooltip/readout entities the other members touch,
// which Bevy can't prove from the queries (#143).
(
apply_safe_area_anchors,
apply_safe_area_bottom_anchors,
apply_safe_area_to_modal_scrims,
)
.chain()
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx)
// UiScale is written on the settings mutator spine.
.after(crate::settings_plugin::SettingsMutation),
// Emits a synthetic WindowResized on resume only; every
// reader drains the queue via cursor, and a resume
// already spans hundreds of ms, so whether the synthetic
// event lands this frame or next is invisible — same
// argument as handle_fullscreen (#143).
on_app_resumed.ambiguous_with_all(),
), ),
); );
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
app.add_message::<crate::events::StateChangedEvent>() app.init_resource::<android::SafeAreaPollTries>()
.init_resource::<android::SafeAreaPollTries>()
.add_systems(Update, android::refresh_insets) .add_systems(Update, android::refresh_insets)
.add_systems(Update, android::rearm_on_resumed) .add_systems(Update, android::rearm_on_resumed)
.add_systems(Update, android::refresh_surface_size); .add_systems(Update, android::refresh_surface_size);
@@ -119,10 +100,9 @@ impl Plugin for SafeAreaInsetsPlugin {
fn apply_safe_area_anchors( fn apply_safe_area_anchors(
insets: Res<SafeAreaInsets>, insets: Res<SafeAreaInsets>,
windows: Query<&Window>, windows: Query<&Window>,
ui_scale: Option<Res<UiScale>>,
mut q: Query<(&SafeAreaAnchoredTop, &mut Node)>, mut q: Query<(&SafeAreaAnchoredTop, &mut Node)>,
) { ) {
if !insets.is_changed() && !ui_scale.as_ref().is_some_and(|s| s.is_changed()) { if !insets.is_changed() {
return; return;
} }
// Android's WindowInsets API returns physical pixels; Bevy UI's Val::Px // Android's WindowInsets API returns physical pixels; Bevy UI's Val::Px
@@ -138,31 +118,19 @@ fn apply_safe_area_anchors(
); );
} }
let top_logical = raw_top.min(max_inset); let top_logical = raw_top.min(max_inset);
// `bevy::ui::UiScale` (Phase K) multiplies every Val::Px at layout
// time. `base_top` is UI chrome and SHOULD scale with the rest of
// the interface, but the system-bar inset is physical reality —
// pre-divide it so the post-multiplication offset stays exact.
let ui = effective_ui_scale(ui_scale.as_deref());
for (anchor, mut node) in &mut q { for (anchor, mut node) in &mut q {
node.top = Val::Px(anchor.base_top + top_logical / ui); node.top = Val::Px(anchor.base_top + top_logical);
} }
} }
/// The live UI scale, defensively clamped — a zero or negative scale
/// would flip or destroy the inset math.
fn effective_ui_scale(ui_scale: Option<&UiScale>) -> f32 {
ui_scale.map_or(1.0, |s| s.0.max(0.1))
}
/// Re-applies `base_bottom + insets.bottom / scale` to every entity carrying /// Re-applies `base_bottom + insets.bottom / scale` to every entity carrying
/// [`SafeAreaAnchoredBottom`] whenever [`SafeAreaInsets`] changes. /// [`SafeAreaAnchoredBottom`] whenever [`SafeAreaInsets`] changes.
fn apply_safe_area_bottom_anchors( fn apply_safe_area_bottom_anchors(
insets: Res<SafeAreaInsets>, insets: Res<SafeAreaInsets>,
windows: Query<&Window>, windows: Query<&Window>,
ui_scale: Option<Res<UiScale>>,
mut q: Query<(&SafeAreaAnchoredBottom, &mut Node)>, mut q: Query<(&SafeAreaAnchoredBottom, &mut Node)>,
) { ) {
if !insets.is_changed() && !ui_scale.as_ref().is_some_and(|s| s.is_changed()) { if !insets.is_changed() {
return; return;
} }
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor()); let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
@@ -175,12 +143,8 @@ fn apply_safe_area_bottom_anchors(
); );
} }
let bottom_logical = raw_bottom.min(max_inset); let bottom_logical = raw_bottom.min(max_inset);
// See `apply_safe_area_anchors`: the physical inset is pre-divided
// by the UI scale so a 90% setting can't sink the action bar into
// the gesture zone (and 130% doesn't over-inset it).
let ui = effective_ui_scale(ui_scale.as_deref());
for (anchor, mut node) in &mut q { for (anchor, mut node) in &mut q {
node.bottom = Val::Px(anchor.base_bottom + bottom_logical / ui); node.bottom = Val::Px(anchor.base_bottom + bottom_logical);
} }
} }
@@ -198,23 +162,19 @@ fn apply_safe_area_bottom_anchors(
fn apply_safe_area_to_modal_scrims( fn apply_safe_area_to_modal_scrims(
insets: Res<SafeAreaInsets>, insets: Res<SafeAreaInsets>,
windows: Query<&Window>, windows: Query<&Window>,
ui_scale: Option<Res<UiScale>>,
mut scrims: Query<&mut Node, With<ModalScrim>>, mut scrims: Query<&mut Node, With<ModalScrim>>,
new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>, new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
) { ) {
let has_new = !new_scrims.is_empty(); let has_new = !new_scrims.is_empty();
if !insets.is_changed() && !has_new && !ui_scale.as_ref().is_some_and(|s| s.is_changed()) { if !insets.is_changed() && !has_new {
return; return;
} }
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor()); let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
let window_height = windows.iter().next().map_or(800.0, |w| w.height()); let window_height = windows.iter().next().map_or(800.0, |w| w.height());
// Clamp each inset to 25% of screen height so an unexpectedly large OS // Clamp each inset to 25% of screen height so an unexpectedly large OS
// value can't push the modal card off the visible area entirely. // value can't push the modal card off the visible area entirely.
// Physical insets are pre-divided by the UI scale — see let top_logical = (insets.top / scale).min(window_height * 0.25);
// `apply_safe_area_anchors`. let bottom_logical = (insets.bottom / scale).min(window_height * 0.25);
let ui = effective_ui_scale(ui_scale.as_deref());
let top_logical = (insets.top / scale).min(window_height * 0.25) / ui;
let bottom_logical = (insets.bottom / scale).min(window_height * 0.25) / ui;
for mut node in &mut scrims { for mut node in &mut scrims {
// Set both edges so the scrim's content box equals the usable area // Set both edges so the scrim's content box equals the usable area
// between the status bar and the gesture/navigation bar. With // between the status bar and the gesture/navigation bar. With
@@ -370,16 +330,11 @@ mod android {
/// `on_window_resized` in `table_plugin` recomputes the board layout, /// `on_window_resized` in `table_plugin` recomputes the board layout,
/// 3. re-arms the inset poller, because a screen change almost always /// 3. re-arms the inset poller, because a screen change almost always
/// moves the system bars too — covering the "re-poll never fires" /// moves the system bars too — covering the "re-poll never fires"
/// hole left open in #116, /// hole left open in #116.
/// 4. emits `StateChangedEvent` so `card_plugin`'s sync pipeline
/// re-renders every card sprite from scratch — belt-and-braces for
/// the transient tableau clip of #130, where geometry converged but
/// stale card visuals survived the relayout.
pub(super) fn refresh_surface_size( pub(super) fn refresh_surface_size(
mut frame: Local<u32>, mut frame: Local<u32>,
mut windows: Query<(Entity, &mut Window)>, mut windows: Query<(Entity, &mut Window)>,
mut resize_events: MessageWriter<WindowResized>, mut resize_events: MessageWriter<WindowResized>,
mut state_events: MessageWriter<crate::events::StateChangedEvent>,
mut poll: ResMut<SafeAreaPollTries>, mut poll: ResMut<SafeAreaPollTries>,
) { ) {
const POLL_INTERVAL_FRAMES: u32 = 30; // ~0.5 s @ 60 fps const POLL_INTERVAL_FRAMES: u32 = 30; // ~0.5 s @ 60 fps
@@ -426,7 +381,6 @@ mod android {
width: decor_w as f32 / scale, width: decor_w as f32 / scale,
height: decor_h as f32 / scale, height: decor_h as f32 / scale,
}); });
state_events.write(crate::events::StateChangedEvent);
poll.0 = 0; poll.0 = 0;
} }
+4 -64
View File
@@ -23,43 +23,14 @@ mod tests {
use bevy::ecs::schedule::{LogLevel, ScheduleBuildSettings}; use bevy::ecs::schedule::{LogLevel, ScheduleBuildSettings};
use bevy::prelude::*; use bevy::prelude::*;
use crate::achievement_plugin::AchievementPlugin;
use crate::animation_plugin::AnimationPlugin;
use crate::auto_complete_plugin::AutoCompletePlugin; use crate::auto_complete_plugin::AutoCompletePlugin;
use crate::card_animation::CardAnimationPlugin;
use crate::card_plugin::CardPlugin; use crate::card_plugin::CardPlugin;
use crate::challenge_plugin::ChallengePlugin;
use crate::cursor_plugin::CursorPlugin;
use crate::daily_challenge_plugin::DailyChallengePlugin;
use crate::difficulty_plugin::DifficultyPlugin;
use crate::feedback_anim_plugin::FeedbackAnimPlugin;
use crate::game_plugin::GamePlugin; use crate::game_plugin::GamePlugin;
use crate::help_plugin::HelpPlugin;
use crate::home_plugin::HomePlugin;
use crate::hud_plugin::HudPlugin; use crate::hud_plugin::HudPlugin;
use crate::input_plugin::InputPlugin;
use crate::leaderboard_plugin::LeaderboardPlugin;
use crate::onboarding_plugin::OnboardingPlugin;
use crate::pause_plugin::PausePlugin;
use crate::play_by_seed_plugin::PlayBySeedPlugin;
use crate::profile_plugin::ProfilePlugin;
use crate::progress_plugin::ProgressPlugin;
use crate::radial_menu::RadialMenuPlugin;
use crate::replay_overlay::ReplayOverlayPlugin;
use crate::replay_playback::ReplayPlaybackPlugin;
use crate::safe_area::SafeAreaInsetsPlugin;
use crate::selection_plugin::SelectionPlugin;
use crate::settings_plugin::SettingsPlugin; use crate::settings_plugin::SettingsPlugin;
use crate::splash_plugin::SplashPlugin;
use crate::stats_plugin::StatsPlugin;
use crate::table_plugin::TablePlugin; use crate::table_plugin::TablePlugin;
use crate::time_attack_plugin::TimeAttackPlugin;
use crate::touch_selection_plugin::TouchSelectionPlugin;
use crate::ui_focus::UiFocusPlugin; use crate::ui_focus::UiFocusPlugin;
use crate::ui_modal::UiModalPlugin; use crate::ui_modal::UiModalPlugin;
use crate::ui_tooltip::UiTooltipPlugin;
use crate::weekly_goals_plugin::WeeklyGoalsPlugin;
use crate::win_summary_plugin::WinSummaryPlugin;
/// The backlog (302 pairs on 2026-07-06) was burned down to ZERO the /// The backlog (302 pairs on 2026-07-06) was burned down to ZERO the
/// same day (#143, PRs #146#149) — this is now a hard gate. If your /// same day (#143, PRs #146#149) — this is now a hard gate. If your
@@ -80,40 +51,7 @@ mod tests {
.add_plugins(AutoCompletePlugin) .add_plugins(AutoCompletePlugin)
.add_plugins(UiModalPlugin) .add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin) .add_plugins(UiFocusPlugin)
.add_plugins(SettingsPlugin::headless()) .add_plugins(SettingsPlugin::headless());
// Batch: game-adjacent plugins (all already exercised headless
// by the engine behaviour tests).
.add_plugins(ProgressPlugin::headless())
.add_plugins(StatsPlugin::headless())
.add_plugins(AchievementPlugin::headless())
.add_plugins(SelectionPlugin)
.add_plugins(AnimationPlugin)
.add_plugins(CardAnimationPlugin)
.add_plugins(FeedbackAnimPlugin)
.add_plugins(PausePlugin)
// Batch: screen/overlay plugins.
.add_plugins(HomePlugin::headless())
.add_plugins(WinSummaryPlugin)
.add_plugins(SplashPlugin)
.add_plugins(HelpPlugin)
.add_plugins(OnboardingPlugin)
.add_plugins(ProfilePlugin)
.add_plugins(LeaderboardPlugin)
// Batch: mode/replay/input plugins.
.add_plugins(ChallengePlugin)
.add_plugins(DailyChallengePlugin)
.add_plugins(TimeAttackPlugin)
.add_plugins(WeeklyGoalsPlugin)
.add_plugins(PlayBySeedPlugin)
.add_plugins(DifficultyPlugin)
.add_plugins(ReplayPlaybackPlugin)
.add_plugins(ReplayOverlayPlugin)
.add_plugins(RadialMenuPlugin)
.add_plugins(InputPlugin)
.add_plugins(TouchSelectionPlugin)
.add_plugins(UiTooltipPlugin)
.add_plugins(CursorPlugin)
.add_plugins(SafeAreaInsetsPlugin);
app.init_resource::<ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
app app
} }
@@ -152,7 +90,9 @@ mod tests {
.and_then(|prefix| prefix.split_whitespace().last()) .and_then(|prefix| prefix.split_whitespace().last())
.and_then(|n| n.parse::<usize>().ok()); .and_then(|n| n.parse::<usize>().ok());
parsed.unwrap_or_else(|| { parsed.unwrap_or_else(|| {
panic!("ambiguity panic message no longer parseable (Bevy upgrade?): {msg}") panic!(
"ambiguity panic message no longer parseable (Bevy upgrade?): {msg}"
)
}) })
} }
}; };
+40 -47
View File
@@ -37,9 +37,9 @@
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::Card; use solitaire_core::Card;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::card_plugin::CardEntityIndex; use crate::card_plugin::CardEntityIndex;
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent}; use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
@@ -134,14 +134,6 @@ impl KeyboardDragState {
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct SelectionKeySet; pub struct SelectionKeySet;
/// Set wrapping the post-mutation selection maintenance chain (clearing the
/// selection on state change, painting the highlight). Runs between
/// [`crate::game_plugin::GameMutation`] and
/// [`crate::card_plugin::BoardVisuals`] so the painters read the settled
/// selection/drag state (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct SelectionFx;
/// Marker component placed on the outline sprite used as the keyboard-selection /// Marker component placed on the outline sprite used as the keyboard-selection
/// highlight. /// highlight.
/// ///
@@ -166,35 +158,14 @@ impl Plugin for SelectionPlugin {
( (
handle_selection_keys handle_selection_keys
.in_set(SelectionKeySet) .in_set(SelectionKeySet)
.before(GameMutation) .before(GameMutation),
// Enter-to-move feeds the shared move request clear_selection_on_state_change.after(GameMutation),
// stream (drained wholesale at GameMutation, #143). update_selection_highlight.after(GameMutation).run_if(
.in_set(crate::game_plugin::MoveRequestWriters) resource_changed::<SelectionState>
.ambiguous_with(crate::game_plugin::MoveRequestWriters), .or(resource_changed::<KeyboardDragState>)
( .or(resource_changed::<GameStateResource>),
clear_selection_on_state_change, ),
update_selection_highlight.run_if(
resource_changed::<SelectionState>
.or(resource_changed::<KeyboardDragState>)
.or(resource_changed::<GameStateResource>),
),
)
.chain()
.in_set(SelectionFx),
), ),
)
.configure_sets(
Update,
SelectionFx
.after(GameMutation)
.before(crate::card_plugin::BoardVisuals),
)
.configure_sets(
Update,
// Selection reads GameStateResource (legal destinations) —
// run after the pre-mutation spine that ticks/mutates it
// (#143).
SelectionKeySet.after(crate::game_plugin::PreMutation),
); );
} }
} }
@@ -516,10 +487,8 @@ fn handle_selection_keys(
1 1
}; };
let start = source_cards.len().saturating_sub(count); let start = source_cards.len().saturating_sub(count);
let lifted_cards: Vec<Card> = source_cards[start..] let lifted_cards: Vec<Card> =
.iter() source_cards[start..].iter().map(|(c, _)| c.clone()).collect();
.map(|(c, _)| c.clone())
.collect();
let Some((bottom, _)) = source_cards.get(start) else { let Some((bottom, _)) = source_cards.get(start) else {
return; return;
}; };
@@ -628,7 +597,10 @@ fn face_up_run_len(cards: &[(Card, bool)]) -> usize {
/// This is intentionally separated from [`best_destination`] so the Enter /// This is intentionally separated from [`best_destination`] so the Enter
/// handler can attempt a foundation move first and fall through to a /// handler can attempt a foundation move first and fall through to a
/// multi-card stack move rather than accepting a single-card tableau move. /// multi-card stack move rather than accepting a single-card tableau move.
fn try_foundation_dest(card: &Card, game: &GameState) -> Option<KlondikePile> { fn try_foundation_dest(
card: &Card,
game: &GameState,
) -> Option<KlondikePile> {
let source = game.pile_containing_card(card.clone())?; let source = game.pile_containing_card(card.clone())?;
for foundation in [ for foundation in [
Foundation::Foundation1, Foundation::Foundation1,
@@ -725,7 +697,13 @@ fn update_selection_highlight(
if let Some(ref pile) = source_pile if let Some(ref pile) = source_pile
&& let Some(card) = top_face_up_card(pile, &game.0) && let Some(card) = top_face_up_card(pile, &game.0)
{ {
spawn_highlight_on_card(&mut commands, &card_index, &card, card_size, source_color); spawn_highlight_on_card(
&mut commands,
&card_index,
&card,
card_size,
source_color,
);
} }
// Destination highlight while lifted. // Destination highlight while lifted.
@@ -736,7 +714,13 @@ fn update_selection_highlight(
// in destination-pick mode and the focused index is observable // in destination-pick mode and the focused index is observable
// via the resource. // via the resource.
if let Some(card) = top_face_up_card(dest, &game.0) { if let Some(card) = top_face_up_card(dest, &game.0) {
spawn_highlight_on_card(&mut commands, &card_index, &card, card_size, dest_color); spawn_highlight_on_card(
&mut commands,
&card_index,
&card,
card_size,
dest_color,
);
} }
} }
} }
@@ -1063,7 +1047,10 @@ mod tests {
press_key(&mut app, KeyCode::Tab); press_key(&mut app, KeyCode::Tab);
app.update(); app.update();
let selected = app.world().resource::<SelectionState>().selected_pile; let selected = app
.world()
.resource::<SelectionState>()
.selected_pile;
// The cycle order starts at Waste, but Waste is empty so the next // The cycle order starts at Waste, but Waste is empty so the next
// available pile (Tableau(0)) is selected. // available pile (Tableau(0)) is selected.
assert_eq!(selected, Some(KlondikePile::Tableau(Tableau::Tableau1))); assert_eq!(selected, Some(KlondikePile::Tableau(Tableau::Tableau1)));
@@ -1229,10 +1216,16 @@ mod tests {
drag.active_touch_id = None; drag.active_touch_id = None;
} }
let before = app.world().resource::<SelectionState>().selected_pile; let before = app
.world()
.resource::<SelectionState>()
.selected_pile;
press_key(&mut app, KeyCode::Tab); press_key(&mut app, KeyCode::Tab);
app.update(); app.update();
let after = app.world().resource::<SelectionState>().selected_pile; let after = app
.world()
.resource::<SelectionState>()
.selected_pile;
assert_eq!( assert_eq!(
before, after, before, after,
@@ -1,139 +0,0 @@
//! Phase M (sync transparency): local backup export/import.
//!
//! Runs in the `Last` schedule — the export snapshot is taken after the
//! whole frame settles, and the import's resource rewrites become visible
//! to every Update system on the next frame, keeping this rare bulk
//! operation off the annotated Update spine entirely.
use bevy::prelude::*;
use chrono::Utc;
use solitaire_data::{DataBundle, data_bundle_path, load_bundle_from, save_bundle_to};
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
use crate::events::{
DataExportRequestEvent, DataImportRequestEvent, InfoToastEvent, WarningToastEvent,
};
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
use crate::stats_plugin::{StatsResource, StatsStoragePath};
use super::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
/// Drains the export/import request streams and performs the file IO.
///
/// The bundle is small (a few KB of JSON), so the write happens inline
/// rather than on the async pool — one synchronous write per explicit
/// button press, never per frame.
#[allow(clippy::too_many_arguments)]
pub(super) fn handle_data_transfer(
mut exports: MessageReader<DataExportRequestEvent>,
mut imports: MessageReader<DataImportRequestEvent>,
mut settings: ResMut<SettingsResource>,
settings_path: Res<SettingsStoragePath>,
stats: Option<ResMut<StatsResource>>,
stats_path: Option<Res<StatsStoragePath>>,
achievements: Option<ResMut<AchievementsResource>>,
achievements_path: Option<Res<AchievementsStoragePath>>,
progress: Option<ResMut<ProgressResource>>,
progress_path: Option<Res<ProgressStoragePath>>,
mut settings_changed: MessageWriter<SettingsChangedEvent>,
mut info_toast: MessageWriter<InfoToastEvent>,
mut warning_toast: MessageWriter<WarningToastEvent>,
) {
let export_requested = exports.read().next().is_some();
let import_requested = imports.read().next().is_some();
if !export_requested && !import_requested {
return;
}
// The bundle lives next to settings.json (same app data dir in
// production); deriving it from the injected settings path keeps
// tests off the real platform directory.
let path = settings_path
.0
.as_ref()
.map(|p| p.with_file_name(solitaire_data::transfer::DATA_BUNDLE_FILE_NAME))
.or_else(data_bundle_path);
let Some(path) = path else {
warning_toast.write(WarningToastEvent(
"No data directory available on this platform".to_string(),
));
return;
};
// An import in the same frame as an export would race on the file;
// real UI can't produce both, but be deterministic anyway: export
// first, then import reads what was just written (a no-op restore).
if export_requested {
let bundle = DataBundle {
schema_version: solitaire_data::DATA_BUNDLE_SCHEMA_VERSION,
exported_at: Utc::now(),
settings: settings.0.clone(),
stats: stats.as_ref().map(|s| s.0.clone()).unwrap_or_default(),
achievements: achievements
.as_ref()
.map(|a| a.0.clone())
.unwrap_or_default(),
progress: progress.as_ref().map(|p| p.0.clone()).unwrap_or_default(),
};
match save_bundle_to(&path, &bundle) {
Ok(()) => {
info_toast.write(InfoToastEvent(format!(
"Data exported to {}",
path.display()
)));
}
Err(e) => {
warn!("data export failed: {e}");
warning_toast.write(WarningToastEvent(format!("Export failed: {e}")));
}
}
}
if import_requested {
match load_bundle_from(&path) {
Ok(bundle) => {
settings.0 = bundle.settings;
if let Some(target) = &settings_path.0
&& let Err(e) = solitaire_data::save_settings_to(target, &settings.0)
{
warn!("import: failed to persist settings: {e}");
}
// Settings appliers (theme, audio, layout) react to the
// change event exactly as if edited in the panel.
settings_changed.write(SettingsChangedEvent(settings.0.clone()));
if let Some(mut s) = stats {
s.0 = bundle.stats;
if let Some(target) = stats_path.as_ref().and_then(|p| p.0.as_ref())
&& let Err(e) = solitaire_data::save_stats_to(target, &s.0)
{
warn!("import: failed to persist stats: {e}");
}
}
if let Some(mut a) = achievements {
a.0 = bundle.achievements;
if let Some(target) = achievements_path.as_ref().and_then(|p| p.0.as_ref())
&& let Err(e) = solitaire_data::save_achievements_to(target, &a.0)
{
warn!("import: failed to persist achievements: {e}");
}
}
if let Some(mut p) = progress {
p.0 = bundle.progress;
if let Some(target) = progress_path.as_ref().and_then(|pp| pp.0.as_ref())
&& let Err(e) = solitaire_data::save_progress_to(target, &p.0)
{
warn!("import: failed to persist progress: {e}");
}
}
info_toast.write(InfoToastEvent(format!(
"Backup from {} restored",
bundle.exported_at.format("%Y-%m-%d %H:%M UTC")
)));
}
Err(e) => {
warn!("data import failed: {e}");
warning_toast.write(WarningToastEvent(format!("Import failed: {e}")));
}
}
}
}
+3 -71
View File
@@ -50,21 +50,15 @@ pub(super) fn handle_volume_keys(
} }
/// Opens or closes the Settings panel — `O` keyboard accelerator or /// Opens or closes the Settings panel — `O` keyboard accelerator or
/// `ToggleSettingsRequestEvent` from the HUD Menu popover. Esc closes /// `ToggleSettingsRequestEvent` from the HUD Menu popover.
/// too (Phase C dismissal audit), but only when Settings is the
/// topmost modal — with sync-setup or the theme store stacked on top,
/// the stacked dialog owns Esc.
pub(super) fn toggle_settings_screen( pub(super) fn toggle_settings_screen(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleSettingsRequestEvent>, mut requests: MessageReader<ToggleSettingsRequestEvent>,
mut screen: ResMut<SettingsScreen>, mut screen: ResMut<SettingsScreen>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
) { ) {
let button_clicked = requests.read().count() > 0; let button_clicked = requests.read().count() > 0;
if keys.just_pressed(KeyCode::KeyO) || button_clicked { if keys.just_pressed(KeyCode::KeyO) || button_clicked {
screen.0 = !screen.0; screen.0 = !screen.0;
} else if keys.just_pressed(KeyCode::Escape) && screen.0 && other_modal_scrims.is_empty() {
screen.0 = false;
} }
} }
@@ -353,14 +347,6 @@ pub(super) fn handle_settings_buttons(
**t = on_off_label(settings.0.reduce_motion_mode); **t = on_off_label(settings.0.reduce_motion_mode);
} }
} }
SettingsButton::ToggleReduceTransparency => {
settings.0.reduce_transparency_mode = !settings.0.reduce_transparency_mode;
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
// Text refreshed by `update_reduce_transparency_text`; the
// glass chrome itself follows via `update_glass_surfaces`
// next frame.
}
SettingsButton::ToggleTouchInputMode => { SettingsButton::ToggleTouchInputMode => {
use solitaire_data::settings::TouchInputMode; use solitaire_data::settings::TouchInputMode;
settings.0.touch_input_mode = match settings.0.touch_input_mode { settings.0.touch_input_mode = match settings.0.touch_input_mode {
@@ -371,14 +357,6 @@ pub(super) fn handle_settings_buttons(
changed.write(SettingsChangedEvent(settings.0.clone())); changed.write(SettingsChangedEvent(settings.0.clone()));
// Text refreshed by `update_touch_input_mode_text` next frame. // Text refreshed by `update_touch_input_mode_text` next frame.
} }
SettingsButton::CycleUiScale => {
settings.0.ui_scale = next_ui_scale(settings.0.ui_scale);
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
// Text refreshed by `update_ui_scale_text`; the live
// `bevy::ui::UiScale` resource follows via
// `sync_ui_scale_resource` next frame.
}
SettingsButton::ToggleWinnableDealsOnly => { SettingsButton::ToggleWinnableDealsOnly => {
settings.0.winnable_deals_only = !settings.0.winnable_deals_only; settings.0.winnable_deals_only = !settings.0.winnable_deals_only;
persist(&path, &settings.0); persist(&path, &settings.0);
@@ -429,13 +407,6 @@ pub(super) fn handle_settings_buttons(
| SettingsButton::DeleteAccount => { | SettingsButton::DeleteAccount => {
// Handled by `handle_sync_buttons`. // Handled by `handle_sync_buttons`.
} }
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::OpenThemeStore => {
// Handled by `handle_sync_buttons`.
}
SettingsButton::ExportData | SettingsButton::ImportData => {
// Handled by `handle_sync_buttons`.
}
SettingsButton::Done => { SettingsButton::Done => {
screen.0 = false; screen.0 = false;
} }
@@ -443,41 +414,15 @@ pub(super) fn handle_settings_buttons(
} }
} }
/// Switches the active Settings tab when a chip is pressed. The
/// rebuild itself happens in `rebuild_panel_on_tab_change`, driven by
/// change detection on [`ActiveSettingsTab`].
pub(super) fn handle_tab_buttons(
interactions: Query<(&Interaction, &SettingsTabButton), Changed<Interaction>>,
mut active: ResMut<ActiveSettingsTab>,
) {
for (interaction, chip) in &interactions {
if *interaction != Interaction::Pressed {
continue;
}
// Compare before writing so re-clicking the active chip doesn't
// trip change detection and rebuild the panel for nothing.
if active.0 != chip.0 {
active.0 = chip.0;
}
}
}
/// Handles sync-related settings buttons: Sync Now, Connect, Disconnect, /// Handles sync-related settings buttons: Sync Now, Connect, Disconnect,
/// Delete Account, and the Phase M Export/Import data pair. Split from /// and Delete Account. Split from `handle_settings_buttons` to stay within
/// `handle_settings_buttons` to stay within Bevy's 16-parameter system /// Bevy's 16-parameter system limit.
/// limit.
#[allow(clippy::too_many_arguments)]
pub(super) fn handle_sync_buttons( pub(super) fn handle_sync_buttons(
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>, interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
mut manual_sync: MessageWriter<ManualSyncRequestEvent>, mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>, mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>, mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
mut delete_account: MessageWriter<DeleteAccountRequestEvent>, mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
mut export_data: MessageWriter<crate::events::DataExportRequestEvent>,
mut import_data: MessageWriter<crate::events::DataImportRequestEvent>,
#[cfg(not(target_arch = "wasm32"))] mut open_theme_store: MessageWriter<
crate::events::ThemeStoreOpenRequestEvent,
>,
mut screen: ResMut<SettingsScreen>, mut screen: ResMut<SettingsScreen>,
) { ) {
for (interaction, button) in &interaction_query { for (interaction, button) in &interaction_query {
@@ -494,25 +439,12 @@ pub(super) fn handle_sync_buttons(
screen.0 = false; screen.0 = false;
configure_sync.write(SyncConfigureRequestEvent); configure_sync.write(SyncConfigureRequestEvent);
} }
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::OpenThemeStore => {
// Same shape as ConnectSync: close settings first so the
// store modal's own-scrim guard doesn't block.
screen.0 = false;
open_theme_store.write(crate::events::ThemeStoreOpenRequestEvent);
}
SettingsButton::DisconnectSync => { SettingsButton::DisconnectSync => {
logout_sync.write(SyncLogoutRequestEvent); logout_sync.write(SyncLogoutRequestEvent);
} }
SettingsButton::DeleteAccount => { SettingsButton::DeleteAccount => {
delete_account.write(DeleteAccountRequestEvent); delete_account.write(DeleteAccountRequestEvent);
} }
SettingsButton::ExportData => {
export_data.write(crate::events::DataExportRequestEvent);
}
SettingsButton::ImportData => {
import_data.write(crate::events::DataImportRequestEvent);
}
_ => {} _ => {}
} }
} }
+5 -143
View File
@@ -25,7 +25,6 @@ use crate::events::{
use crate::resources::SettingsScrollPos; use crate::resources::SettingsScrollPos;
use crate::theme::ThemeThumbnailPair; use crate::theme::ThemeThumbnailPair;
mod data_transfer;
mod input; mod input;
mod ui; mod ui;
mod updates; mod updates;
@@ -88,19 +87,6 @@ pub struct SettingsChangedEvent(pub Settings);
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct SettingsMutation; pub struct SettingsMutation;
/// Self-ambiguous club for the screen/overlay systems that touch
/// [`SettingsResource`] (onboarding first-run flag, home difficulty/draw
/// chips, leaderboard opt-in and display name, pause draw toggle, splash
/// reduce-motion reads, …). Each accessor touches its own disjoint fields,
/// and the modal-stacking guard means at most one of these screens is
/// interactive at a time — same-frame access order is provably irrelevant,
/// so members declare `.in_set(SettingsAccess).ambiguous_with(SettingsAccess)`
/// (#143). [`SettingsMutation`] is nested inside the club (its members keep
/// their internal chain). Do NOT add members whose settings read must be
/// same-frame-exact; order those `.after(SettingsMutation)` instead.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct SettingsAccess;
/// Marker on the root Settings panel entity. /// Marker on the root Settings panel entity.
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct SettingsPanel; pub struct SettingsPanel;
@@ -125,12 +111,6 @@ struct ThemeText;
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct SyncStatusText; struct SyncStatusText;
/// Marks the headline `Text` node of the last-merge summary in the
/// Account tab (Phase M — sync transparency). Tests query it to assert
/// the summary rendered.
#[derive(Component, Debug)]
pub(super) struct SyncMergeSummaryText;
/// Marks the `Text` node showing the active card-back index. /// Marks the `Text` node showing the active card-back index.
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct CardBackText; struct CardBackText;
@@ -155,18 +135,10 @@ struct HighContrastText;
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct ReduceMotionText; struct ReduceMotionText;
/// Marks the `Text` node showing the current reduce-transparency state.
#[derive(Component, Debug)]
struct ReduceTransparencyText;
/// Marks the `Text` node showing the current touch input mode state. /// Marks the `Text` node showing the current touch input mode state.
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct TouchInputModeText; struct TouchInputModeText;
/// Marks the `Text` node showing the current UI scale percentage.
#[derive(Component, Debug)]
struct UiScaleText;
/// Marks the `Text` node showing the live tooltip-delay value. /// Marks the `Text` node showing the live tooltip-delay value.
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct TooltipDelayText; struct TooltipDelayText;
@@ -198,51 +170,6 @@ struct SmartDefaultSizeText;
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct AnalyticsEnabledText; struct AnalyticsEnabledText;
/// Which tab of the Settings panel is showing. Tabs replace the old
/// single-scroll five-section layout (Phase A of
/// `docs/ui-redesign-2026-07.md`) so each group fits on screen without
/// scroll-hunting.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(super) enum SettingsTab {
#[default]
Audio,
Gameplay,
Appearance,
Accessibility,
Account,
}
impl SettingsTab {
/// Every tab, in display order.
pub(super) const ALL: [Self; 5] = [
Self::Audio,
Self::Gameplay,
Self::Appearance,
Self::Accessibility,
Self::Account,
];
/// Chip label.
pub(super) fn label(self) -> &'static str {
match self {
Self::Audio => "Audio",
Self::Gameplay => "Gameplay",
Self::Appearance => "Appearance",
Self::Accessibility => "Access",
Self::Account => "Account",
}
}
}
/// The active Settings tab. Session-only — deliberately not persisted;
/// reopening Settings within a session returns to the last tab.
#[derive(Resource, Debug, Default)]
pub(super) struct ActiveSettingsTab(pub(super) SettingsTab);
/// Marker on each tab chip button in the Settings header row.
#[derive(Component, Debug)]
pub(super) struct SettingsTabButton(pub(super) SettingsTab);
/// Marks the scrollable inner card so the mouse-wheel system can target it. /// Marks the scrollable inner card so the mouse-wheel system can target it.
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct SettingsPanelScrollable; struct SettingsPanelScrollable;
@@ -301,18 +228,10 @@ enum SettingsButton {
/// non-essential motion (card-slide animations become instant /// non-essential motion (card-slide animations become instant
/// snaps) per `design-system.md` §Accessibility (#3). /// snaps) per `design-system.md` §Accessibility (#3).
ToggleReduceMotion, ToggleReduceMotion,
/// Toggle the [`Settings::reduce_transparency_mode`] flag — swaps
/// translucent glass chrome (the floating touch tab bar) for opaque
/// fills so table content never bleeds through UI text.
ToggleReduceTransparency,
/// Toggle [`Settings::touch_input_mode`] between `OneTap` /// Toggle [`Settings::touch_input_mode`] between `OneTap`
/// (auto-move on tap, default) and `TapToSelect` (first tap selects /// (auto-move on tap, default) and `TapToSelect` (first tap selects
/// a card/stack, second tap on a target pile moves it). /// a card/stack, second tap on a target pile moves it).
ToggleTouchInputMode, ToggleTouchInputMode,
/// Cycle [`Settings::ui_scale`] through 90 % → 100 % → 115 % →
/// 130 % → 90 %. Applied live via `bevy::ui::UiScale`; the table
/// stays window-fit (Phase K).
CycleUiScale,
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new /// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
/// random Classic-mode deals are filtered through /// random Classic-mode deals are filtered through
/// [`solitaire_core::game_state::GameState::solve_fresh_deal`] until one is provably /// [`solitaire_core::game_state::GameState::solve_fresh_deal`] until one is provably
@@ -331,9 +250,6 @@ enum SettingsButton {
/// Scan `user_theme_dir()` for new `.zip` files and import each one. /// Scan `user_theme_dir()` for new `.zip` files and import each one.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
ScanThemes, ScanThemes,
/// Open the in-game theme-store modal (closes Settings first).
#[cfg(not(target_arch = "wasm32"))]
OpenThemeStore,
SyncNow, SyncNow,
/// Open the sync-server Connect modal (shown when backend = Local). /// Open the sync-server Connect modal (shown when backend = Local).
ConnectSync, ConnectSync,
@@ -341,10 +257,6 @@ enum SettingsButton {
DisconnectSync, DisconnectSync,
/// Open the account-deletion confirmation modal. /// Open the account-deletion confirmation modal.
DeleteAccount, DeleteAccount,
/// Write every JSON save into one backup bundle (Phase M).
ExportData,
/// Restore every JSON save from the backup bundle (Phase M).
ImportData,
Done, Done,
/// Select a specific card-back by index from the picker row. /// Select a specific card-back by index from the picker row.
SelectCardBack(usize), SelectCardBack(usize),
@@ -393,9 +305,7 @@ impl SettingsButton {
// run before continuing to the picker rows. // run before continuing to the picker rows.
SettingsButton::ToggleHighContrast => 61, SettingsButton::ToggleHighContrast => 61,
SettingsButton::ToggleReduceMotion => 62, SettingsButton::ToggleReduceMotion => 62,
SettingsButton::ToggleReduceTransparency => 63, SettingsButton::ToggleTouchInputMode => 63,
SettingsButton::ToggleTouchInputMode => 64,
SettingsButton::CycleUiScale => 65,
// Picker rows — every swatch in a row shares the row's // Picker rows — every swatch in a row shares the row's
// priority so entity-index tiebreaking yields left → right. // priority so entity-index tiebreaking yields left → right.
SettingsButton::SelectCardBack(_) => 70, SettingsButton::SelectCardBack(_) => 70,
@@ -403,15 +313,11 @@ impl SettingsButton {
SettingsButton::SelectTheme(_) => 85, SettingsButton::SelectTheme(_) => 85,
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
SettingsButton::ScanThemes => 86, SettingsButton::ScanThemes => 86,
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::OpenThemeStore => 87,
// Sync section // Sync section
SettingsButton::SyncNow => 90, SettingsButton::SyncNow => 90,
SettingsButton::ConnectSync => 91, SettingsButton::ConnectSync => 91,
SettingsButton::DisconnectSync => 92, SettingsButton::DisconnectSync => 92,
SettingsButton::DeleteAccount => 93, SettingsButton::DeleteAccount => 93,
SettingsButton::ExportData => 94,
SettingsButton::ImportData => 95,
// Done is tagged by `attach_focusable_to_modal_buttons` and // Done is tagged by `attach_focusable_to_modal_buttons` and
// never reaches `attach_focusable_to_settings_buttons`; the // never reaches `attach_focusable_to_settings_buttons`; the
// value here is only a fallback for completeness. // value here is only a fallback for completeness.
@@ -458,24 +364,13 @@ impl Plugin for SettingsPlugin {
.insert_resource(SettingsStoragePath(self.storage_path.clone())) .insert_resource(SettingsStoragePath(self.storage_path.clone()))
.init_resource::<SettingsScreen>() .init_resource::<SettingsScreen>()
.init_resource::<SettingsScrollPos>() .init_resource::<SettingsScrollPos>()
.init_resource::<ActiveSettingsTab>()
.init_resource::<PendingWindowGeometry>() .init_resource::<PendingWindowGeometry>()
.add_message::<SettingsChangedEvent>() .add_message::<SettingsChangedEvent>()
.add_message::<ManualSyncRequestEvent>() .add_message::<ManualSyncRequestEvent>()
.add_message::<SyncConfigureRequestEvent>() .add_message::<SyncConfigureRequestEvent>()
.add_message::<SyncLogoutRequestEvent>() .add_message::<SyncLogoutRequestEvent>()
.add_message::<DeleteAccountRequestEvent>() .add_message::<DeleteAccountRequestEvent>()
.add_message::<crate::events::DataExportRequestEvent>()
.add_message::<crate::events::DataImportRequestEvent>()
// Idempotent — the sync plugin also registers it; needed here
// so the transfer handler's failure toasts work headless.
.add_message::<crate::events::WarningToastEvent>()
// Bulk backup/restore lives in Last: the export snapshot is
// taken post-frame and an import's rewrites surface next
// frame — keeps the rare path off the annotated Update spine.
.add_systems(Last, data_transfer::handle_data_transfer)
.add_message::<ToggleSettingsRequestEvent>() .add_message::<ToggleSettingsRequestEvent>()
.add_message::<crate::events::ThemeStoreOpenRequestEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_message::<MouseWheel>() .add_message::<MouseWheel>()
.add_message::<TouchInput>() .add_message::<TouchInput>()
@@ -494,12 +389,7 @@ impl Plugin for SettingsPlugin {
Update, Update,
SettingsMutation SettingsMutation
.after(crate::layout::LayoutSystem::UpdateOnResize) .after(crate::layout::LayoutSystem::UpdateOnResize)
.before(crate::game_plugin::GameMutation) .before(crate::game_plugin::GameMutation),
// Nested in the club: members keep their chain, but
// their pairs against other screen-plugin settings
// accessors are declared irrelevant (#143).
.in_set(SettingsAccess)
.ambiguous_with(SettingsAccess),
) )
.add_systems( .add_systems(
Update, Update,
@@ -507,11 +397,6 @@ impl Plugin for SettingsPlugin {
handle_volume_keys, handle_volume_keys,
record_window_geometry_changes, record_window_geometry_changes,
persist_window_geometry_after_debounce, persist_window_geometry_after_debounce,
// State sync, not UI — runs even under `headless()`
// so the live `bevy::ui::UiScale` always mirrors the
// setting; rides the mutator spine so it is ordered
// after every settings writer this frame.
sync_ui_scale_resource,
) )
.chain() .chain()
.in_set(SettingsMutation), .in_set(SettingsMutation),
@@ -522,12 +407,8 @@ impl Plugin for SettingsPlugin {
toggle_settings_screen toggle_settings_screen
.before(crate::ui_focus::FocusKeys) .before(crate::ui_focus::FocusKeys)
.ambiguous_with(crate::hud_plugin::HudButtons), .ambiguous_with(crate::hud_plugin::HudButtons),
scroll_settings_panel scroll_settings_panel,
.in_set(crate::ui_modal::PanelScroll) crate::ui_modal::touch_scroll_panel::<SettingsPanelScrollable>,
.ambiguous_with(crate::ui_modal::PanelScroll),
crate::ui_modal::touch_scroll_panel::<SettingsPanelScrollable>
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
) )
.chain(), .chain(),
); );
@@ -536,15 +417,7 @@ impl Plugin for SettingsPlugin {
app.add_systems( app.add_systems(
Update, Update,
( (
// Chained: a tab click must observe this frame's sync_settings_panel_visibility,
// visibility state, and the rebuild must observe the
// click — total order avoids panel double-spawns.
(
sync_settings_panel_visibility,
handle_tab_buttons,
rebuild_panel_on_tab_change,
)
.chain(),
handle_settings_buttons, handle_settings_buttons,
handle_sync_buttons, handle_sync_buttons,
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
@@ -559,7 +432,6 @@ impl Plugin for SettingsPlugin {
update_high_contrast_backgrounds.run_if(resource_changed::<SettingsResource>), update_high_contrast_backgrounds.run_if(resource_changed::<SettingsResource>),
update_reduce_motion_text, update_reduce_motion_text,
update_touch_input_mode_text, update_touch_input_mode_text,
update_ui_scale_text,
update_tooltip_delay_text, update_tooltip_delay_text,
update_time_bonus_multiplier_text, update_time_bonus_multiplier_text,
update_replay_move_interval_text, update_replay_move_interval_text,
@@ -567,16 +439,6 @@ impl Plugin for SettingsPlugin {
update_smart_default_size_text, update_smart_default_size_text,
), ),
); );
app.add_systems(
Update,
(
update_reduce_transparency_text,
// Guards internally on settings-change / newly added
// glass — `Added<GlassSurface>` can't be expressed as
// a resource run condition.
update_glass_surfaces,
),
);
app.add_systems( app.add_systems(
Update, Update,
( (
+70 -300
View File
@@ -98,25 +98,6 @@ fn volume_clamped_at_zero_does_not_emit_event() {
); );
} }
/// Opens the Settings panel showing `tab`, then waits out the
/// spawn / child-flush / marker-attach frames.
fn open_settings_on(app: &mut App, tab: SettingsTab) {
app.world_mut().resource_mut::<ActiveSettingsTab>().0 = tab;
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
app.update();
app.update();
app.update();
}
/// Switches the already-open panel to `tab` (drives the
/// `rebuild_panel_on_tab_change` path) and waits out the rebuild frames.
fn switch_settings_tab(app: &mut App, tab: SettingsTab) {
app.world_mut().resource_mut::<ActiveSettingsTab>().0 = tab;
app.update();
app.update();
app.update();
}
#[test] #[test]
fn pressing_o_toggles_settings_screen_flag() { fn pressing_o_toggles_settings_screen_flag() {
let mut app = headless_app(); let mut app = headless_app();
@@ -140,31 +121,6 @@ fn pressing_o_toggles_settings_screen_flag() {
); );
} }
/// Esc closes the Settings panel like O / Done do (Phase C dismissal
/// audit). Esc while the panel is closed must NOT open it.
#[test]
fn escape_closes_settings_screen_flag() {
let mut app = headless_app();
press(&mut app, KeyCode::Escape);
app.update();
assert!(
!app.world().resource::<SettingsScreen>().0,
"Esc on a closed panel stays closed"
);
press(&mut app, KeyCode::KeyO);
app.update();
assert!(app.world().resource::<SettingsScreen>().0, "O opens");
press(&mut app, KeyCode::Escape);
app.update();
assert!(
!app.world().resource::<SettingsScreen>().0,
"Esc closes settings"
);
}
// cycle_unlocked pure-function tests // cycle_unlocked pure-function tests
#[test] #[test]
fn cycle_unlocked_wraps_at_end() { fn cycle_unlocked_wraps_at_end() {
@@ -287,40 +243,40 @@ fn headless_app_with_focus() -> App {
fn settings_buttons_get_focusable_marker() { fn settings_buttons_get_focusable_marker() {
let mut app = headless_app_with_focus(); let mut app = headless_app_with_focus();
// Walk every tab: each rebuild must leave no bespoke // Open the panel.
// `SettingsButton` (not `Done`, which is also a `ModalButton`) app.world_mut().resource_mut::<SettingsScreen>().0 = true;
// without a `Focusable`. app.update();
let mut total_tagged = 0usize; // Two more ticks: the first runs `sync_settings_panel_visibility`
for (i, tab) in SettingsTab::ALL.into_iter().enumerate() { // and queues the spawn commands; the second flushes them and
if i == 0 { // runs `attach_focusable_to_settings_buttons`.
open_settings_on(&mut app, tab); app.update();
} else { app.update();
switch_settings_tab(&mut app, tab);
}
let untagged: Vec<&SettingsButton> = app // Every bespoke `SettingsButton` (not `Done`, which is also a
.world_mut() // `ModalButton`) must carry a `Focusable`.
.query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>() let untagged: Vec<&SettingsButton> = app
.iter(app.world()) .world_mut()
.collect(); .query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
assert!( .iter(app.world())
untagged.is_empty(), .collect();
"every bespoke Settings button on tab {tab:?} must carry Focusable; missing: {untagged:?}"
);
total_tagged += app
.world_mut()
.query_filtered::<&SettingsButton, With<Focusable>>()
.iter(app.world())
.count();
}
// Floor across all five tabs so the assertion above isn't
// vacuously true (audio 4 + gameplay 8 + appearance ≥5 +
// accessibility 6 + account ≥1).
assert!( assert!(
total_tagged >= 20, untagged.is_empty(),
"expected the five tabs to spawn many bespoke buttons; got {total_tagged}" "every bespoke Settings button must carry Focusable; missing: {:?}",
untagged
);
// And there must be at least one tagged `SettingsButton` so the
// assertion above isn't vacuously true (the panel really did
// spawn).
let tagged_count = app
.world_mut()
.query_filtered::<&SettingsButton, With<Focusable>>()
.iter(app.world())
.count();
assert!(
tagged_count >= 6,
"expected the panel to spawn many bespoke buttons (volume up/down ×2, toggles ×4, sync, swatches…); got {tagged_count}"
); );
} }
@@ -334,45 +290,41 @@ fn settings_buttons_get_focusable_marker() {
fn settings_buttons_carry_tooltip() { fn settings_buttons_carry_tooltip() {
let mut app = headless_app_with_focus(); let mut app = headless_app_with_focus();
// Walk every tab: no bespoke `SettingsButton` (i.e. excluding // Open the panel and let spawn + child-flush run.
// `Done`, which is also a `ModalButton`) may be missing a app.world_mut().resource_mut::<SettingsScreen>().0 = true;
// `Tooltip` on any of them. app.update();
let mut total_tipped = 0usize; app.update();
for (i, tab) in SettingsTab::ALL.into_iter().enumerate() { app.update();
if i == 0 {
open_settings_on(&mut app, tab);
} else {
switch_settings_tab(&mut app, tab);
}
let untipped: Vec<&SettingsButton> = app // No bespoke `SettingsButton` (i.e. excluding `Done`, which is
.world_mut() // also a `ModalButton`) may be missing a `Tooltip`.
.query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>() let untipped: Vec<&SettingsButton> = app
.iter(app.world()) .world_mut()
.collect(); .query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>()
assert!( .iter(app.world())
untipped.is_empty(), .collect();
"every bespoke Settings button on tab {tab:?} must carry Tooltip; missing: {untipped:?}"
);
total_tipped += app
.world_mut()
.query_filtered::<&SettingsButton, With<Tooltip>>()
.iter(app.world())
.count();
}
// Floor across all five tabs so the assertion above isn't
// vacuously true.
assert!( assert!(
total_tipped >= 20, untipped.is_empty(),
"expected the five tabs to spawn many tooltipped buttons; got {total_tipped}" "every bespoke Settings button must carry Tooltip; missing: {:?}",
untipped
); );
// The loop ends on the Account tab, where with default (Local) // And there must be at least 6 tipped buttons so the assertion
// settings the Connect button spawns. // above isn't vacuously true: SFX +/, Music +/, Draw Mode,
// Anim Speed, Theme, Color-blind, Sync Now, plus at least one
// card-back and one background swatch — well over the floor.
let tipped_count = app
.world_mut()
.query_filtered::<&SettingsButton, With<Tooltip>>()
.iter(app.world())
.count();
assert!(
tipped_count >= 6,
"expected the panel to spawn many tooltipped buttons; got {tipped_count}"
);
// We verify its tooltip carries the canonical microcopy. // Spot-check: with default (Local) settings the Connect button
// spawns. We verify its tooltip carries the canonical microcopy.
let connect_tip = app let connect_tip = app
.world_mut() .world_mut()
.query::<(&SettingsButton, &Tooltip)>() .query::<(&SettingsButton, &Tooltip)>()
@@ -390,8 +342,10 @@ fn settings_buttons_carry_tooltip() {
fn settings_picker_rows_get_focus_row_marker() { fn settings_picker_rows_get_focus_row_marker() {
let mut app = headless_app_with_focus(); let mut app = headless_app_with_focus();
// Picker rows live on the Appearance tab. app.world_mut().resource_mut::<SettingsScreen>().0 = true;
open_settings_on(&mut app, SettingsTab::Appearance); app.update();
app.update();
app.update();
// Two picker rows are spawned (card-back + background); each // Two picker rows are spawned (card-back + background); each
// must carry the FocusRow marker. // must carry the FocusRow marker.
@@ -406,45 +360,6 @@ fn settings_picker_rows_get_focus_row_marker() {
); );
} }
/// Switching tabs despawns the old rows and spawns the new tab's —
/// exactly one panel exists afterwards (no double-spawn from the
/// visibility system racing the rebuild system).
#[test]
fn switching_tab_rebuilds_panel_with_new_rows() {
let mut app = headless_app_with_focus();
fn has_button(app: &mut App, wanted: fn(&SettingsButton) -> bool) -> bool {
app.world_mut()
.query::<&SettingsButton>()
.iter(app.world())
.any(wanted)
}
open_settings_on(&mut app, SettingsTab::Audio);
assert!(has_button(&mut app, |b| matches!(b, SettingsButton::SfxUp)));
assert!(
!has_button(&mut app, |b| matches!(b, SettingsButton::ToggleDrawMode)),
"Gameplay rows must not spawn while the Audio tab is active"
);
switch_settings_tab(&mut app, SettingsTab::Gameplay);
assert!(
!has_button(&mut app, |b| matches!(b, SettingsButton::SfxUp)),
"Audio rows must despawn when leaving the Audio tab"
);
assert!(has_button(&mut app, |b| matches!(
b,
SettingsButton::ToggleDrawMode
)));
let panel_count = app
.world_mut()
.query_filtered::<Entity, With<SettingsPanel>>()
.iter(app.world())
.count();
assert_eq!(panel_count, 1, "tab switch must not stack panels");
}
/// Test 3 of the thumbnail-picker spec: when [`ThemeRegistry`] has /// Test 3 of the thumbnail-picker spec: when [`ThemeRegistry`] has
/// at least one theme and the [`ThemeThumbnailCache`] holds a /// at least one theme and the [`ThemeThumbnailCache`] holds a
/// fully-populated [`ThemeThumbnailPair`] for that theme's id, the /// fully-populated [`ThemeThumbnailPair`] for that theme's id, the
@@ -489,9 +404,11 @@ fn theme_picker_chip_includes_thumbnail_sprite_when_thumbnails_loaded() {
); );
app.insert_resource(cache); app.insert_resource(cache);
// Open the panel on the Appearance tab (the theme picker's home) // Open the panel and let the spawn + child-flush systems run.
// and let the spawn + child-flush systems run. app.world_mut().resource_mut::<SettingsScreen>().0 = true;
open_settings_on(&mut app, SettingsTab::Appearance); app.update();
app.update();
app.update();
// Find every ImageNode tagged with ThemeThumbnailMarker — the // Find every ImageNode tagged with ThemeThumbnailMarker — the
// theme picker chip for "test_theme" must contribute exactly // theme picker chip for "test_theme" must contribute exactly
@@ -736,150 +653,3 @@ fn scroll_clamps_offset_to_zero_at_top() {
"scrolling past top must clamp to 0, got {offset}" "scrolling past top must clamp to 0, got {offset}"
); );
} }
// ---------------------------------------------------------------------------
// Phase K: UI scale
// ---------------------------------------------------------------------------
#[test]
fn ui_scale_steps_cycle_and_wrap() {
assert_eq!(next_ui_scale(0.9), 1.0);
assert_eq!(next_ui_scale(1.0), 1.15);
assert_eq!(next_ui_scale(1.15), 1.3);
assert_eq!(next_ui_scale(1.3), 0.9, "the cycle must wrap");
// A hand-edited in-between value advances to the next larger step.
assert_eq!(next_ui_scale(1.05), 1.15);
}
#[test]
fn ui_scale_label_formats_as_percent() {
assert_eq!(ui_scale_label(0.9), "90%");
assert_eq!(ui_scale_label(1.0), "100%");
assert_eq!(ui_scale_label(1.15), "115%");
assert_eq!(ui_scale_label(1.3), "130%");
}
#[test]
fn ui_scale_setting_syncs_the_bevy_resource() {
let mut app = headless_app();
app.insert_resource(UiScale(1.0));
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.ui_scale = 1.3;
app.update();
assert!(
(app.world().resource::<UiScale>().0 - 1.3).abs() < f32::EPSILON,
"Settings::ui_scale must drive bevy::ui::UiScale"
);
}
#[test]
fn ui_scale_out_of_range_sanitizes_on_load() {
use solitaire_data::settings::{UI_SCALE_MAX, UI_SCALE_MIN};
let wild = Settings {
ui_scale: 5.0,
..Settings::default()
}
.sanitized();
assert_eq!(wild.ui_scale, UI_SCALE_MAX);
let tiny = Settings {
ui_scale: 0.1,
..Settings::default()
}
.sanitized();
assert_eq!(tiny.ui_scale, UI_SCALE_MIN);
}
// ---------------------------------------------------------------------------
// Phase M: local data export / import
// ---------------------------------------------------------------------------
/// App with a real (temp-dir) settings path so the data-transfer handler
/// derives its bundle path inside the temp dir instead of the platform
/// data directory.
fn transfer_app(dir: &std::path::Path) -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(SettingsPlugin {
storage_path: Some(dir.join("settings.json")),
ui_enabled: false,
});
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
#[test]
fn export_then_import_round_trips_settings() {
let dir = std::env::temp_dir().join(format!("settings_transfer_{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let mut app = transfer_app(&dir);
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.sfx_volume = 0.25;
app.world_mut()
.write_message(crate::events::DataExportRequestEvent);
app.update();
assert!(
dir.join(solitaire_data::transfer::DATA_BUNDLE_FILE_NAME)
.exists(),
"export should write the bundle next to settings.json"
);
// Clobber the live value, then restore from the bundle.
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.sfx_volume = 1.0;
app.world_mut()
.write_message(crate::events::DataImportRequestEvent);
app.update();
// Import handling runs in Last of the same frame; the rewrite is
// visible immediately after update() returns.
let restored = app.world().resource::<SettingsResource>().0.sfx_volume;
assert!(
(restored - 0.25).abs() < f32::EPSILON,
"import should restore the exported value, got {restored}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_without_backup_warns_instead_of_defaulting() {
let dir =
std::env::temp_dir().join(format!("settings_transfer_missing_{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let mut app = transfer_app(&dir);
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.sfx_volume = 0.25;
app.world_mut()
.write_message(crate::events::DataImportRequestEvent);
app.update();
// Settings untouched (no silent default-restore)…
let v = app.world().resource::<SettingsResource>().0.sfx_volume;
assert!(
(v - 0.25).abs() < f32::EPSILON,
"failed import must not touch settings"
);
// …and the failure is surfaced as a warning toast.
let msgs = app
.world()
.resource::<Messages<crate::events::WarningToastEvent>>();
let mut cursor = msgs.get_cursor();
assert!(
cursor.read(msgs).any(|w| w.0.contains("Import failed")),
"missing backup should emit a warning toast"
);
let _ = std::fs::remove_dir_all(&dir);
}
+192 -459
View File
@@ -18,7 +18,6 @@ use crate::theme::{ImportError, import_theme, refresh_registry};
use crate::ui_focus::FocusRow; use crate::ui_focus::FocusRow;
use crate::ui_modal::{ use crate::ui_modal::{
ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header,
spawn_tab_chip,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM,
@@ -46,35 +45,13 @@ pub(super) fn spawn_settings_panel(
scroll_offset: f32, scroll_offset: f32,
font_res: Option<&FontResource>, font_res: Option<&FontResource>,
theme_overrides_back: bool, theme_overrides_back: bool,
conflict_log: Option<&crate::resources::SyncConflictLog>,
active_tab: SettingsTab,
) { ) {
spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| { spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Settings", font_res); spawn_modal_header(card, "Settings", font_res);
// Tab chips — one group of rows on screen at a time, no // Scrollable bodycontains every section so tall content stays
// scroll-hunting (Phase A, docs/ui-redesign-2026-07.md). // reachable on short windows. The Done button below stays fixed
card.spawn(( // outside the scroll so it's always one click away.
FocusRow,
Node {
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
column_gap: VAL_SPACE_2,
row_gap: VAL_SPACE_2,
margin: UiRect::bottom(VAL_SPACE_3),
..default()
},
))
.with_children(|row| {
for tab in SettingsTab::ALL {
tab_chip(row, tab, tab == active_tab, font_res);
}
});
// Scrollable body — most tabs fit without scrolling, but the
// Appearance pickers can still overflow short windows. The Done
// button below stays fixed outside the scroll so it's always one
// click away.
card.spawn(( card.spawn((
SettingsPanelScrollable, SettingsPanelScrollable,
SettingsScrollNode, SettingsScrollNode,
@@ -88,22 +65,198 @@ pub(super) fn spawn_settings_panel(
..default() ..default()
}, },
)) ))
.with_children(|body| match active_tab { .with_children(|body| {
SettingsTab::Audio => spawn_audio_tab(body, settings, font_res), // --- Audio ---
SettingsTab::Gameplay => spawn_gameplay_tab(body, settings, font_res), section_label(body, "Audio", font_res);
SettingsTab::Appearance => spawn_appearance_tab( volume_row(
body, body,
settings, "SFX Volume",
unlocked_card_backs, settings.sfx_volume,
unlocked_backgrounds, SfxVolumeText,
themes, SettingsButton::SfxDown,
theme_overrides_back, SettingsButton::SfxUp,
"Lower sound effects volume.",
"Raise sound effects volume.",
font_res, font_res,
), );
SettingsTab::Accessibility => spawn_accessibility_tab(body, settings, font_res), volume_row(
SettingsTab::Account => { body,
spawn_account_tab(body, settings, sync_status, conflict_log, font_res) "Music Volume",
settings.music_volume,
MusicVolumeText,
SettingsButton::MusicDown,
SettingsButton::MusicUp,
"Lower music and ambience volume.",
"Raise music and ambience volume.",
font_res,
);
// --- Gameplay ---
section_label(body, "Gameplay", font_res);
toggle_row(
body,
"Draw Mode",
DrawModeText,
draw_mode_label(&settings.draw_mode),
SettingsButton::ToggleDrawMode,
"Switch between Draw 1 and Draw 3. Takes effect next deal.",
font_res,
);
toggle_row(
body,
"Winnable deals only",
WinnableDealsOnlyText,
winnable_deals_only_label(settings.winnable_deals_only),
SettingsButton::ToggleWinnableDealsOnly,
"When on, fresh Classic deals are filtered through a solver \
(may take a moment when on).",
font_res,
);
toggle_row(
body,
"Anim Speed",
AnimSpeedText,
anim_speed_label(&settings.animation_speed),
SettingsButton::CycleAnimSpeed,
"Cycle animation speed: Normal, Fast, Instant.",
font_res,
);
tooltip_delay_row(
body,
settings.tooltip_delay_secs,
font_res,
);
time_bonus_multiplier_row(
body,
settings.time_bonus_multiplier,
font_res,
);
replay_move_interval_row(
body,
settings.replay_move_interval_secs,
font_res,
);
toggle_row(
body,
"Smart window size",
SmartDefaultSizeText,
smart_default_size_label(!settings.disable_smart_default_size),
SettingsButton::ToggleSmartDefaultSize,
"When ON, fresh launches resize the window to ~70 % of the \
monitor. OFF pins the 1280\u{00D7}800 baseline. Saved \
window size always wins.",
font_res,
);
// --- Cosmetic ---
section_label(body, "Cosmetic", font_res);
toggle_row(
body,
"Theme",
ThemeText,
theme_label(&settings.theme),
SettingsButton::ToggleTheme,
"Cycle felt color: Green, Blue, Dark.",
font_res,
);
toggle_row(
body,
"Color-blind Mode",
ColorBlindText,
color_blind_label(settings.color_blind_mode),
SettingsButton::ToggleColorBlind,
"Show shape glyphs alongside suit colors. Suit-blind friendly.",
font_res,
);
toggle_row(
body,
"High Contrast",
HighContrastText,
on_off_label(settings.high_contrast_mode),
SettingsButton::ToggleHighContrast,
"Boosts foreground + suit-red glyphs to higher-luminance variants for low-vision readers and low-quality displays.",
font_res,
);
toggle_row(
body,
"Reduce Motion",
ReduceMotionText,
on_off_label(settings.reduce_motion_mode),
SettingsButton::ToggleReduceMotion,
"Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.",
font_res,
);
toggle_row(
body,
"Touch Input Mode",
TouchInputModeText,
touch_input_mode_label(&settings.touch_input_mode),
SettingsButton::ToggleTouchInputMode,
"One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.",
font_res,
);
if theme_overrides_back {
// The active theme provides its own back; the legacy
// picker has no visible effect, so we replace its
// swatch row with an informational caption. The
// player's `selected_card_back` value still
// round-trips through `settings.json` — the moment
// they switch to a theme without a back, the picker
// re-appears with their previous choice intact.
picker_row_overridden_by_theme(body, "Card Back", font_res);
} else {
picker_row(
body,
"Card Back",
unlocked_card_backs,
settings.selected_card_back,
SettingsButton::SelectCardBack,
"Choose your deck art. New backs unlock at higher levels.",
font_res,
);
} }
picker_row(
body,
"Background",
unlocked_backgrounds,
settings.selected_background,
SettingsButton::SelectBackground,
"Choose your felt art. New felts unlock at higher levels.",
font_res,
);
// Card-art theme picker — only renders when the registry has
// entries (production: always; tests: only when
// ThemeRegistryPlugin is installed).
if !themes.is_empty() {
theme_picker_row(
body,
"Card Theme",
themes,
&settings.selected_theme_id,
"Choose card-face artwork. Imported themes appear here.",
font_res,
);
}
#[cfg(not(target_arch = "wasm32"))]
import_themes_row(body, font_res);
// --- Privacy (only shown when a Matomo URL is configured) ---
if settings.matomo_url.is_some() {
section_label(body, "Privacy", font_res);
toggle_row(
body,
"Share usage data",
AnalyticsEnabledText,
on_off_label(settings.analytics_enabled),
SettingsButton::ToggleAnalytics,
"Sends anonymous game events to Matomo for aggregate analytics.",
font_res,
);
}
// --- Sync ---
section_label(body, "Sync", font_res);
sync_row(body, sync_status, &settings.sync_backend, font_res);
}); });
// Done is the only action — primary so the player always knows // Done is the only action — primary so the player always knows
@@ -121,401 +274,6 @@ pub(super) fn spawn_settings_panel(
}); });
} }
/// One Settings tab chip — thin wrapper over the shared
/// [`spawn_tab_chip`] widget so Settings and the You hub stay visually
/// identical.
fn tab_chip(
parent: &mut ChildSpawnerCommands,
tab: SettingsTab,
active: bool,
font_res: Option<&FontResource>,
) {
spawn_tab_chip(
parent,
SettingsTabButton(tab),
tab.label(),
active,
font_res,
);
}
/// Audio tab: the two volume rows.
fn spawn_audio_tab(
body: &mut ChildSpawnerCommands,
settings: &Settings,
font_res: Option<&FontResource>,
) {
volume_row(
body,
"SFX Volume",
settings.sfx_volume,
SfxVolumeText,
SettingsButton::SfxDown,
SettingsButton::SfxUp,
"Lower sound effects volume.",
"Raise sound effects volume.",
font_res,
);
volume_row(
body,
"Music Volume",
settings.music_volume,
MusicVolumeText,
SettingsButton::MusicDown,
SettingsButton::MusicUp,
"Lower music and ambience volume.",
"Raise music and ambience volume.",
font_res,
);
}
/// Gameplay tab: rules and pacing.
fn spawn_gameplay_tab(
body: &mut ChildSpawnerCommands,
settings: &Settings,
font_res: Option<&FontResource>,
) {
toggle_row(
body,
"Draw Mode",
DrawModeText,
draw_mode_label(&settings.draw_mode),
SettingsButton::ToggleDrawMode,
"Switch between Draw 1 and Draw 3. Takes effect next deal.",
font_res,
);
toggle_row(
body,
"Winnable deals only",
WinnableDealsOnlyText,
winnable_deals_only_label(settings.winnable_deals_only),
SettingsButton::ToggleWinnableDealsOnly,
"When on, fresh Classic deals are filtered through a solver \
(may take a moment when on).",
font_res,
);
toggle_row(
body,
"Anim Speed",
AnimSpeedText,
anim_speed_label(&settings.animation_speed),
SettingsButton::CycleAnimSpeed,
"Cycle animation speed: Normal, Fast, Instant.",
font_res,
);
time_bonus_multiplier_row(body, settings.time_bonus_multiplier, font_res);
replay_move_interval_row(body, settings.replay_move_interval_secs, font_res);
toggle_row(
body,
"Smart window size",
SmartDefaultSizeText,
smart_default_size_label(!settings.disable_smart_default_size),
SettingsButton::ToggleSmartDefaultSize,
"When ON, fresh launches resize the window to ~70 % of the \
monitor. OFF pins the 1280\u{00D7}800 baseline. Saved \
window size always wins.",
font_res,
);
}
/// Appearance tab: felt, deck art, backgrounds, and the theme system.
#[allow(clippy::too_many_arguments)]
fn spawn_appearance_tab(
body: &mut ChildSpawnerCommands,
settings: &Settings,
unlocked_card_backs: &[usize],
unlocked_backgrounds: &[usize],
themes: &[ThemePickerEntry],
theme_overrides_back: bool,
font_res: Option<&FontResource>,
) {
toggle_row(
body,
"Theme",
ThemeText,
theme_label(&settings.theme),
SettingsButton::ToggleTheme,
"Cycle felt color: Green, Blue, Dark.",
font_res,
);
if theme_overrides_back {
// The active theme provides its own back; the legacy
// picker has no visible effect, so we replace its
// swatch row with an informational caption. The
// player's `selected_card_back` value still
// round-trips through `settings.json` — the moment
// they switch to a theme without a back, the picker
// re-appears with their previous choice intact.
picker_row_overridden_by_theme(body, "Card Back", font_res);
} else {
picker_row(
body,
"Card Back",
unlocked_card_backs,
settings.selected_card_back,
SettingsButton::SelectCardBack,
"Choose your deck art. New backs unlock at higher levels.",
font_res,
);
}
picker_row(
body,
"Background",
unlocked_backgrounds,
settings.selected_background,
SettingsButton::SelectBackground,
"Choose your felt art. New felts unlock at higher levels.",
font_res,
);
// Card-art theme picker — only renders when the registry has
// entries (production: always; tests: only when
// ThemeRegistryPlugin is installed).
if !themes.is_empty() {
theme_picker_row(
body,
"Card Theme",
themes,
&settings.selected_theme_id,
"Choose card-face artwork. Imported themes appear here.",
font_res,
);
}
#[cfg(not(target_arch = "wasm32"))]
import_themes_row(body, font_res);
#[cfg(not(target_arch = "wasm32"))]
theme_store_row(body, font_res);
}
/// Accessibility tab: perception and input assists, gathered from the
/// old Gameplay/Cosmetic sections so they're findable in one place.
fn spawn_accessibility_tab(
body: &mut ChildSpawnerCommands,
settings: &Settings,
font_res: Option<&FontResource>,
) {
toggle_row(
body,
"Color-blind Mode",
ColorBlindText,
color_blind_label(settings.color_blind_mode),
SettingsButton::ToggleColorBlind,
"Show shape glyphs alongside suit colors. Suit-blind friendly.",
font_res,
);
toggle_row(
body,
"High Contrast",
HighContrastText,
on_off_label(settings.high_contrast_mode),
SettingsButton::ToggleHighContrast,
"Boosts foreground + suit-red glyphs to higher-luminance variants for low-vision readers and low-quality displays.",
font_res,
);
toggle_row(
body,
"Reduce Motion",
ReduceMotionText,
on_off_label(settings.reduce_motion_mode),
SettingsButton::ToggleReduceMotion,
"Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.",
font_res,
);
toggle_row(
body,
"Reduce Transparency",
ReduceTransparencyText,
on_off_label(settings.reduce_transparency_mode),
SettingsButton::ToggleReduceTransparency,
"Replaces the see-through glass toolbar with a solid panel so cards behind it never reduce readability.",
font_res,
);
toggle_row(
body,
"Touch Input Mode",
TouchInputModeText,
touch_input_mode_label(&settings.touch_input_mode),
SettingsButton::ToggleTouchInputMode,
"One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.",
font_res,
);
toggle_row(
body,
"UI Scale",
UiScaleText,
ui_scale_label(settings.ui_scale),
SettingsButton::CycleUiScale,
"Scales all menus, buttons, and HUD text. The table itself always fits the screen.",
font_res,
);
tooltip_delay_row(body, settings.tooltip_delay_secs, font_res);
}
/// Account tab: privacy opt-in (when analytics is configured) and the
/// sync backend controls.
fn spawn_account_tab(
body: &mut ChildSpawnerCommands,
settings: &Settings,
sync_status: &str,
conflict_log: Option<&crate::resources::SyncConflictLog>,
font_res: Option<&FontResource>,
) {
// --- Privacy (only shown when a Matomo URL is configured) ---
if settings.matomo_url.is_some() {
section_label(body, "Privacy", font_res);
toggle_row(
body,
"Share usage data",
AnalyticsEnabledText,
on_off_label(settings.analytics_enabled),
SettingsButton::ToggleAnalytics,
"Sends anonymous game events to Matomo for aggregate analytics.",
font_res,
);
}
// --- Sync ---
section_label(body, "Sync", font_res);
sync_row(body, sync_status, &settings.sync_backend, font_res);
spawn_sync_merge_summary(body, conflict_log, font_res);
// --- Local data (Phase M) --- backup/restore of the JSON saves;
// filesystem-backed, so not offered on the wasm build.
#[cfg(not(target_arch = "wasm32"))]
{
section_label(body, "Local data", font_res);
local_data_row(body, font_res);
}
}
/// `Export data` / `Import data` pill buttons plus a caption naming the
/// backup file (Phase M). Mirrors the sync row's pill-button styling.
#[cfg(not(target_arch = "wasm32"))]
fn local_data_row(parent: &mut ChildSpawnerCommands, font_res: Option<&FontResource>) {
let button_font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_CAPTION,
..default()
};
let caption_font = button_font.clone();
let small_button = |row: &mut ChildSpawnerCommands,
marker: SettingsButton,
label: &str,
tooltip: String,
font: TextFont| {
row.spawn((
marker,
Button,
Tooltip::new(tooltip),
Node {
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(BG_ELEVATED_HI),
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|b| {
b.spawn((Text::new(label.to_string()), font, TextColor(TEXT_PRIMARY)));
});
};
parent
.spawn(Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_2,
..default()
})
.with_children(|col| {
col.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_3,
flex_wrap: FlexWrap::Wrap,
row_gap: VAL_SPACE_2,
..default()
})
.with_children(|row| {
small_button(
row,
SettingsButton::ExportData,
"Export data",
"Write settings, stats, achievements and progress to one backup file"
.to_string(),
button_font.clone(),
);
small_button(
row,
SettingsButton::ImportData,
"Import data",
"Restore all saves from the backup file (replaces current data)".to_string(),
button_font.clone(),
);
});
col.spawn((
Text::new(format!(
"Backup file: {}",
solitaire_data::data_bundle_path()
.map_or_else(|| "unavailable".to_string(), |p| p.display().to_string())
)),
caption_font,
TextColor(TEXT_SECONDARY),
));
});
}
/// Phase M (sync transparency): renders what the most recent sync merge
/// did — a clean-merge caption, or one line per conflicting field with
/// both values, so the player can see exactly which numbers were
/// reconciled and how.
fn spawn_sync_merge_summary(
body: &mut ChildSpawnerCommands,
conflict_log: Option<&crate::resources::SyncConflictLog>,
font_res: Option<&FontResource>,
) {
let Some(summary) = conflict_log.and_then(|log| log.0.as_ref()) else {
return;
};
let caption_font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_CAPTION,
..default()
};
let when = summary.at.format("%H:%M UTC");
if summary.conflicts.is_empty() {
body.spawn((
SyncMergeSummaryText,
Text::new(format!("Last merge at {when}: no conflicts")),
caption_font,
TextColor(TEXT_SECONDARY),
));
return;
}
let n = summary.conflicts.len();
let plural = if n == 1 { "conflict" } else { "conflicts" };
body.spawn((
SyncMergeSummaryText,
Text::new(format!(
"Last merge at {when}: {n} {plural} — kept newer values"
)),
caption_font.clone(),
TextColor(TEXT_SECONDARY),
));
for c in &summary.conflicts {
body.spawn((
Text::new(format!(
" {} — this device: {} / server: {}",
c.field, c.local_value, c.remote_value
)),
caption_font.clone(),
TextColor(TEXT_SECONDARY),
));
}
}
/// Section divider — small lavender label inside the scrollable body. /// Section divider — small lavender label inside the scrollable body.
pub(super) fn section_label( pub(super) fn section_label(
parent: &mut ChildSpawnerCommands, parent: &mut ChildSpawnerCommands,
@@ -1435,31 +1193,6 @@ pub(super) fn import_themes_row(
}); });
} }
/// "Theme store" row: one pill button that closes Settings and opens
/// the in-game theme-store modal (`theme_store_plugin`). Sits directly
/// under the Import row so both install paths live together.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn theme_store_row(parent: &mut ChildSpawnerCommands, font_res: Option<&FontResource>) {
parent
.spawn((
FocusRow,
Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
..default()
},
))
.with_children(|row| {
pill_button(
row,
SettingsButton::OpenThemeStore,
"Browse theme store",
"Download themes from your sync server without leaving the game.",
font_res,
);
});
}
pub(super) fn icon_button( pub(super) fn icon_button(
parent: &mut ChildSpawnerCommands, parent: &mut ChildSpawnerCommands,
label: &str, label: &str,
+51 -214
View File
@@ -9,17 +9,14 @@ use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource}; use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
use crate::theme::ThemeThumbnailCache; use crate::theme::ThemeThumbnailCache;
use crate::ui_glass::{GlassSurface, glass_decorations};
use crate::ui_modal::ModalScrim; use crate::ui_modal::ModalScrim;
use crate::ui_theme::{BORDER_SUBTLE_HC, HighContrastBackground, HighContrastBorder}; use crate::ui_theme::{BORDER_SUBTLE_HC, HighContrastBackground, HighContrastBorder};
use bevy::ui::{BackgroundGradient, BorderGradient};
/// Spawns the Settings panel when `SettingsScreen` becomes `true`; /// Spawns the Settings panel when `SettingsScreen` becomes `true`;
/// despawns it when it becomes `false`. /// despawns it when it becomes `false`.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub(super) fn sync_settings_panel_visibility( pub(super) fn sync_settings_panel_visibility(
screen: Res<SettingsScreen>, screen: Res<SettingsScreen>,
active_tab: Res<ActiveSettingsTab>,
panels: Query<Entity, With<SettingsPanel>>, panels: Query<Entity, With<SettingsPanel>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>, other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
scroll_nodes: Query<&ScrollPosition, With<SettingsScrollNode>>, scroll_nodes: Query<&ScrollPosition, With<SettingsScrollNode>>,
@@ -32,25 +29,66 @@ pub(super) fn sync_settings_panel_visibility(
theme_registry: Option<Res<crate::theme::ThemeRegistry>>, theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
theme_thumbs: Option<Res<ThemeThumbnailCache>>, theme_thumbs: Option<Res<ThemeThumbnailCache>>,
card_images: Option<Res<crate::card_plugin::CardImageSet>>, card_images: Option<Res<crate::card_plugin::CardImageSet>>,
conflict_log: Option<Res<crate::resources::SyncConflictLog>>,
) { ) {
if !screen.is_changed() { if !screen.is_changed() {
return; return;
} }
if screen.0 { if screen.0 {
if panels.is_empty() && other_modal_scrims.is_empty() { if panels.is_empty() && other_modal_scrims.is_empty() {
build_panel( let status_label = sync_status.map_or_else(
|| "Status: local only".to_string(),
|s| sync_status_label(&s.0),
);
let unlocked_backs = progress
.as_ref()
.map_or(&[0][..], |p| p.0.unlocked_card_backs.as_slice());
let unlocked_bgs = progress
.as_ref()
.map_or(&[0][..], |p| p.0.unlocked_backgrounds.as_slice());
// Snapshot themes by id, display_name and (optional)
// thumbnail pair so spawn_settings_panel doesn't have to
// know about the registry / cache shapes. Empty when
// ThemeRegistryPlugin isn't installed (tests under
// MinimalPlugins) — the picker row simply won't render.
// Missing thumbnails (cache not ready, or partial user
// theme) leave `thumbnails: None` so the chip renders its
// plain-text fallback instead of a broken sprite.
let themes: Vec<ThemePickerEntry> = theme_registry
.as_deref()
.map(|r| {
r.iter()
.map(|e| ThemePickerEntry {
id: e.id.clone(),
display_name: e.display_name.clone(),
thumbnails: theme_thumbs
.as_deref()
.and_then(|c| c.get(&e.id))
.filter(|p| p.is_fully_populated())
.cloned(),
})
.collect()
})
.unwrap_or_default();
// The active card-art theme can supply its own back image —
// see `card_plugin::CardImageSet::theme_back`. When that is
// populated the legacy "Card Back" picker has no visible
// effect, so we render it muted with an explanatory caption
// rather than letting the player click swatches that do
// nothing. Absent under `MinimalPlugins`; treated as
// "no override" in that case.
let theme_overrides_back = card_images
.as_ref()
.is_some_and(|cs| cs.theme_back.is_some());
spawn_settings_panel(
&mut commands, &mut commands,
&settings.0, &settings.0,
sync_status.as_deref(), &status_label,
progress.as_deref(), unlocked_backs,
font_res.as_deref(), unlocked_bgs,
theme_registry.as_deref(), &themes,
theme_thumbs.as_deref(),
card_images.as_deref(),
conflict_log.as_deref(),
scroll_pos.0, scroll_pos.0,
active_tab.0, font_res.as_deref(),
theme_overrides_back,
); );
} }
} else { } else {
@@ -64,120 +102,6 @@ pub(super) fn sync_settings_panel_visibility(
} }
} }
/// Gathers the spawn context (sync label, unlocks, theme snapshot,
/// back-override flag) and spawns the panel showing `active_tab`.
/// Shared by [`sync_settings_panel_visibility`] (open) and
/// [`rebuild_panel_on_tab_change`] (tab switch).
#[allow(clippy::too_many_arguments)]
fn build_panel(
commands: &mut Commands,
settings: &Settings,
sync_status: Option<&SyncStatusResource>,
progress: Option<&ProgressResource>,
font_res: Option<&FontResource>,
theme_registry: Option<&crate::theme::ThemeRegistry>,
theme_thumbs: Option<&ThemeThumbnailCache>,
card_images: Option<&crate::card_plugin::CardImageSet>,
conflict_log: Option<&crate::resources::SyncConflictLog>,
scroll_offset: f32,
active_tab: SettingsTab,
) {
let status_label = sync_status.map_or_else(
|| "Status: local only".to_string(),
|s| sync_status_label(&s.0),
);
let unlocked_backs = progress.map_or(&[0][..], |p| p.0.unlocked_card_backs.as_slice());
let unlocked_bgs = progress.map_or(&[0][..], |p| p.0.unlocked_backgrounds.as_slice());
// Snapshot themes by id, display_name and (optional)
// thumbnail pair so spawn_settings_panel doesn't have to
// know about the registry / cache shapes. Empty when
// ThemeRegistryPlugin isn't installed (tests under
// MinimalPlugins) — the picker row simply won't render.
// Missing thumbnails (cache not ready, or partial user
// theme) leave `thumbnails: None` so the chip renders its
// plain-text fallback instead of a broken sprite.
let themes: Vec<ThemePickerEntry> = theme_registry
.map(|r| {
r.iter()
.map(|e| ThemePickerEntry {
id: e.id.clone(),
display_name: e.display_name.clone(),
thumbnails: theme_thumbs
.and_then(|c| c.get(&e.id))
.filter(|p| p.is_fully_populated())
.cloned(),
})
.collect()
})
.unwrap_or_default();
// The active card-art theme can supply its own back image —
// see `card_plugin::CardImageSet::theme_back`. When that is
// populated the legacy "Card Back" picker has no visible
// effect, so we render it muted with an explanatory caption
// rather than letting the player click swatches that do
// nothing. Absent under `MinimalPlugins`; treated as
// "no override" in that case.
let theme_overrides_back = card_images.is_some_and(|cs| cs.theme_back.is_some());
spawn_settings_panel(
commands,
settings,
&status_label,
unlocked_backs,
unlocked_bgs,
&themes,
scroll_offset,
font_res,
theme_overrides_back,
conflict_log,
active_tab,
);
}
/// Rebuilds the open panel when [`ActiveSettingsTab`] changes (tab chip
/// clicked). No-op while the panel is closed — the next open reads the
/// resource directly.
#[allow(clippy::too_many_arguments)]
pub(super) fn rebuild_panel_on_tab_change(
active_tab: Res<ActiveSettingsTab>,
panels: Query<Entity, With<SettingsPanel>>,
mut scroll_pos: ResMut<SettingsScrollPos>,
mut commands: Commands,
settings: Res<SettingsResource>,
sync_status: Option<Res<SyncStatusResource>>,
progress: Option<Res<ProgressResource>>,
font_res: Option<Res<FontResource>>,
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
theme_thumbs: Option<Res<ThemeThumbnailCache>>,
card_images: Option<Res<crate::card_plugin::CardImageSet>>,
conflict_log: Option<Res<crate::resources::SyncConflictLog>>,
) {
if !active_tab.is_changed() || active_tab.is_added() {
return;
}
if panels.is_empty() {
return;
}
for entity in &panels {
commands.entity(entity).despawn();
}
// A fresh tab starts at the top; per-tab scroll memory isn't worth
// the bookkeeping now that no tab needs much scrolling.
scroll_pos.0 = 0.0;
build_panel(
&mut commands,
&settings.0,
sync_status.as_deref(),
progress.as_deref(),
font_res.as_deref(),
theme_registry.as_deref(),
theme_thumbs.as_deref(),
card_images.as_deref(),
conflict_log.as_deref(),
0.0,
active_tab.0,
);
}
/// Keeps the sync-status text node current while the panel is open. /// Keeps the sync-status text node current while the panel is open.
pub(super) fn update_sync_status_text( pub(super) fn update_sync_status_text(
sync_status: Option<Res<SyncStatusResource>>, sync_status: Option<Res<SyncStatusResource>>,
@@ -338,41 +262,6 @@ pub(super) fn update_reduce_motion_text(
} }
} }
pub(super) fn update_reduce_transparency_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<ReduceTransparencyText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = on_off_label(settings.0.reduce_transparency_mode);
}
}
/// Retargets every [`GlassSurface`]'s sheen + rim gradients when the
/// reduce-transparency or high-contrast toggles change. Runs on
/// `SettingsResource` change and on newly added surfaces (so glass
/// spawned after startup picks up an already-active toggle). Parallel to
/// [`update_high_contrast_borders`]: same trigger, different components.
pub(super) fn update_glass_surfaces(
settings: Res<SettingsResource>,
mut surfaces: Query<(&mut BackgroundGradient, &mut BorderGradient), With<GlassSurface>>,
added: Query<(), Added<GlassSurface>>,
) {
if !settings.is_changed() && added.is_empty() {
return;
}
let (sheen, rim) = glass_decorations(
settings.0.reduce_transparency_mode,
settings.0.high_contrast_mode,
);
for (mut s, mut r) in &mut surfaces {
*s = sheen.clone();
*r = rim.clone();
}
}
pub(super) fn update_touch_input_mode_text( pub(super) fn update_touch_input_mode_text(
settings: Res<SettingsResource>, settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<TouchInputModeText>>, mut text_nodes: Query<&mut Text, With<TouchInputModeText>>,
@@ -558,58 +447,6 @@ pub(super) fn touch_input_mode_label(mode: &solitaire_data::settings::TouchInput
} }
} }
/// The four UI-scale steps the settings row cycles through (Phase K).
pub(super) const UI_SCALE_STEPS: [f32; 4] = [0.9, 1.0, 1.15, 1.3];
/// The next UI-scale step after `current`, wrapping 130 % → 90 %. A
/// hand-edited value between steps advances to the first step larger
/// than it, so the cycle always makes visible progress.
pub(super) fn next_ui_scale(current: f32) -> f32 {
for step in UI_SCALE_STEPS {
if step > current + 0.001 {
return step;
}
}
UI_SCALE_STEPS[0]
}
/// Display string for the UI-scale row, e.g. `"115%"`.
pub(super) fn ui_scale_label(scale: f32) -> String {
format!("{:.0}%", scale * 100.0)
}
/// Refreshes the live UI-scale value text whenever settings change.
pub(super) fn update_ui_scale_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<UiScaleText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = ui_scale_label(settings.0.ui_scale);
}
}
/// Applies `Settings::ui_scale` to the live [`bevy::ui::UiScale`]
/// resource — at startup (first change tick) and whenever the setting
/// changes. Absent under `MinimalPlugins` (no `bevy_ui`), hence the
/// `Option`; the table is unaffected either way (`compute_layout`
/// owns world-space sizing, not UI scale).
pub(super) fn sync_ui_scale_resource(
settings: Res<SettingsResource>,
ui_scale: Option<ResMut<UiScale>>,
) {
let Some(mut ui_scale) = ui_scale else { return };
if !settings.is_changed() {
return;
}
let target = settings.0.ui_scale;
if (ui_scale.0 - target).abs() > f32::EPSILON {
ui_scale.0 = target;
}
}
/// Display string for the "Smart window size" toggle. The argument /// Display string for the "Smart window size" toggle. The argument
/// is the *enabled* state (i.e. the inverse of the underlying /// is the *enabled* state (i.e. the inverse of the underlying
/// `disable_smart_default_size` field) so reading the label gives /// `disable_smart_default_size` field) so reading the label gives
@@ -1,414 +0,0 @@
//! "Show solution" — solve the current deal off-thread and auto-play
//! the winning line through the normal move pipeline.
//!
//! The pause menu's "Show solution" button fires
//! [`crate::events::ShowSolutionRequestEvent`]. This plugin snapshots
//! the live [`GameState`], runs [`GameState::winning_line`] on
//! [`AsyncComputeTaskPool`] (the solver plus `clean_solution` can take
//! seconds — §2.4 never block the main thread), then steps the returned
//! instructions on a cadence, one `MoveRequestEvent` /
//! `DrawRequestEvent` per tick — the same events player input produces,
//! so animations, scoring, undo history, and win detection all behave
//! exactly as if the player made the moves.
//!
//! Playback cancels on Esc, on pause, on undo / new-game requests, and
//! on any rejected move (which is what a player interfering mid-line
//! produces — their move diverges the state, the next scripted step
//! becomes illegal, and the rejection stops the run cleanly).
use std::collections::VecDeque;
use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_core::game_state::GameState;
use solitaire_core::{
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction,
};
use crate::events::{
DrawRequestEvent, InfoToastEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent,
ShowSolutionRequestEvent, UndoRequestEvent,
};
use crate::game_plugin::GameMutation;
use crate::pause_plugin::PausedResource;
use crate::resources::GameStateResource;
/// Seconds between scripted moves — slow enough to follow, fast enough
/// not to drag on a 100-move line.
const STEP_INTERVAL_SECS: f32 = 0.45;
/// Initial delay before the first scripted move, giving the pause modal
/// time to close and the player a beat to see what's happening.
const FIRST_STEP_DELAY_SECS: f32 = 0.9;
/// In-flight solver task plus the `move_count` snapshot used to detect
/// a stale result (player moved while the solver ran). Mirrors
/// `PendingHintTask`.
#[derive(Resource, Default)]
pub struct SolutionSolveTask {
inner: Option<(u32, Task<SolveTaskOutput>)>,
}
/// What the solver task carries back to the main thread.
enum SolveTaskOutput {
Line(Vec<KlondikeInstruction>),
Unwinnable,
Inconclusive,
}
/// Queue of instructions currently being auto-played, or empty when no
/// playback is active. HUD/UI may read `is_active` to badge the state.
#[derive(Resource, Default)]
pub struct SolutionPlayback {
queue: VecDeque<KlondikeInstruction>,
cooldown: f32,
}
impl SolutionPlayback {
/// `true` while a solution line is being auto-played.
pub fn is_active(&self) -> bool {
!self.queue.is_empty()
}
fn stop(&mut self) {
self.queue.clear();
}
}
/// Bevy plugin for the Show-solution flow. See the module docs.
pub struct SolutionPlaybackPlugin;
impl Plugin for SolutionPlaybackPlugin {
fn build(&self, app: &mut App) {
// add_message is idempotent — GamePlugin registers most of these
// too, but this plugin must also boot standalone under
// MinimalPlugins in tests.
app.init_resource::<SolutionSolveTask>()
.init_resource::<SolutionPlayback>()
.add_message::<ShowSolutionRequestEvent>()
.add_message::<InfoToastEvent>()
.add_message::<MoveRequestEvent>()
.add_message::<DrawRequestEvent>()
.add_message::<MoveRejectedEvent>()
.add_message::<UndoRequestEvent>()
.add_message::<NewGameRequestEvent>()
.add_systems(
Update,
(
handle_show_solution_request,
poll_solution_task,
cancel_playback_on_interrupt,
drive_solution_playback,
)
.chain()
.before(GameMutation),
);
}
}
/// Starts a solver task from the live game state. A repeat request
/// while one is already in flight (or playback is running) is ignored
/// — the button is idempotent, not a queue.
fn handle_show_solution_request(
mut requests: MessageReader<ShowSolutionRequestEvent>,
game: Option<Res<GameStateResource>>,
mut task: ResMut<SolutionSolveTask>,
playback: Res<SolutionPlayback>,
mut toast: MessageWriter<InfoToastEvent>,
) {
if requests.is_empty() {
return;
}
requests.clear();
if task.inner.is_some() || playback.is_active() {
return;
}
let Some(game) = game else { return };
if game.0.is_won() {
return;
}
toast.write(InfoToastEvent("Searching for a solution…".to_string()));
let snapshot: GameState = game.0.clone();
let move_count = snapshot.move_count();
let handle = AsyncComputeTaskPool::get().spawn(async move {
match snapshot.winning_line(DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET) {
Ok(Some(line)) => SolveTaskOutput::Line(line),
Ok(None) => SolveTaskOutput::Unwinnable,
Err(_) => SolveTaskOutput::Inconclusive,
}
});
task.inner = Some((move_count, handle));
}
/// Polls the solver; on completion either starts playback or explains
/// why there is nothing to play. A result computed for a position the
/// player has since moved past is discarded silently.
fn poll_solution_task(
mut task: ResMut<SolutionSolveTask>,
game: Option<Res<GameStateResource>>,
mut playback: ResMut<SolutionPlayback>,
mut toast: MessageWriter<InfoToastEvent>,
) {
let Some((move_count_at_spawn, handle)) = task.inner.as_mut() else {
return;
};
let Some(output) = future::block_on(future::poll_once(handle)) else {
return;
};
let move_count_at_spawn = *move_count_at_spawn;
task.inner = None;
let Some(game) = game else { return };
if game.0.move_count() != move_count_at_spawn {
return; // Stale — the board moved while we were solving.
}
match output {
SolveTaskOutput::Line(line) if line.is_empty() => {}
SolveTaskOutput::Line(line) => {
toast.write(InfoToastEvent(format!(
"Solution found — playing {} moves. Press Esc to stop.",
line.len()
)));
playback.queue = line.into();
playback.cooldown = FIRST_STEP_DELAY_SECS;
}
SolveTaskOutput::Unwinnable => {
toast.write(InfoToastEvent(
"No winning line exists from this position.".to_string(),
));
}
SolveTaskOutput::Inconclusive => {
toast.write(InfoToastEvent(
"Couldn't find a solution within the search budget.".to_string(),
));
}
}
}
/// Stops playback on Esc, pause, undo / new-game requests, or a
/// rejected move (the signature of the player diverging the board
/// mid-line). Runs before `drive_solution_playback` so a cancel takes
/// effect without one extra scripted move slipping out.
fn cancel_playback_on_interrupt(
mut playback: ResMut<SolutionPlayback>,
keys: Option<Res<ButtonInput<KeyCode>>>,
paused: Option<Res<PausedResource>>,
mut rejected: MessageReader<MoveRejectedEvent>,
mut undos: MessageReader<UndoRequestEvent>,
mut new_games: MessageReader<NewGameRequestEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
if !playback.is_active() {
rejected.clear();
undos.clear();
new_games.clear();
return;
}
let esc = keys.is_some_and(|k| k.just_pressed(KeyCode::Escape));
let interrupted = esc
|| paused.is_some_and(|p| p.0)
|| rejected.read().next().is_some()
|| undos.read().next().is_some()
|| new_games.read().next().is_some();
if interrupted {
playback.stop();
toast.write(InfoToastEvent("Solution playback stopped.".to_string()));
}
}
/// Emits the next scripted instruction every [`STEP_INTERVAL_SECS`]
/// while playback is active, translated to the same request events
/// player input produces. Instructions that no longer decode against
/// the live state stop the run instead of guessing.
fn drive_solution_playback(
mut playback: ResMut<SolutionPlayback>,
game: Option<Res<GameStateResource>>,
time: Res<Time>,
mut moves: MessageWriter<MoveRequestEvent>,
mut draws: MessageWriter<DrawRequestEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
if !playback.is_active() {
return;
}
let Some(game) = game else {
playback.stop();
return;
};
if game.0.is_won() {
playback.stop();
return;
}
playback.cooldown -= time.delta_secs();
if playback.cooldown > 0.0 {
return;
}
playback.cooldown = STEP_INTERVAL_SECS;
let Some(instruction) = playback.queue.pop_front() else {
return;
};
match instruction {
KlondikeInstruction::RotateStock => {
draws.write(DrawRequestEvent);
}
other => {
// Decode against the LIVE state — tableau run lengths depend on
// the current face-up counts, so this must happen at step time,
// not at solve time.
let Some((from, to, count)) = game.0.instruction_to_piles(other) else {
playback.stop();
toast.write(InfoToastEvent("Solution playback stopped.".to_string()));
return;
};
moves.write(MoveRequestEvent { from, to, count });
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin;
use bevy::ecs::message::Messages;
use solitaire_core::DrawStockConfig;
/// Seed proven Winnable at 5k budgets by the core
/// `budget_is_passed_through_not_clamped` test. Solved here under
/// standard rules (no take-from-foundation) to match that baseline.
const WINNABLE_SEED: u64 = 0xD1FF_0000_0000_0012;
fn winnable_state() -> GameState {
let mut game = GameState::new(WINNABLE_SEED, DrawStockConfig::DrawOne);
game.take_from_foundation = false;
game
}
/// Full pipeline: GamePlugin consumes the Move/Draw requests the
/// playback driver emits, exactly as in production.
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(SolutionPlaybackPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
fn request_solution(app: &mut App) {
app.world_mut()
.resource_mut::<Messages<ShowSolutionRequestEvent>>()
.write(ShowSolutionRequestEvent);
}
/// Pump updates until the solver task resolves (wall-clock bounded,
/// mirroring `winnable_solver_emits_hint_after_async_completes`).
fn pump_until_solved(app: &mut App) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while app.world().resource::<SolutionSolveTask>().inner.is_some() {
app.update();
std::thread::yield_now();
if std::time::Instant::now() >= deadline {
break;
}
}
assert!(
app.world().resource::<SolutionSolveTask>().inner.is_none(),
"solver task should have completed within 30 s wall-clock",
);
}
#[test]
fn request_solves_and_arms_playback() {
let mut app = headless_app();
app.insert_resource(GameStateResource(winnable_state()));
request_solution(&mut app);
app.update();
assert!(
app.world().resource::<SolutionSolveTask>().inner.is_some(),
"request must spawn a solver task",
);
pump_until_solved(&mut app);
assert!(
app.world().resource::<SolutionPlayback>().is_active(),
"a winnable position must arm playback",
);
}
#[test]
fn playback_reaches_win_through_normal_pipeline() {
let mut app = headless_app();
app.insert_resource(GameStateResource(winnable_state()));
request_solution(&mut app);
app.update();
pump_until_solved(&mut app);
assert!(app.world().resource::<SolutionPlayback>().is_active());
// Force each step instead of waiting out the real cadence; a line
// is at most a few hundred instructions.
for _ in 0..600 {
app.world_mut().resource_mut::<SolutionPlayback>().cooldown = 0.0;
app.update();
if app.world().resource::<GameStateResource>().0.is_won() {
break;
}
}
assert!(
app.world().resource::<GameStateResource>().0.is_won(),
"auto-played line must drive the real game to a win",
);
assert!(
!app.world().resource::<SolutionPlayback>().is_active(),
"playback must deactivate once the game is won",
);
}
#[test]
fn escape_cancels_playback() {
let mut app = headless_app();
app.insert_resource(GameStateResource(winnable_state()));
app.world_mut()
.resource_mut::<SolutionPlayback>()
.queue
.push_back(KlondikeInstruction::RotateStock);
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::Escape);
app.update();
assert!(
!app.world().resource::<SolutionPlayback>().is_active(),
"Esc must stop playback",
);
}
#[test]
fn repeat_request_is_ignored_while_active() {
let mut app = headless_app();
app.insert_resource(GameStateResource(winnable_state()));
app.world_mut()
.resource_mut::<SolutionPlayback>()
.queue
.push_back(KlondikeInstruction::RotateStock);
request_solution(&mut app);
app.update();
assert!(
app.world().resource::<SolutionSolveTask>().inner.is_none(),
"a request during active playback must not spawn a solver task",
);
}
}
+1 -12
View File
@@ -99,18 +99,7 @@ impl Plugin for SplashPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_systems(Startup, spawn_splash).add_systems( app.add_systems(Startup, spawn_splash).add_systems(
Update, Update,
// Splash text/fade writers touch UI entities only — they join (dismiss_splash_on_input, advance_splash, pulse_splash_cursor).chain(),
// the UiTextFx club (disjoint from board/anim sets) and, for
// the reduce-motion read in the pulse, the screen-plugin
// settings club. Disjoint from the HUD button painters for the
// same entity-domain reason (#143).
(dismiss_splash_on_input, advance_splash, pulse_splash_cursor)
.chain()
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::hud_plugin::HudButtons)
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
); );
} }
} }
+138 -202
View File
@@ -8,6 +8,7 @@
use std::path::PathBuf; use std::path::PathBuf;
use bevy::input::ButtonInput;
use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_data::{ use solitaire_data::{
@@ -24,13 +25,17 @@ use crate::events::{
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::platform::ClipboardBackendResource; use crate::platform::ClipboardBackendResource;
use crate::progress_plugin::ProgressResource;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::time_attack_plugin::TimeAttackResource; use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_modal::{ButtonVariant, ModalButton, spawn_modal_button}; use crate::ui_modal::{
ButtonVariant, ModalButton, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO, ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO,
STATE_WARNING, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, STATE_WARNING, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
}; };
/// Bevy resource wrapping the current stats. /// Bevy resource wrapping the current stats.
@@ -46,14 +51,6 @@ pub struct StatsStoragePath(pub Option<PathBuf>);
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct StatsUpdate; pub struct StatsUpdate;
/// Set holding the abandon-recorder (`update_stats_on_new_game`). It must
/// read the outgoing game's `move_count` before any mode-start handler or
/// `GameMutation` clobbers the game state — mode plugins order their reset
/// handlers `.after(AbandonRecord)` via `crate::game_plugin::ModeStart`
/// (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct AbandonRecord;
/// Marker component on the stats overlay root node. /// Marker component on the stats overlay root node.
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct StatsScreen; pub struct StatsScreen;
@@ -213,34 +210,10 @@ impl Plugin for StatsPlugin {
// constraints (win_summary_plugin: cache_win_data.before(StatsUpdate)), // constraints (win_summary_plugin: cache_win_data.before(StatsUpdate)),
// and a system cannot be both inside a set and individually before a // and a system cannot be both inside a set and individually before a
// set-level ordering constraint. // set-level ordering constraint.
.add_systems(Update, update_stats_on_new_game.before(GameMutation))
.add_systems( .add_systems(
Update, Update,
update_stats_on_new_game update_stats_on_win.after(GameMutation).in_set(StatsUpdate),
.in_set(AbandonRecord)
.before(GameMutation)
// Reads the shared new-game request stream via its own
// cursor — append order is irrelevant, so it joins the
// self-ambiguous writer club (NewGameRequestWriters must
// not carry set-level edges; see its doc). The explicit
// edges pin it after the pause/settings/selection
// mutators and the pre-mutation game-state spine it
// reads `move_count` from (#143).
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
.after(crate::game_plugin::PreMutation)
.after(crate::pause_plugin::PauseMutation)
.after(crate::settings_plugin::SettingsMutation)
.after(crate::selection_plugin::SelectionKeySet),
)
.add_systems(
Update,
update_stats_on_win
.after(GameMutation)
.in_set(StatsUpdate)
// Also emits toasts (streak milestones) — join the
// self-ambiguous writer club (#143).
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
) )
.add_systems( .add_systems(
Update, Update,
@@ -248,69 +221,28 @@ impl Plugin for StatsPlugin {
// the NewGameRequestEvent it emits is not visible to // the NewGameRequestEvent it emits is not visible to
// update_stats_on_new_game in the same frame — otherwise // update_stats_on_new_game in the same frame — otherwise
// record_abandoned() fires twice on every forfeit (#21). // record_abandoned() fires twice on every forfeit (#21).
// It drains the ForfeitEvent the pause/forfeit modal chain
// emits, and shares the new-game request stream with the
// other writers (#143).
handle_forfeit handle_forfeit
.before(GameMutation) .before(GameMutation)
.before(update_stats_on_new_game) .before(update_stats_on_new_game),
.after(crate::pause_plugin::PauseMutation)
.after(crate::selection_plugin::SelectionKeySet)
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
) )
// Open/close/tab handling moved to `you_hub_plugin` .add_systems(Update, toggle_stats_screen.after(GameMutation))
// (Phase E) — this plugin now owns body content, replay .add_systems(Update, handle_stats_close_button)
// selector behavior, and scroll. .add_systems(Update, refresh_replay_history_on_win.after(GameMutation))
// .add_systems(Update, handle_watch_replay_button)
// Stats overlay UI: one deterministic chain — every member .add_systems(Update, handle_copy_share_link_button)
// touches the replay-selector state or the overlay's nodes, and
// handle_watch_replay_button resets the live game, so the chain
// sits between GameMutation and the auto-complete driver on the
// game-state timeline (#143). The toast-emitting members join
// the self-ambiguous InfoToastWriters set so the toast pipeline
// (AnimFx) drains them this frame.
.add_systems( .add_systems(
Update, Update,
( (
refresh_replay_history_on_win,
handle_replay_selector_buttons, handle_replay_selector_buttons,
// Marker-filtered Text repaints — disjoint from the repaint_replay_selector_caption,
// HUD text painters (UiTextFx club, #143). repaint_replay_selector_detail,
repaint_replay_selector_caption
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
repaint_replay_selector_detail
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
handle_watch_replay_button
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
handle_copy_share_link_button
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
) )
.chain() .chain(),
.after(GameMutation)
// The overlay repaints read this frame's settled stats,
// progress, and achievement evaluation (level line,
// streak).
.after(StatsUpdate)
.after(crate::progress_plugin::ProgressUpdate)
.after(crate::achievement_plugin::AchievementEval)
.before(crate::auto_complete_plugin::AutoComplete),
) )
.add_systems(Update, scroll_stats_panel)
.add_systems( .add_systems(
Update, Update,
scroll_stats_panel crate::ui_modal::touch_scroll_panel::<StatsScrollable>,
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
)
.add_systems(
Update,
crate::ui_modal::touch_scroll_panel::<StatsScrollable>
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
); );
} }
} }
@@ -700,17 +632,70 @@ fn handle_forfeit(
} }
} }
/// Builds the Stats tab body inside the You hub's card: the 8-cell /// Marker on the "Done" button inside the Stats modal. Click despawns
/// grid plus per-mode bests, progression, weekly goals, unlocks, and /// the overlay; `S` keyboard shortcut toggles it the same way.
/// the optional Time Attack line. The replay selector lives on its own #[derive(Component, Debug)]
/// hub tab now — see [`spawn_replays_body`]. All markers pub struct StatsCloseButton;
/// (`StatsCell`, `PerModeBestsRow`, `StatsScrollable`) are unchanged.
pub(crate) fn spawn_stats_body( #[allow(clippy::too_many_arguments)]
card: &mut ChildSpawnerCommands, fn toggle_stats_screen(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleStatsRequestEvent>,
stats: Res<StatsResource>,
progress: Option<Res<ProgressResource>>,
time_attack: Option<Res<TimeAttackResource>>,
font_res: Option<Res<FontResource>>,
latest_replay: Res<ReplayHistoryResource>,
selected_index: Res<SelectedReplayIndex>,
screens: Query<Entity, With<StatsScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<StatsScreen>)>,
) {
let button_clicked = requests.read().count() > 0;
if !keys.just_pressed(KeyCode::KeyS) && !button_clicked {
return;
}
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else {
if !other_modal_scrims.is_empty() {
return;
}
spawn_stats_screen(
&mut commands,
&stats.0,
progress.as_deref().map(|p| &p.0),
time_attack.as_deref(),
font_res.as_deref(),
&latest_replay.0.replays,
selected_index.0,
);
}
}
/// Click handler for the modal's "Done" button — despawns the overlay
/// the same way the `S` accelerator does.
fn handle_stats_close_button(
mut commands: Commands,
close_buttons: Query<&Interaction, (With<StatsCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<StatsScreen>>,
) {
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
fn spawn_stats_screen(
commands: &mut Commands,
stats: &StatsSnapshot, stats: &StatsSnapshot,
progress: Option<&PlayerProgress>, progress: Option<&PlayerProgress>,
time_attack: Option<&TimeAttackResource>, time_attack: Option<&TimeAttackResource>,
font_res: Option<&FontResource>, font_res: Option<&FontResource>,
replays: &[Replay],
selected_index: usize,
) { ) {
// --- primary stat cells --- // --- primary stat cells ---
// First-launch zero-state: when no games have been played yet, render // First-launch zero-state: when no games have been played yet, render
@@ -771,7 +756,9 @@ pub(crate) fn spawn_stats_body(
..default() ..default()
}; };
{ let scrim = spawn_modal(commands, StatsScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Statistics", font_res);
// Scrollable body — the Stats panel renders an 8-cell grid plus // Scrollable body — the Stats panel renders an 8-cell grid plus
// multiple sections (per-mode bests, progression, weekly goals, // multiple sections (per-mode bests, progression, weekly goals,
// unlocks, optional Time Attack, latest replay caption) and // unlocks, optional Time Attack, latest replay caption) and
@@ -791,17 +778,25 @@ pub(crate) fn spawn_stats_body(
}, },
)) ))
.with_children(|body| { .with_children(|body| {
// First-launch empty state (Phase L) — sits above the grid so // First-launch caption — sits above the grid as gentle nudge so
// the wall of em-dashes reads as "nothing to track yet" rather // the wall of em-dashes reads as "nothing to track yet" rather
// than as broken state. // than as broken state.
if is_first_launch { if is_first_launch {
crate::ui_modal::spawn_empty_state( body.spawn((
body, Text::new("Play a game to start tracking stats."),
"\u{2663}", TextFont {
"Play a game to start tracking stats.", font_size: TYPE_CAPTION,
None, ..default()
font_res, },
); TextColor(TEXT_SECONDARY),
Node {
margin: UiRect {
bottom: VAL_SPACE_2,
..default()
},
..default()
},
));
} }
// --- primary stat cells grid --- // --- primary stat cells grid ---
@@ -940,59 +935,7 @@ pub(crate) fn spawn_stats_body(
TextColor(STATE_WARNING), TextColor(STATE_WARNING),
)); ));
} }
});
}
}
/// Builds the Replays tab body inside the You hub's card: the
/// Prev/Next selector, detail line, and the Watch / Copy-share-link
/// actions (buttons live in the body now — the hub owns the single
/// Done in the action row). All markers (`ReplayPrevButton`,
/// `ReplayNextButton`, `ReplaySelectorCaption`, `ReplaySelectorDetail`,
/// `WatchReplayButton`, `CopyShareLinkButton`) are unchanged, so the
/// selector/watch/copy systems keep working.
pub(crate) fn spawn_replays_body(
card: &mut ChildSpawnerCommands,
replays: &[Replay],
selected_index: usize,
font_res: Option<&FontResource>,
) {
// Standard empty state (Phase L) — the selector / Watch / Copy
// controls only spawn when there is something to select, so their
// handlers all no-op via their existing empty-query paths.
if replays.is_empty() {
crate::ui_modal::spawn_empty_state(
card,
"\u{2666}",
"No replays yet.",
Some("Every win records a replay here automatically."),
font_res,
);
return;
}
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_row = TextFont {
font: font_handle,
font_size: TYPE_BODY,
..default()
};
{
// Scrollable body — mirrors the other hub tabs so short
// windows can still reach every control.
card.spawn((
StatsScrollable,
ScrollPosition::default(),
Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_3,
max_height: Val::Vh(70.0),
overflow: Overflow::scroll_y(),
..default()
},
))
.with_children(|body| {
// --- Replay selector --- // --- Replay selector ---
// Prev / Next chips step through the full replay history; // Prev / Next chips step through the full replay history;
// `repaint_replay_selector_caption` and // `repaint_replay_selector_caption` and
@@ -1076,21 +1019,12 @@ pub(crate) fn spawn_replays_body(
)); ));
}); });
// Tab-local actions. The Watch Replay button is always spawn_modal_actions(card, |actions| {
// rendered so the affordance is discoverable from a fresh // The Watch Replay button is always rendered so the
// install — with no replay, its click handler surfaces a // affordance is discoverable from a fresh install. When no
// "No replay recorded yet" toast rather than silently doing // replay exists, the click handler surfaces a clear
// nothing. Same policy for Copy share link (toast explains // "No replay recorded yet" toast rather than silently
// when no shareable upload exists). // doing nothing.
card.spawn(Node {
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
column_gap: VAL_SPACE_2,
row_gap: VAL_SPACE_2,
margin: UiRect::top(VAL_SPACE_2),
..default()
})
.with_children(|actions| {
spawn_modal_button( spawn_modal_button(
actions, actions,
WatchReplayButton, WatchReplayButton,
@@ -1099,6 +1033,11 @@ pub(crate) fn spawn_replays_body(
ButtonVariant::Secondary, ButtonVariant::Secondary,
font_res, font_res,
); );
// Copy share link only renders when a sharable URL is in
// hand. The button is intentionally absent (rather than
// disabled) when no upload has happened yet — keeps the
// action bar free of dead controls in the local-only and
// first-launch cases.
spawn_modal_button( spawn_modal_button(
actions, actions,
CopyShareLinkButton, CopyShareLinkButton,
@@ -1107,8 +1046,18 @@ pub(crate) fn spawn_replays_body(
ButtonVariant::Secondary, ButtonVariant::Secondary,
font_res, font_res,
); );
spawn_modal_button(
actions,
StatsCloseButton,
"Done",
Some("S"),
ButtonVariant::Primary,
font_res,
);
}); });
} });
// Stats is read-only — opt into click-outside-to-dismiss.
commands.entity(scrim).insert(ScrimDismissible);
} }
/// Spawn one row of the "Per-mode bests" section: the mode label on the /// Spawn one row of the "Per-mode bests" section: the mode label on the
@@ -1341,7 +1290,6 @@ mod tests {
// ProgressResource is an optional dependency for the stats screen; // ProgressResource is an optional dependency for the stats screen;
// include it so toggle tests exercise the progression panel. // include it so toggle tests exercise the progression panel.
app.add_plugins(crate::progress_plugin::ProgressPlugin::headless()); app.add_plugins(crate::progress_plugin::ProgressPlugin::headless());
app.add_plugins(crate::you_hub_plugin::YouHubPlugin);
app.update(); app.update();
app app
} }
@@ -1552,8 +1500,10 @@ mod tests {
#[test] #[test]
fn zen_win_event_updates_zen_best_score_only() { fn zen_win_event_updates_zen_best_score_only() {
let mut app = headless_app(); let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0.mode = app.world_mut()
solitaire_core::game_state::GameMode::Zen; .resource_mut::<GameStateResource>()
.0
.mode = solitaire_core::game_state::GameMode::Zen;
app.world_mut().write_message(GameWonEvent { app.world_mut().write_message(GameWonEvent {
score: 1800, score: 1800,
@@ -1884,25 +1834,6 @@ mod tests {
// Prev/Next replay selector spawn-site tests // Prev/Next replay selector spawn-site tests
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/// Opens the You hub via `S`, then switches to the Replays tab
/// (where the selector lives since Phase E) and settles the rebuild.
fn open_replays_tab(app: &mut App) {
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyS);
app.update();
// MinimalPlugins never clears input, so the sticky `just_pressed`
// would keep re-requesting the Stats tab every frame.
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.clear();
app.world_mut()
.resource_mut::<crate::you_hub_plugin::ActiveYouTab>()
.0 = crate::you_hub_plugin::YouTab::Replays;
app.update();
app.update();
}
#[test] #[test]
fn selector_row_spawns_when_stats_screen_opens() { fn selector_row_spawns_when_stats_screen_opens() {
let mut app = headless_app(); let mut app = headless_app();
@@ -1911,7 +1842,10 @@ mod tests {
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>(); let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
hist.0.replays.push(make_test_replay(90, None)); hist.0.replays.push(make_test_replay(90, None));
} }
open_replays_tab(&mut app); app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyS);
app.update();
let prev = app let prev = app
.world_mut() .world_mut()
@@ -1946,7 +1880,10 @@ mod tests {
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>(); let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
hist.0.replays.push(make_test_replay(120, None)); hist.0.replays.push(make_test_replay(120, None));
} }
open_replays_tab(&mut app); app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyS);
app.update();
let mut q = app let mut q = app
.world_mut() .world_mut()
@@ -1966,7 +1903,10 @@ mod tests {
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>(); let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
hist.0.replays.push(make_test_replay(65, None)); // 65s → "1:05" hist.0.replays.push(make_test_replay(65, None)); // 65s → "1:05"
} }
open_replays_tab(&mut app); app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyS);
app.update();
let mut q = app let mut q = app
.world_mut() .world_mut()
@@ -2017,11 +1957,7 @@ mod tests {
time_seconds, time_seconds,
0, 0,
date, date,
solitaire_core::SessionRecording::from_instructions_unchecked( vec![],
1,
solitaire_core::DrawStockConfig::DrawOne,
[],
),
); );
r.share_url = share_url; r.share_url = share_url;
r r

Some files were not shown because too many files have changed in this diff Show More