Compare commits
103 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b405e54bb0 | |||
| 5c97913868 | |||
| c8fc3e7a09 | |||
| 7c2daa1e32 | |||
| 37919cfe17 | |||
| 9081a9916c | |||
| 5430d4c3ab | |||
| fc4ea17789 | |||
| 02bcc8b4af | |||
| d4d0bde0c0 | |||
| cbbab3d09f | |||
| 36605751cd | |||
| 4d9a07727a | |||
| 513dee8fa9 | |||
| 19ddf86c7b | |||
| 28be65f092 | |||
| 65913de2cb | |||
| 25f1fd27d9 | |||
| 9a0d6496c5 | |||
| 7669a1bb56 | |||
| ddee605874 | |||
| ae8118d7df | |||
| 739c94d3bb | |||
| 3388169329 | |||
| 0e07e1d1ad | |||
| 83f37bada6 | |||
| d989d07fe7 | |||
| fa54c58bc4 | |||
| dd304913df | |||
| 48ad6b6618 | |||
| 81ac4a5383 | |||
| f51ee7b234 | |||
| d593b61af8 | |||
| fc87b13e5b | |||
| 560470b86b | |||
| 1fded5ff16 | |||
| 65585c61ad | |||
| 0583a8ffae | |||
| 4d9d710a02 | |||
| 3fbee9ce30 | |||
| fd98f46267 | |||
| 07044f439c | |||
| c60d465711 | |||
| ce2b29f5df | |||
| 4cb4212829 | |||
| fce0266b47 | |||
| 8d80f95bd0 | |||
| 48f0907f78 | |||
| dda6a25439 | |||
| 1aad3251ba | |||
| b26200f948 | |||
| ea8d8eee9a | |||
| adbcb8f59a | |||
| 15c924c3dc | |||
| ede58f9666 | |||
| ccfb9394e0 | |||
| b5c1ba4867 | |||
| d179d9d582 | |||
| db1cc58f3a | |||
| 255b781420 | |||
| 5b5d587818 | |||
| 2b2e7a7f2c | |||
| 5c4d440b31 | |||
| 374858ab6d | |||
| 38b82d4858 | |||
| 4700bd7912 | |||
| d4448bf0cd | |||
| e1d91bee73 | |||
| 9dcd25b3e7 | |||
| 2ef0bce1ea | |||
| 326ef6894a | |||
| caaafe34e0 | |||
| 2254693a7a | |||
| 4ba646738c | |||
| 251d35bc28 | |||
| 1d2b6dc5de | |||
| fe3c3aed31 | |||
| d0e4ce796b | |||
| 9f038250d9 | |||
| 0c69d6859d | |||
| 50d6d41d85 | |||
| 9e4d4a6716 | |||
| ea1014285d | |||
| 4f849a23b8 | |||
| 2c2f0b592a | |||
| 299f6bfea7 | |||
| 8c0eb3dfab | |||
| a39e04329e | |||
| 6f27e775f2 | |||
| 358bbc7eb5 | |||
| 444f8d7e33 | |||
| a80547c514 | |||
| d87397b382 | |||
| 018b69285d | |||
| a4ad848c93 | |||
| ff8c00d2f4 | |||
| abf1312cf5 | |||
| 8b09c51271 | |||
| f61573513c | |||
| 757c35e4a0 | |||
| 113a933170 | |||
| 18bb1fa0be | |||
| ae7af9adf4 |
@@ -10,9 +10,11 @@ on:
|
||||
- 'solitaire_web/**'
|
||||
- 'solitaire_sync/**'
|
||||
- 'solitaire_core/**'
|
||||
- 'solitaire_data/**'
|
||||
- 'solitaire_engine/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'build_wasm.sh'
|
||||
- 'solitaire_server/Dockerfile'
|
||||
- '.gitea/workflows/docker-build.yml'
|
||||
|
||||
@@ -36,12 +38,9 @@ jobs:
|
||||
id: meta
|
||||
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# WASM artifact freshness is owned by the `web-wasm-rebuild` workflow,
|
||||
# which rebuilds pkg/ in CI on every master change to a wasm-feeding crate
|
||||
# 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.
|
||||
# The wasm bundles (solitaire_server/web/pkg/) are not in the repo —
|
||||
# the Dockerfile's wasm-builder stage builds them from source inside
|
||||
# this image build, so the deployed image always ships fresh wasm.
|
||||
|
||||
- name: Log in to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
# Workspace gate: the same clippy + test commands CLAUDE.md §6 requires
|
||||
# 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
|
||||
# master (or the web-wasm-rebuild bot commit) was entirely unguarded.
|
||||
# master 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
|
||||
|
||||
on:
|
||||
@@ -28,8 +41,50 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: rust-host
|
||||
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:
|
||||
- name: Checkout
|
||||
@@ -41,8 +96,19 @@ jobs:
|
||||
toolchain: 1.95.0
|
||||
components: clippy
|
||||
|
||||
- name: Cache cargo build
|
||||
uses: Swatinem/rust-cache@v2
|
||||
# Toolchain or lockfile bumps strand stale artifacts nothing will
|
||||
# ever reuse; reset the dir when it crosses 40 GiB rather than
|
||||
# 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
|
||||
# ubuntu runner: ALSA + udev for input/audio, X11 + Wayland for winit.
|
||||
|
||||
@@ -8,9 +8,13 @@ on:
|
||||
- 'solitaire_server/src/**'
|
||||
- 'solitaire_server/e2e/**'
|
||||
- 'solitaire_wasm/**'
|
||||
- 'solitaire_web/**'
|
||||
- 'solitaire_engine/**'
|
||||
- 'solitaire_data/**'
|
||||
- 'solitaire_core/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'build_wasm.sh'
|
||||
- '.gitea/workflows/web-e2e.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -24,10 +28,31 @@ jobs:
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Cache cargo build
|
||||
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
|
||||
# `cargo run -p solitaire_server`) starts from a compiled binary instead
|
||||
# of cold-compiling the whole dependency graph (axum/sqlx/reqwest) inside
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -41,3 +41,7 @@ deploy/*-auth-secret.yaml
|
||||
# Local token-saving helper scripts (peek/cargoclip/testfail/diffclip/etc.) —
|
||||
# inspection-only Go tools, not committed. Tracked scripts/*.sh and *.md stay.
|
||||
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/
|
||||
|
||||
+9
-7
@@ -145,7 +145,6 @@ Shared API contract types imported by both the game client (`solitaire_data`) an
|
||||
Owns:
|
||||
- `SyncPayload`, `SyncResponse`, `ConflictReport`
|
||||
- `ChallengeGoal`, `LeaderboardEntry`
|
||||
- `ApiError` enum
|
||||
- Merge logic (pure functions, no I/O)
|
||||
|
||||
### `solitaire_data`
|
||||
@@ -193,7 +192,7 @@ Owns:
|
||||
### `solitaire_wasm`
|
||||
**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`; the output lives in `solitaire_server/web/pkg/` and is served statically by the server.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -257,7 +256,7 @@ solitaire_sync::merge(local, remote)
|
||||
│
|
||||
▼
|
||||
Write merged result to disk
|
||||
│ fires SyncCompleteEvent
|
||||
│
|
||||
▼
|
||||
Bevy main thread reads updated StatsResource
|
||||
```
|
||||
@@ -377,7 +376,6 @@ struct StateChangedEvent;
|
||||
struct CardFlippedEvent(u32);
|
||||
struct GameWonEvent { score: i32, time_seconds: u64 }
|
||||
struct AchievementUnlockedEvent(AchievementRecord);
|
||||
struct SyncCompleteEvent(Result<SyncResponse, String>);
|
||||
```
|
||||
|
||||
### Layout System
|
||||
@@ -592,11 +590,15 @@ pub struct Session<G> { /* replayable instruction log + derived stats */ }
|
||||
|
||||
// From `klondike` (upstream — never edit):
|
||||
pub enum KlondikePile {
|
||||
Stock,
|
||||
Waste,
|
||||
Stock, // NB: no Waste variant — see below
|
||||
Foundation(Foundation), // 4 slots, any suit may claim any slot
|
||||
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 KlondikeInstruction { /* RotateStock, DstFoundation, ... — the
|
||||
serialized move format (schema v4+) */ }
|
||||
@@ -741,7 +743,7 @@ All endpoints are under the base URL configured by the user (e.g., `https://soli
|
||||
| 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 | `/web/*` | None | Static assets served via `ServeDir` from `solitaire_server/web/` (includes `web/pkg/` with wasm-bindgen output) |
|
||||
| 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) |
|
||||
|
||||
### Account Management
|
||||
|
||||
|
||||
+133
@@ -6,6 +6,139 @@ project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+10
@@ -7331,7 +7331,9 @@ dependencies = [
|
||||
"card_game",
|
||||
"klondike",
|
||||
"proptest",
|
||||
"rand 0.10.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
@@ -7351,13 +7353,16 @@ dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"solitaire_core",
|
||||
"solitaire_server",
|
||||
"solitaire_sync",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7402,10 +7407,13 @@ dependencies = [
|
||||
"chrono",
|
||||
"dotenvy",
|
||||
"jsonwebtoken",
|
||||
"ron",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"solitaire_sync",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tower",
|
||||
@@ -7414,6 +7422,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7422,6 +7431,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -47,6 +47,7 @@ dirs = "6"
|
||||
keyring = "4"
|
||||
keyring-core = "1"
|
||||
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
||||
sha2 = "0.10"
|
||||
arboard = { version = "3", default-features = false }
|
||||
jni = { version = "0.21", default-features = false }
|
||||
|
||||
|
||||
@@ -44,6 +44,26 @@ 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
|
||||
|
||||
If a player loses access to their account, the server binary includes a
|
||||
|
||||
+5
-4
@@ -13,9 +13,10 @@
|
||||
# Run from the repo root:
|
||||
# ./build_wasm.sh
|
||||
#
|
||||
# The generated pkg/ files are committed to git so self-hosters who don't
|
||||
# touch the WASM crates can skip this step. Regenerate after any change to
|
||||
# solitaire_wasm/, solitaire_web/, solitaire_engine/, or solitaire_core/.
|
||||
# The generated pkg/ files are NOT committed to git (issue #156). CI builds
|
||||
# them where needed: the Docker image's wasm-builder stage for deployment,
|
||||
# and the web-e2e workflow for browser tests. Run this script locally before
|
||||
# serving /web or /play from a source checkout.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -77,7 +78,7 @@ wasm-bindgen \
|
||||
--out-name canvas \
|
||||
--target web \
|
||||
--no-typescript \
|
||||
"$REPO_ROOT/target/wasm32-unknown-unknown/wasm-release/solitaire_web.wasm"
|
||||
"${CARGO_TARGET_DIR:-$REPO_ROOT/target}/wasm32-unknown-unknown/wasm-release/solitaire_web.wasm"
|
||||
|
||||
# Optional size optimisation — Bevy bundles are large (~5-15 MB uncompressed).
|
||||
# wasm-opt passes are skipped silently when the tool is not installed.
|
||||
|
||||
@@ -34,6 +34,11 @@ spec:
|
||||
key: jwt-secret
|
||||
- name: SERVER_PORT
|
||||
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:
|
||||
- name: db-data
|
||||
mountPath: /data
|
||||
|
||||
@@ -6,8 +6,13 @@ services:
|
||||
# Override DATABASE_URL so the DB always lands in the persistent volume,
|
||||
# regardless of what .env contains.
|
||||
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:
|
||||
- ./data:/data
|
||||
- ./theme_store:/theme_store:ro
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "${SERVER_PORT:-8080}"
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
# 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 I–M 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 | 1–2 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 | 1–2 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 | Low–Medium | 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.
|
||||
@@ -2,8 +2,8 @@
|
||||
# Live-watch the Gitea Actions deploy pipeline for Ferrous Solitaire.
|
||||
#
|
||||
# Polls recent workflow runs and prints a compact status block each cycle.
|
||||
# Stops when the newest docker-build (the deploy) has completed and no
|
||||
# web-wasm-rebuild is still pending — i.e. the full fix is live.
|
||||
# Stops when the newest docker-build (the deploy) has completed — the wasm
|
||||
# is built inside that image build, so no other workflow gates the deploy.
|
||||
#
|
||||
# Usage: ./scripts/watch_deploy.sh [interval_seconds]
|
||||
# token is read from ~/.config/tea/config.yml (never printed).
|
||||
@@ -49,16 +49,15 @@ for r in runs:
|
||||
r.get("id"), str(r.get("head_sha"))[:7], wf[:18],
|
||||
r.get("status"), r.get("conclusion")))
|
||||
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
|
||||
live = bool(top and top.get("status")=="completed" and top.get("conclusion")=="success" and not wr_pending)
|
||||
print("STATE=%s" % ("LIVE" if live else ("WAIT_WASM" if wr_pending else "DEPLOYING")))
|
||||
live = bool(top and top.get("status")=="completed" and top.get("conclusion")=="success")
|
||||
print("STATE=%s" % ("LIVE" if live else "DEPLOYING"))
|
||||
PY
|
||||
)"
|
||||
echo "$out" | grep -v '^STATE='
|
||||
if echo "$out" | grep -q '^STATE=LIVE'; then
|
||||
echo ""
|
||||
echo "DEPLOY LIVE — newest docker-build succeeded, no wasm rebuild pending."
|
||||
echo "DEPLOY LIVE — newest docker-build succeeded."
|
||||
echo " Test: https://klondike.aleshym.co/play?v=${RANDOM}"
|
||||
break
|
||||
fi
|
||||
|
||||
@@ -37,11 +37,6 @@ fn load_settings() -> Settings {
|
||||
.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.
|
||||
///
|
||||
/// Called from both the desktop `bin` target's `main` shim and (on
|
||||
|
||||
@@ -9,13 +9,19 @@ default = []
|
||||
test-support = []
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
proptest = "1"
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
klondike = { 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]
|
||||
workspace = true
|
||||
|
||||
@@ -22,11 +22,17 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
/// indices for enum variants. No longer loadable — v3 files are discarded.
|
||||
/// - v4: `saved_moves` uses upstream `KlondikeInstruction` serde with named enum
|
||||
/// variants (e.g. `"Foundation1"` instead of `0`).
|
||||
/// - v5 (current): `score`, `undo_count`, and `recycle_count` are no longer
|
||||
/// - v5: `score`, `undo_count`, and `recycle_count` are no longer
|
||||
/// persisted. They are derived from the upstream `card_game`/`klondike` session
|
||||
/// stats, which are rebuilt by replaying `saved_moves` on load. Older files that
|
||||
/// still carry those keys load fine — the extra fields are ignored.
|
||||
pub const GAME_STATE_SCHEMA_VERSION: u32 = 5;
|
||||
/// - v6 (current): `saved_moves` replaced by `recording`, the upstream
|
||||
/// `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
|
||||
/// loop in the engine.
|
||||
@@ -95,8 +101,8 @@ pub enum GameMode {
|
||||
Difficulty(DifficultyLevel),
|
||||
}
|
||||
|
||||
/// Output struct for schema v4 serialisation. `saved_moves` uses upstream
|
||||
/// `KlondikeInstruction` serde, which produces named enum variants.
|
||||
/// Output struct for schema v6 serialisation. `recording` is the upstream
|
||||
/// session serialisation (config + dealt board + instruction list).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct PersistedGameState {
|
||||
pub draw_mode: DrawStockConfig,
|
||||
@@ -105,15 +111,16 @@ struct PersistedGameState {
|
||||
pub seed: u64,
|
||||
pub take_from_foundation: bool,
|
||||
pub schema_version: u32,
|
||||
pub saved_moves: Vec<KlondikeInstruction>,
|
||||
pub recording: SessionRecording,
|
||||
}
|
||||
|
||||
/// Input struct that accepts schema v4 and v5 `saved_moves` formats.
|
||||
/// Input struct that accepts schema v4, v5, and v6 save formats.
|
||||
///
|
||||
/// `saved_moves` is deserialised directly as upstream `KlondikeInstruction`
|
||||
/// (named-variant serde). `score`, `undo_count`, and `recycle_count` are
|
||||
/// intentionally absent: all three are rebuilt by replaying the instruction
|
||||
/// history through the upstream session stats. Older v4 save files still carry
|
||||
/// v6 files carry `recording` (the upstream session serialisation, deal
|
||||
/// included); v4/v5 files carry `saved_moves` and rebuild the deal from
|
||||
/// `seed`. `score`, `undo_count`, and `recycle_count` are intentionally
|
||||
/// absent: all three are rebuilt by replaying the instruction history
|
||||
/// through the upstream session stats. Older v4 save files still carry
|
||||
/// those keys; serde ignores them.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct PersistedGameStateIn {
|
||||
@@ -126,7 +133,12 @@ struct PersistedGameStateIn {
|
||||
pub take_from_foundation: bool,
|
||||
#[serde(default = "schema_v1")]
|
||||
pub schema_version: u32,
|
||||
/// v4/v5 only. Replayed against a seed-dealt board.
|
||||
#[serde(default)]
|
||||
pub saved_moves: Vec<KlondikeInstruction>,
|
||||
/// v6 only. Carries the dealt board explicitly.
|
||||
#[serde(default)]
|
||||
pub recording: Option<SessionRecording>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
@@ -222,7 +234,7 @@ impl Serialize for GameState {
|
||||
seed: self.seed,
|
||||
take_from_foundation: self.take_from_foundation,
|
||||
schema_version: GAME_STATE_SCHEMA_VERSION,
|
||||
saved_moves: self.saved_moves(),
|
||||
recording: self.recording(),
|
||||
}
|
||||
.serialize(serializer)
|
||||
}
|
||||
@@ -232,34 +244,51 @@ impl<'de> Deserialize<'de> for GameState {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let persisted = PersistedGameStateIn::deserialize(deserializer)?;
|
||||
|
||||
// Accept v4 (upstream named-variant serde) and v5 (current, derived
|
||||
// stats). v3 (legacy u8-index format) and all others are rejected.
|
||||
match persisted.schema_version {
|
||||
4 | 5 => {}
|
||||
// Accept v6 (current, recording-based), plus v4/v5 (legacy seed-dealt
|
||||
// saves — loadable while the seed→deal mapping they were written
|
||||
// under still holds; rewritten as v6 on the next save). v3 (legacy
|
||||
// u8-index format) and all others are rejected.
|
||||
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 => {
|
||||
return Err(serde::de::Error::custom(format!(
|
||||
"unsupported GameState schema version {v}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let mut game = Self {
|
||||
mode: persisted.mode,
|
||||
elapsed_seconds: persisted.elapsed_seconds,
|
||||
seed: persisted.seed,
|
||||
take_from_foundation: persisted.take_from_foundation,
|
||||
session: Self::new_session(persisted.seed, persisted.draw_mode),
|
||||
#[cfg(feature = "test-support")]
|
||||
test_pile_state: None,
|
||||
};
|
||||
game.mode = persisted.mode;
|
||||
game.elapsed_seconds = persisted.elapsed_seconds;
|
||||
game.take_from_foundation = persisted.take_from_foundation;
|
||||
|
||||
// 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.
|
||||
// Replay the saved instruction history with validation — a tampered
|
||||
// or corrupt file must surface an error, not a silently wrong board.
|
||||
// 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);
|
||||
for instruction in persisted.saved_moves {
|
||||
for instruction in instructions {
|
||||
if !game
|
||||
.session
|
||||
.state()
|
||||
@@ -277,6 +306,90 @@ 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 {
|
||||
/// Creates a new Classic-mode game dealt from the given seed and draw mode.
|
||||
pub fn new(seed: u64, draw_mode: DrawStockConfig) -> Self {
|
||||
@@ -296,6 +409,44 @@ 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.
|
||||
/// Derived from the underlying session config (set once at deal time).
|
||||
pub fn draw_mode(&self) -> DrawStockConfig {
|
||||
@@ -402,16 +553,6 @@ impl GameState {
|
||||
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
|
||||
/// upstream [`KlondikeInstruction`] values.
|
||||
///
|
||||
@@ -439,10 +580,7 @@ impl GameState {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -507,8 +645,10 @@ impl GameState {
|
||||
Self::cards_with_face(cards.iter().cloned(), true)
|
||||
}
|
||||
KlondikePile::Tableau(tableau) => {
|
||||
let mut cards =
|
||||
Self::cards_with_face(state.tableau_face_down_cards(tableau).iter().cloned(), false);
|
||||
let mut cards = Self::cards_with_face(
|
||||
state.tableau_face_down_cards(tableau).iter().cloned(),
|
||||
false,
|
||||
);
|
||||
cards.extend(Self::cards_with_face(
|
||||
state.tableau_face_up_cards(tableau).iter().cloned(),
|
||||
true,
|
||||
@@ -823,9 +963,7 @@ impl GameState {
|
||||
) -> Option<(KlondikePile, KlondikePile, usize)> {
|
||||
let state = self.session.state().state().state();
|
||||
match instruction {
|
||||
KlondikeInstruction::RotateStock => {
|
||||
Some((KlondikePile::Stock, KlondikePile::Stock, 1))
|
||||
}
|
||||
KlondikeInstruction::RotateStock => Some((KlondikePile::Stock, KlondikePile::Stock, 1)),
|
||||
KlondikeInstruction::DstFoundation(dst_foundation) => {
|
||||
if matches!(dst_foundation.src, KlondikePile::Foundation(_)) {
|
||||
return None;
|
||||
@@ -928,10 +1066,7 @@ impl GameState {
|
||||
///
|
||||
/// Returns [`MoveError::RuleViolation`] if the instruction is illegal in the
|
||||
/// 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() {
|
||||
return Err(MoveError::GameAlreadyWon);
|
||||
}
|
||||
@@ -1115,6 +1250,66 @@ 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`.
|
||||
///
|
||||
/// Fresh-deal solving models standard Klondike rules, so the non-standard
|
||||
@@ -1132,6 +1327,25 @@ 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1296,12 +1510,13 @@ mod tests {
|
||||
|
||||
game.take_from_foundation = false;
|
||||
assert!(!game.can_move_cards(&from, &to, 1));
|
||||
assert!(
|
||||
legal_pile_moves(&game)
|
||||
.iter()
|
||||
.all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_))
|
||||
|| !matches!(t, KlondikePile::Tableau(_)))
|
||||
);
|
||||
assert!(legal_pile_moves(&game).iter().all(|(f, t, _)| !matches!(
|
||||
f,
|
||||
KlondikePile::Foundation(_)
|
||||
) || !matches!(
|
||||
t,
|
||||
KlondikePile::Tableau(_)
|
||||
)));
|
||||
assert!(game.move_cards(from, to, 1).is_err());
|
||||
}
|
||||
|
||||
@@ -1356,14 +1571,244 @@ mod tests {
|
||||
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]
|
||||
fn budget_is_passed_through_not_clamped() {
|
||||
// This seed is Inconclusive at 1k states but Winnable at 5k — proving the
|
||||
// budget reaches the solver unchanged.
|
||||
let easy = GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 1_000, 1_000);
|
||||
let medium =
|
||||
GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 5_000, 5_000);
|
||||
let easy = GameState::solve_fresh_deal(
|
||||
0xD1FF_0000_0000_0012,
|
||||
DrawStockConfig::DrawOne,
|
||||
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!(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,6 +3,7 @@ pub mod error;
|
||||
pub mod game_state;
|
||||
pub mod klondike_adapter;
|
||||
pub mod scoring;
|
||||
pub mod spider;
|
||||
|
||||
// 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
|
||||
@@ -12,12 +13,23 @@ pub mod scoring;
|
||||
// re-exported — they are only used internally (in `klondike_adapter.rs` and
|
||||
// when decoding instructions to piles in `instruction_to_piles`) and do not
|
||||
// appear in any public method signature.
|
||||
pub use card_game::{Card, Deck, Rank, Session, SolveError, Suit};
|
||||
pub use klondike::{DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau};
|
||||
pub use card_game::{Card, Deck, Rank, SolveError, Suit};
|
||||
pub use klondike::{
|
||||
DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau,
|
||||
};
|
||||
|
||||
// Solvability check API (delegates to `card_game::Session::solve`); replaces the
|
||||
// former `solitaire_data::solver` wrapper module.
|
||||
pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
|
||||
pub use game_state::{
|
||||
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.
|
||||
///
|
||||
|
||||
@@ -41,13 +41,20 @@ fn all_cards(game: &GameState) -> Vec<Card> {
|
||||
);
|
||||
}
|
||||
for t in &tableaux {
|
||||
cards.extend(game.pile(KlondikePile::Tableau(*t)).iter().map(|(c, _)| c.clone()));
|
||||
cards.extend(
|
||||
game.pile(KlondikePile::Tableau(*t))
|
||||
.iter()
|
||||
.map(|(c, _)| c.clone()),
|
||||
);
|
||||
}
|
||||
cards
|
||||
}
|
||||
|
||||
fn draw_mode_strategy() -> impl Strategy<Value = DrawStockConfig> {
|
||||
prop_oneof![Just(DrawStockConfig::DrawOne), Just(DrawStockConfig::DrawThree)]
|
||||
prop_oneof![
|
||||
Just(DrawStockConfig::DrawOne),
|
||||
Just(DrawStockConfig::DrawThree)
|
||||
]
|
||||
}
|
||||
|
||||
/// Apply a sequence of random actions to a game, silently ignoring errors.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,9 @@ uuid = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
reqwest = { 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
|
||||
# `auth_tokens`. The crate's own dependency tree pulls in
|
||||
@@ -47,6 +50,10 @@ sqlx = { workspace = true }
|
||||
jsonwebtoken = { workspace = true }
|
||||
uuid = { 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]
|
||||
workspace = true
|
||||
|
||||
+12
-12
@@ -105,10 +105,9 @@ pub use stats::{StatsExt, StatsSnapshot};
|
||||
pub mod storage;
|
||||
pub use storage::{
|
||||
TimeAttackSession, cleanup_orphaned_tmp_files, delete_game_state_at,
|
||||
delete_time_attack_session_at, game_state_file_path, load_game_state_from, load_stats,
|
||||
load_stats_from, load_time_attack_session_from, load_time_attack_session_from_at,
|
||||
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,
|
||||
delete_time_attack_session_at, game_state_file_path, load_game_state_from, load_stats_from,
|
||||
load_time_attack_session_from, save_game_state_to, save_stats_to, save_time_attack_session_to,
|
||||
stats_file_path, time_attack_session_path,
|
||||
};
|
||||
|
||||
pub mod achievements;
|
||||
@@ -136,11 +135,9 @@ pub use difficulty_seeds::{DifficultySeeds, seeds_for};
|
||||
|
||||
pub mod settings;
|
||||
pub use settings::{
|
||||
AnimSpeed, REPLAY_MOVE_INTERVAL_MAX_SECS, REPLAY_MOVE_INTERVAL_MIN_SECS,
|
||||
REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend,
|
||||
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,
|
||||
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend,
|
||||
TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_STEP_SECS, Theme, WindowGeometry, load_settings_from,
|
||||
save_settings_to, settings_file_path,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
@@ -152,15 +149,18 @@ mod android_keystore;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod auth_tokens;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use auth_tokens::{
|
||||
TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens,
|
||||
};
|
||||
pub use auth_tokens::{TokenError, delete_tokens, store_tokens};
|
||||
|
||||
pub mod sync_client;
|
||||
pub use sync_client::LocalOnlyProvider;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
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 use replay::{
|
||||
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay,
|
||||
|
||||
@@ -12,22 +12,24 @@
|
||||
//! carries any other version so older replays are silently dropped instead
|
||||
//! of crashing the loader.
|
||||
//!
|
||||
//! The recording is intentionally minimal — only the
|
||||
//! [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) inputs that
|
||||
//! successfully advanced the game. `Undo` is **not** recorded: a replay
|
||||
//! represents the canonical path the player ultimately took to win, so
|
||||
//! backed-out missteps simply do not appear in the move list. The starting
|
||||
//! deal is not stored either — the [`seed`](Replay::seed) +
|
||||
//! [`draw_mode`](Replay::draw_mode) + [`mode`](Replay::mode) are sufficient
|
||||
//! for `GameState::new_with_mode` to rebuild the identical layout.
|
||||
//! The payload is a [`SessionRecording`](solitaire_core::SessionRecording):
|
||||
//! the upstream `card_game` session serialisation, which stores the dealt
|
||||
//! board **explicitly** plus the ordered instruction list. `Undo` is not
|
||||
//! recorded: a replay represents the canonical path the player ultimately
|
||||
//! took to win, so backed-out missteps simply do not appear (the session
|
||||
//! pops them from its history).
|
||||
//!
|
||||
//! Storing the deal (rather than re-dealing from [`seed`](Replay::seed) at
|
||||
//! 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.
|
||||
//! `KlondikeInstruction::RotateStock` covers every click on the stock pile;
|
||||
//! the engine resolves draw-vs-recycle deterministically from the current
|
||||
//! stock state during playback, so the same input always produces the same
|
||||
//! effect on the same starting deal. Runtime-only pile-position types are
|
||||
//! never serialised — the instruction itself serialises via its compact
|
||||
//! upstream serde representation.
|
||||
//! effect on the same starting deal.
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
@@ -35,7 +37,7 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
|
||||
use solitaire_core::{DrawStockConfig, SessionRecording, game_state::GameMode};
|
||||
|
||||
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
||||
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
|
||||
@@ -77,13 +79,19 @@ fn history_schema_v0() -> u32 {
|
||||
/// variants which carried the *outcome* of a stock interaction rather
|
||||
/// than the player's atomic input.
|
||||
/// - v2: `Draw` + `Recycle` collapsed into a single `StockClick` variant.
|
||||
/// - v3 (current): the bespoke `ReplayMove` serde mirror was dropped. Moves
|
||||
/// are now stored directly as upstream
|
||||
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) (compact
|
||||
/// int serde); `StockClick` is now `RotateStock`. Pile-position types are
|
||||
/// runtime-only and are never serialised. v1/v2 files fail to deserialise
|
||||
/// and are discarded by the loader.
|
||||
pub const REPLAY_SCHEMA_VERSION: u32 = 3;
|
||||
/// - v3: the bespoke `ReplayMove` serde mirror was dropped. Moves
|
||||
/// were stored directly as upstream `KlondikeInstruction` (compact
|
||||
/// int serde); `StockClick` became `RotateStock`. Pile-position types are
|
||||
/// runtime-only and are never serialised. The starting deal was still
|
||||
/// rebuilt from the seed at playback time.
|
||||
/// - v4 (current): the bare `moves` list was replaced by a
|
||||
/// [`SessionRecording`](solitaire_core::SessionRecording) — the upstream
|
||||
/// `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). v1–v3 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
|
||||
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`]
|
||||
@@ -94,9 +102,10 @@ fn schema_v0() -> u32 {
|
||||
|
||||
/// A complete recording of a single winning game.
|
||||
///
|
||||
/// Replays are reconstructed by rebuilding a fresh
|
||||
/// `GameState::new_with_mode(seed, draw_mode, mode)` and applying the
|
||||
/// [`moves`](Self::moves) in order. The presentation fields
|
||||
/// Replays are reconstructed via
|
||||
/// `GameState::from_recording(&replay.recording, replay.seed, replay.mode)`,
|
||||
/// which rebuilds the recorded deal directly and returns the instruction
|
||||
/// list to step through. The presentation fields
|
||||
/// ([`time_seconds`](Self::time_seconds), [`final_score`](Self::final_score),
|
||||
/// [`recorded_at`](Self::recorded_at)) drive the Stats UI caption such as
|
||||
/// "Replay (2:14 win on 2026-05-02)".
|
||||
@@ -105,8 +114,9 @@ pub struct Replay {
|
||||
/// Schema version. See [`REPLAY_SCHEMA_VERSION`].
|
||||
#[serde(default = "schema_v0")]
|
||||
pub schema_version: u32,
|
||||
/// Seed used for the deal — replay rasterises the deck via
|
||||
/// `GameState::new_with_mode(seed, draw_mode, mode)`.
|
||||
/// Seed the recorded game was originally dealt from. Presentation /
|
||||
/// indexing metadata only — playback rebuilds the board from
|
||||
/// [`recording`](Self::recording), never by re-dealing this seed.
|
||||
pub seed: u64,
|
||||
/// Draw mode the recorded game was played in.
|
||||
pub draw_mode: DrawStockConfig,
|
||||
@@ -119,11 +129,10 @@ pub struct Replay {
|
||||
pub final_score: i32,
|
||||
/// ISO-8601 date the win was recorded.
|
||||
pub recorded_at: NaiveDate,
|
||||
/// Ordered move list. Each entry is the atomic
|
||||
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) the player
|
||||
/// issued, replayable against a fresh `GameState` constructed from the
|
||||
/// seed via `GameState::apply_instruction`.
|
||||
pub moves: Vec<KlondikeInstruction>,
|
||||
/// The dealt board plus the ordered instruction list, serialised via the
|
||||
/// upstream `card_game` session serializers. Self-contained: playback
|
||||
/// needs nothing else to reproduce the game move-for-move.
|
||||
pub recording: SessionRecording,
|
||||
/// Public share URL for this replay on the active sync backend, set
|
||||
/// by `sync_plugin::poll_replay_upload_result` when the upload
|
||||
/// task resolves. `None` when the player won on a local-only
|
||||
@@ -133,11 +142,12 @@ pub struct Replay {
|
||||
/// [`REPLAY_SCHEMA_VERSION`].
|
||||
#[serde(default)]
|
||||
pub share_url: Option<String>,
|
||||
/// Index into [`moves`](Self::moves) of the move that triggered
|
||||
/// the win condition (i.e. completed the last foundation pile).
|
||||
/// Index into the [`recording`](Self::recording)'s instruction list
|
||||
/// of the move that triggered the win condition (i.e. completed the
|
||||
/// last foundation pile).
|
||||
///
|
||||
/// For replays recorded by the live engine this is always
|
||||
/// `Some(moves.len() - 1)` because recording freezes on win — but
|
||||
/// `Some(recording.len() - 1)` because recording freezes on win — but
|
||||
/// the field is stored explicitly so the playback UI can read it
|
||||
/// directly without re-deriving "the last move was the win" each
|
||||
/// time, and to leave room for future recording semantics that
|
||||
@@ -172,7 +182,7 @@ impl Replay {
|
||||
time_seconds: u64,
|
||||
final_score: i32,
|
||||
recorded_at: NaiveDate,
|
||||
moves: Vec<KlondikeInstruction>,
|
||||
recording: SessionRecording,
|
||||
) -> Self {
|
||||
Self {
|
||||
schema_version: REPLAY_SCHEMA_VERSION,
|
||||
@@ -182,7 +192,7 @@ impl Replay {
|
||||
time_seconds,
|
||||
final_score,
|
||||
recorded_at,
|
||||
moves,
|
||||
recording,
|
||||
share_url: None,
|
||||
win_move_index: None,
|
||||
}
|
||||
@@ -193,7 +203,7 @@ impl Replay {
|
||||
/// [`Replay::new`]:
|
||||
///
|
||||
/// ```ignore
|
||||
/// let replay = Replay::new(...).with_win_move_index(Some(recording.moves.len() - 1));
|
||||
/// let replay = Replay::new(...).with_win_move_index(recording.len().checked_sub(1));
|
||||
/// ```
|
||||
///
|
||||
/// `None` is a valid input — useful for tests that don't care about
|
||||
@@ -430,7 +440,8 @@ pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use klondike::{
|
||||
DstFoundation, DstTableau, Foundation, KlondikePile, KlondikePileStack, Tableau,
|
||||
DstFoundation, DstTableau, Foundation, KlondikeInstruction, KlondikePile,
|
||||
KlondikePileStack, Tableau,
|
||||
};
|
||||
use std::env;
|
||||
|
||||
@@ -447,18 +458,22 @@ mod tests {
|
||||
134,
|
||||
5_120,
|
||||
date,
|
||||
vec![
|
||||
KlondikeInstruction::RotateStock,
|
||||
KlondikeInstruction::DstTableau(DstTableau {
|
||||
src: KlondikePileStack::Stock,
|
||||
tableau: Tableau::Tableau4,
|
||||
}),
|
||||
KlondikeInstruction::RotateStock,
|
||||
KlondikeInstruction::DstFoundation(DstFoundation {
|
||||
src: KlondikePile::Tableau(Tableau::Tableau4),
|
||||
foundation: Foundation::Foundation1,
|
||||
}),
|
||||
],
|
||||
SessionRecording::from_instructions_unchecked(
|
||||
12345,
|
||||
DrawStockConfig::DrawThree,
|
||||
[
|
||||
KlondikeInstruction::RotateStock,
|
||||
KlondikeInstruction::DstTableau(DstTableau {
|
||||
src: KlondikePileStack::Stock,
|
||||
tableau: Tableau::Tableau4,
|
||||
}),
|
||||
KlondikeInstruction::RotateStock,
|
||||
KlondikeInstruction::DstFoundation(DstFoundation {
|
||||
src: KlondikePile::Tableau(Tableau::Tableau4),
|
||||
foundation: Foundation::Foundation1,
|
||||
}),
|
||||
],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -518,25 +533,22 @@ mod tests {
|
||||
/// rolling history wiped on the v0.19.0 update.
|
||||
#[test]
|
||||
fn replay_loads_when_share_url_field_is_absent() {
|
||||
let pre_v019_json = format!(
|
||||
r#"{{
|
||||
"schema_version": {schema},
|
||||
"seed": 1,
|
||||
"draw_mode": "DrawOne",
|
||||
"mode": "Classic",
|
||||
"time_seconds": 60,
|
||||
"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");
|
||||
// Build a current-schema JSON object, then strip the optional
|
||||
// fields to simulate a file written before they existed.
|
||||
let mut value = serde_json::to_value(sample_replay()).expect("serialise sample");
|
||||
let obj = value.as_object_mut().expect("replay serialises as object");
|
||||
obj.remove("share_url");
|
||||
obj.remove("win_move_index");
|
||||
let parsed: Replay = serde_json::from_value(value)
|
||||
.expect("replay JSON without optional fields must still deserialise");
|
||||
assert!(
|
||||
parsed.share_url.is_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
|
||||
@@ -588,7 +600,11 @@ mod tests {
|
||||
60,
|
||||
id,
|
||||
date,
|
||||
vec![KlondikeInstruction::RotateStock],
|
||||
SessionRecording::from_instructions_unchecked(
|
||||
id as u64,
|
||||
DrawStockConfig::DrawOne,
|
||||
[KlondikeInstruction::RotateStock],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -824,22 +840,14 @@ mod tests {
|
||||
let path = tmp_path("legacy_no_win_move_index");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
// Hand-rolled minimal current-schema replay JSON with no
|
||||
// win_move_index field — the additive field must still default to None.
|
||||
let no_field = format!(
|
||||
r#"{{
|
||||
"schema_version": {schema},
|
||||
"seed": 1,
|
||||
"draw_mode": "DrawOne",
|
||||
"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");
|
||||
// Current-schema replay JSON with the win_move_index field stripped —
|
||||
// the additive field must still default to None.
|
||||
let mut value = serde_json::to_value(sample_replay()).expect("serialise sample");
|
||||
value
|
||||
.as_object_mut()
|
||||
.expect("replay serialises as object")
|
||||
.remove("win_move_index");
|
||||
fs::write(&path, serde_json::to_string(&value).expect("to_string")).expect("write fixture");
|
||||
|
||||
let loaded = load_latest_replay_from(&path).expect("load");
|
||||
assert_eq!(loaded.win_move_index, None);
|
||||
|
||||
@@ -9,7 +9,10 @@ use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::{DrawStockConfig, game_state::DifficultyLevel};
|
||||
use solitaire_core::{
|
||||
DrawStockConfig,
|
||||
game_state::{DifficultyLevel, GameMode},
|
||||
};
|
||||
|
||||
const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||
|
||||
@@ -161,8 +164,10 @@ pub struct Settings {
|
||||
/// Identifier of the active card-art theme. Matches `meta.id` from
|
||||
/// the theme's `theme.ron` manifest. `"dark"` and `"classic"` are
|
||||
/// always present; user-supplied themes register under their own ids.
|
||||
/// Older `settings.json` files that stored `"default"` or `"classic"`
|
||||
/// are migrated to `"dark"` by [`Settings::sanitized`].
|
||||
/// Older `settings.json` files that stored `"default"` (the
|
||||
/// pre-rename id of the dark theme) are migrated to `"dark"` by
|
||||
/// [`Settings::sanitized`]; `"classic"` is a valid player choice
|
||||
/// and is never rewritten.
|
||||
#[serde(default = "default_theme_id")]
|
||||
pub selected_theme_id: String,
|
||||
/// Set to `true` once the achievement-onboarding info-toast has been
|
||||
@@ -246,6 +251,38 @@ pub struct Settings {
|
||||
/// cleanly to `None` via `#[serde(default)]`.
|
||||
#[serde(default)]
|
||||
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
|
||||
/// player's server `username` is used instead. Trimmed to 32 characters
|
||||
/// before submission. Older `settings.json` files written before this
|
||||
@@ -354,6 +391,16 @@ fn default_replay_move_interval_secs() -> f32 {
|
||||
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 {
|
||||
1
|
||||
}
|
||||
@@ -413,6 +460,11 @@ impl Default for Settings {
|
||||
disable_smart_default_size: false,
|
||||
replay_move_interval_secs: default_replay_move_interval_secs(),
|
||||
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_opted_in: false,
|
||||
take_from_foundation: true,
|
||||
@@ -448,6 +500,7 @@ impl Settings {
|
||||
replay_move_interval_secs: self
|
||||
.replay_move_interval_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,
|
||||
..self
|
||||
}
|
||||
|
||||
@@ -46,22 +46,6 @@ pub fn save_stats_to(path: &Path, stats: &StatsSnapshot) -> io::Result<()> {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -245,18 +229,6 @@ 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`.
|
||||
///
|
||||
/// Per-file errors (already deleted, permission denied) are silently ignored.
|
||||
@@ -505,7 +477,7 @@ mod tests {
|
||||
/// again must reproduce byte-identical JSON. `undo_count` deliberately resets
|
||||
/// to 0 on load because only the forward instruction history is persisted.
|
||||
#[test]
|
||||
fn game_state_v5_mid_game_round_trip() {
|
||||
fn game_state_v6_mid_game_round_trip() {
|
||||
use solitaire_core::KlondikeInstruction;
|
||||
use solitaire_core::game_state::GameState;
|
||||
|
||||
@@ -546,19 +518,24 @@ mod tests {
|
||||
|
||||
save_game_state_to(&path, &gs).expect("save");
|
||||
|
||||
// Verify the file carries the v5 schema marker.
|
||||
// Verify the file carries the v6 schema marker and the recording.
|
||||
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!(
|
||||
json.contains("\"schema_version\"") && json.contains('5'),
|
||||
"saved file must use schema version 5",
|
||||
parsed["recording"].is_object(),
|
||||
"saved file must embed the session recording",
|
||||
);
|
||||
|
||||
let loaded = load_game_state_from(&path)
|
||||
.expect("a valid in-progress game must load without error");
|
||||
let loaded =
|
||||
load_game_state_from(&path).expect("a valid in-progress game must load without error");
|
||||
|
||||
// The forward instruction history round-trips, so the reconstructed board
|
||||
// re-serialises to byte-identical JSON.
|
||||
let path_reload = gs_path("v5_mid_game_reload");
|
||||
let path_reload = gs_path("v6_mid_game_reload");
|
||||
let _ = fs::remove_file(&path_reload);
|
||||
save_game_state_to(&path_reload, &loaded).expect("re-save loaded");
|
||||
assert_eq!(
|
||||
@@ -569,7 +546,11 @@ mod tests {
|
||||
|
||||
// Derived board reads match the live game (move count + recycle count are
|
||||
// both rebuilt from the replayed forward history).
|
||||
assert_eq!(loaded.move_count(), gs.move_count(), "move_count round-trips");
|
||||
assert_eq!(
|
||||
loaded.move_count(),
|
||||
gs.move_count(),
|
||||
"move_count round-trips"
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.recycle_count(),
|
||||
gs.recycle_count(),
|
||||
|
||||
@@ -77,6 +77,11 @@ pub struct SolitaireServerClient {
|
||||
username: String,
|
||||
/// Shared `reqwest` client (keeps connection pools alive across calls).
|
||||
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"))]
|
||||
@@ -90,6 +95,7 @@ impl SolitaireServerClient {
|
||||
base_url: base_url.into().trim_end_matches('/').to_owned(),
|
||||
username: username.into(),
|
||||
client: reqwest::Client::new(),
|
||||
refresh_lock: tokio::sync::Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +178,27 @@ impl SolitaireServerClient {
|
||||
/// The server rotates refresh tokens on each call: the response includes a
|
||||
/// new refresh token that replaces the old one. Both tokens are persisted
|
||||
/// 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 =
|
||||
load_refresh_token(&self.username).map_err(|e| SyncError::Auth(e.to_string()))?;
|
||||
|
||||
@@ -231,7 +257,7 @@ impl SyncProvider for SolitaireServerClient {
|
||||
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
// Token expired — refresh and retry once.
|
||||
self.refresh_token().await?;
|
||||
self.refresh_token(&token).await?;
|
||||
let new_token = self.access_token()?;
|
||||
let resp = self
|
||||
.client
|
||||
@@ -264,7 +290,7 @@ impl SyncProvider for SolitaireServerClient {
|
||||
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
// Token expired — refresh and retry once.
|
||||
self.refresh_token().await?;
|
||||
self.refresh_token(&token).await?;
|
||||
let new_token = self.access_token()?;
|
||||
let resp = self
|
||||
.client
|
||||
@@ -331,7 +357,7 @@ impl SyncProvider for SolitaireServerClient {
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
self.refresh_token().await?;
|
||||
self.refresh_token(&token).await?;
|
||||
let new_token = self.access_token()?;
|
||||
let resp = self
|
||||
.client
|
||||
@@ -366,7 +392,7 @@ impl SyncProvider for SolitaireServerClient {
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
self.refresh_token().await?;
|
||||
self.refresh_token(&token).await?;
|
||||
let new_token = self.access_token()?;
|
||||
let resp = self
|
||||
.client
|
||||
@@ -406,7 +432,7 @@ impl SyncProvider for SolitaireServerClient {
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
self.refresh_token().await?;
|
||||
self.refresh_token(&token).await?;
|
||||
let new_token = self.access_token()?;
|
||||
let resp = self
|
||||
.client
|
||||
@@ -446,7 +472,7 @@ impl SyncProvider for SolitaireServerClient {
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
self.refresh_token().await?;
|
||||
self.refresh_token(&token).await?;
|
||||
let new_token = self.access_token()?;
|
||||
let resp = self
|
||||
.client
|
||||
@@ -480,7 +506,7 @@ impl SyncProvider for SolitaireServerClient {
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
self.refresh_token().await?;
|
||||
self.refresh_token(&token).await?;
|
||||
let new_token = self.access_token()?;
|
||||
let resp = self
|
||||
.client
|
||||
@@ -542,7 +568,7 @@ impl SolitaireServerClient {
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
self.refresh_token().await?;
|
||||
self.refresh_token(&token).await?;
|
||||
let new_token = self.access_token()?;
|
||||
let resp = self
|
||||
.client
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
//! 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 { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! 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());
|
||||
}
|
||||
@@ -30,13 +30,9 @@ use crate::replay_playback::ReplayPlaybackState;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||
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::{
|
||||
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
|
||||
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, Z_MODAL_PANEL,
|
||||
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1,
|
||||
};
|
||||
use crate::ui_tooltip::Tooltip;
|
||||
|
||||
@@ -137,8 +133,8 @@ impl Plugin for AchievementPlugin {
|
||||
.after(GameMutation)
|
||||
.after(StatsUpdate),
|
||||
)
|
||||
.add_systems(Update, toggle_achievements_screen)
|
||||
.add_systems(Update, handle_achievements_close_button)
|
||||
// Open/close/tab handling moved to `you_hub_plugin`
|
||||
// (Phase E) — this plugin now owns body content + scroll.
|
||||
.add_systems(Update, scroll_achievements_panel)
|
||||
.add_systems(
|
||||
Update,
|
||||
@@ -385,47 +381,6 @@ pub fn display_name_for(id: &str) -> 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
|
||||
/// while the panel is open.
|
||||
///
|
||||
@@ -458,14 +413,18 @@ fn scroll_achievements_panel(
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_achievements_screen(
|
||||
commands: &mut Commands,
|
||||
/// Builds the Achievements tab body inside the You hub's card. The
|
||||
/// unlock-count line that used to live in the standalone modal's
|
||||
/// 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],
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let unlocked: Vec<_> = records.iter().filter(|r| r.unlocked).collect();
|
||||
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_name = TextFont {
|
||||
@@ -486,8 +445,13 @@ fn spawn_achievements_screen(
|
||||
|
||||
let any_unlocked = records.iter().any(|r| r.unlocked);
|
||||
|
||||
let scrim = spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, header, font_res);
|
||||
{
|
||||
// Unlock progress — formerly the standalone modal's header.
|
||||
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.
|
||||
// The list itself describes individual rewards, but a top-level
|
||||
@@ -594,21 +558,7 @@ fn spawn_achievements_screen(
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
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 {
|
||||
@@ -668,7 +618,8 @@ mod tests {
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(StatsPlugin::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
|
||||
// MinimalPlugins it isn't auto-registered.
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
@@ -911,8 +862,7 @@ mod tests {
|
||||
|
||||
// Put the active game in Zen mode. evaluate_on_win reads
|
||||
// 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 {
|
||||
score: 0,
|
||||
@@ -1393,7 +1343,9 @@ mod tests {
|
||||
|
||||
use crate::replay_playback::ReplayPlaybackState;
|
||||
use chrono::NaiveDate;
|
||||
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
|
||||
use solitaire_core::{
|
||||
DrawStockConfig, KlondikeInstruction, SessionRecording, game_state::GameMode,
|
||||
};
|
||||
use solitaire_data::Replay;
|
||||
|
||||
/// Headless app variant that injects a default `ReplayPlaybackState`
|
||||
@@ -1414,7 +1366,11 @@ mod tests {
|
||||
10,
|
||||
100,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![KlondikeInstruction::RotateStock],
|
||||
SessionRecording::from_instructions_unchecked(
|
||||
1,
|
||||
DrawStockConfig::DrawOne,
|
||||
[KlondikeInstruction::RotateStock],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1458,12 +1414,8 @@ mod tests {
|
||||
|
||||
// Frame 1: enter Playing. The observer's first sample sees
|
||||
// `last_was_playing = false` and `now_playing = true`.
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
|
||||
app.update();
|
||||
assert!(
|
||||
!cinephile_unlocked(&app),
|
||||
@@ -1492,12 +1444,8 @@ mod tests {
|
||||
fn cinephile_does_not_unlock_on_stop_button_abort() {
|
||||
let mut app = cinephile_app();
|
||||
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
|
||||
app.update();
|
||||
|
||||
// Direct Playing → Inactive — the path the Stop button takes via
|
||||
@@ -1523,12 +1471,8 @@ mod tests {
|
||||
let mut app = cinephile_app();
|
||||
|
||||
// First completion cycle to unlock.
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
|
||||
app.update();
|
||||
@@ -1546,12 +1490,8 @@ mod tests {
|
||||
// Second cycle: Inactive → Playing → Completed once more.
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Inactive;
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
|
||||
app.update();
|
||||
@@ -1570,12 +1510,8 @@ mod tests {
|
||||
fn cinephile_fires_once_across_completed_linger() {
|
||||
let mut app = cinephile_app();
|
||||
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
|
||||
app.update();
|
||||
|
||||
@@ -29,7 +29,9 @@ use crate::events::{
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::layout::LayoutResource;
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::platform::USE_TOUCH_UI_LAYOUT;
|
||||
use crate::progress_plugin::LevelUpEvent;
|
||||
use crate::safe_area::SafeAreaAnchoredBottom;
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||
use crate::time_attack_plugin::TimeAttackEndedEvent;
|
||||
use crate::ui_theme::{
|
||||
@@ -160,6 +162,71 @@ pub struct ActiveToast {
|
||||
/// Duration of each queued info-toast in seconds.
|
||||
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.
|
||||
pub struct AnimationPlugin;
|
||||
|
||||
@@ -185,7 +252,7 @@ impl Plugin for AnimationPlugin {
|
||||
.init_resource::<EffectiveSlideDuration>()
|
||||
.init_resource::<ToastQueue>()
|
||||
.init_resource::<ActiveToast>()
|
||||
.add_systems(Startup, init_slide_duration)
|
||||
.add_systems(Startup, (init_slide_duration, spawn_toast_stack_root))
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -206,6 +273,7 @@ impl Plugin for AnimationPlugin {
|
||||
handle_warning_toast,
|
||||
tick_toasts,
|
||||
(enqueue_toasts, drive_toast_display).chain(),
|
||||
adopt_toasts_into_stack,
|
||||
)
|
||||
.after(GameMutation),
|
||||
);
|
||||
@@ -252,10 +320,17 @@ fn advance_card_anims(
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut anims: Query<(Entity, &mut Transform, &mut CardAnim)>,
|
||||
mut redraw: MessageWriter<RequestRedraw>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
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();
|
||||
for (entity, mut transform, mut anim) in &mut anims {
|
||||
if anim.delay > 0.0 {
|
||||
@@ -558,10 +633,17 @@ fn drive_toast_display(
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut queue: ResMut<ToastQueue>,
|
||||
mut active: ResMut<ActiveToast>,
|
||||
mut redraw: MessageWriter<RequestRedraw>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
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();
|
||||
|
||||
// Tick down the active toast timer.
|
||||
@@ -593,9 +675,9 @@ pub enum ToastVariant {
|
||||
/// Neutral system message — teal border. Default for `InfoToastEvent`,
|
||||
/// settings volume notifications, and the auto-complete announcement.
|
||||
Info,
|
||||
/// Caution / penalty — gold border. Currently unused by an in-engine
|
||||
/// event; kept so future warning-flavoured toasts have a slot.
|
||||
#[allow(dead_code)]
|
||||
/// Caution / penalty — gold border. Used by [`handle_warning_toast`]
|
||||
/// for `WarningToastEvent` messages (daily-challenge expiry, sync,
|
||||
/// theme-store, and leaderboard warnings).
|
||||
Warning,
|
||||
/// Failure / rejected action — pink border. Used by
|
||||
/// [`handle_move_rejected_toast`] for illegal-placement
|
||||
@@ -623,26 +705,14 @@ impl ToastVariant {
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns a bottom-anchored `ToastEntity` for the queued toast system.
|
||||
/// Spawns a `ToastEntity` for the queued toast system.
|
||||
///
|
||||
/// Queued toasts always carry [`ToastVariant::Info`] — the queue is fed
|
||||
/// by [`InfoToastEvent`] which is by definition neutral system info.
|
||||
/// Variants other than `Info` belong on the immediate-fire path
|
||||
/// ([`spawn_toast`]) where the call site knows the semantic intent.
|
||||
fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity {
|
||||
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),
|
||||
)
|
||||
spawn_toast_node(commands, ToastEntity, message, ToastVariant::Info)
|
||||
}
|
||||
|
||||
fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpAwardedEvent>) {
|
||||
@@ -730,12 +800,6 @@ fn spawn_toast(
|
||||
(ToastOverlay, ToastTimer(duration_secs)),
|
||||
message,
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -752,31 +816,25 @@ fn spawn_toast(
|
||||
/// rungs; 18 is the closest rung that preserves the scale invariants
|
||||
/// tested in `ui_theme::tests`.
|
||||
/// - [`RADIUS_MD`] corners.
|
||||
/// - Bottom-anchored absolute position; `bottom_pct` differs between
|
||||
/// queued and immediate paths so they layer instead of overlap.
|
||||
// The 8-argument signature is intentional — these are the per-toast
|
||||
// layout values that genuinely differ between the queued and fire-and-
|
||||
// forget call sites. A struct wrapper would just rename the same data.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
///
|
||||
/// Layout is owned by [`ToastStackRoot`] (Phase H): the node spawns
|
||||
/// hidden and unpositioned, and [`adopt_toasts_into_stack`] slots it
|
||||
/// into the shared bottom-anchored column — one anchor for every toast,
|
||||
/// simultaneous toasts stack instead of overlapping.
|
||||
fn spawn_toast_node<B: Bundle>(
|
||||
commands: &mut Commands,
|
||||
bundle: B,
|
||||
message: String,
|
||||
variant: ToastVariant,
|
||||
bottom_pct: Val,
|
||||
left_pct: Val,
|
||||
width_pct: Val,
|
||||
padding: UiRect,
|
||||
) -> Entity {
|
||||
commands
|
||||
.spawn((
|
||||
bundle,
|
||||
ToastNode,
|
||||
Visibility::Hidden,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: left_pct,
|
||||
bottom: bottom_pct,
|
||||
width: width_pct,
|
||||
padding,
|
||||
max_width: Val::Percent(70.0),
|
||||
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
@@ -785,7 +843,6 @@ fn spawn_toast_node<B: Bundle>(
|
||||
},
|
||||
BackgroundColor(BG_ELEVATED),
|
||||
BorderColor::all(variant.border_color()),
|
||||
ZIndex(Z_TOAST),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
@@ -1301,4 +1358,85 @@ mod tests {
|
||||
fn cascade_duration_instant_is_zero() {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
//! custom `AssetSource` implementations used by the theme system.
|
||||
|
||||
pub mod card_face_svg;
|
||||
pub mod hud_icon_svg;
|
||||
pub mod icon_svg;
|
||||
pub mod sources;
|
||||
pub mod svg_loader;
|
||||
|
||||
@@ -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
|
||||
/// current platform.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if [`solitaire_data::data_dir`] returns `None`, which on
|
||||
/// desktop indicates a broken `$HOME` / `$XDG_*` configuration.
|
||||
/// Android always returns `Some`. The panic message names the
|
||||
/// supported workaround ([`set_user_theme_dir`]).
|
||||
/// When [`solitaire_data::data_dir`] returns `None` (broken `$HOME` /
|
||||
/// `$XDG_*` on desktop; always on wasm32, which has no filesystem) this
|
||||
/// returns an empty path — callers treat that as "no user themes" and
|
||||
/// the bundled default theme still works. A warning naming the
|
||||
/// [`set_user_theme_dir`] workaround is logged once. Android always
|
||||
/// resolves.
|
||||
pub fn user_theme_dir() -> PathBuf {
|
||||
if let Some(p) = USER_THEME_DIR_OVERRIDE.get() {
|
||||
return p.clone();
|
||||
@@ -76,29 +76,32 @@ fn user_theme_dir_for(data_dir: PathBuf) -> PathBuf {
|
||||
/// Per-target-os resolution of the platform's data dir. Delegates
|
||||
/// to [`solitaire_data::data_dir`] which encapsulates the
|
||||
/// per-target shape (desktop: `dirs::data_dir()`; android: the
|
||||
/// hardcoded `/data/data/<package>/files` sandbox path). Panics
|
||||
/// only when the underlying resolver returns `None`, which on
|
||||
/// desktop indicates a broken `$HOME` / `$XDG_*` configuration —
|
||||
/// the panic message names the supported workaround.
|
||||
/// hardcoded `/data/data/<package>/files` sandbox path).
|
||||
///
|
||||
/// When the resolver returns `None` — always on wasm32 (no
|
||||
/// filesystem), or a broken `$HOME` / `$XDG_*` configuration on
|
||||
/// 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 {
|
||||
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"))]
|
||||
{
|
||||
panic!(
|
||||
"user_theme_dir(): platform data directory is unavailable. \
|
||||
On Linux check $XDG_DATA_HOME or $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()."
|
||||
)
|
||||
use std::sync::Once;
|
||||
static WARN_ONCE: Once = Once::new();
|
||||
WARN_ONCE.call_once(|| {
|
||||
bevy::log::warn!(
|
||||
"user_theme_dir(): platform data directory is unavailable; \
|
||||
user themes are disabled. On Linux check $XDG_DATA_HOME or \
|
||||
$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()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Sound-effect playback via `kira`.
|
||||
//!
|
||||
//! Loads five embedded WAVs (`include_bytes!`) at startup and plays them in
|
||||
//! response to gameplay events:
|
||||
//! Loads seven embedded WAVs (`include_bytes!`) at startup — six SFX plus
|
||||
//! the ambient loop — and plays them in response to gameplay events:
|
||||
//!
|
||||
//! | Event | Sound |
|
||||
//! |---|---|
|
||||
@@ -10,6 +10,7 @@
|
||||
//! | `MoveRejectedEvent` | `card_invalid.wav` |
|
||||
//! | `NewGameRequestEvent` | `card_deal.wav` |
|
||||
//! | `GameWonEvent` | `win_fanfare.wav` |
|
||||
//! | `FoundationCompletedEvent` | `foundation_complete.wav` |
|
||||
//!
|
||||
//! An ambient loop (`ambient_loop.wav`) is started at plugin startup at very
|
||||
//! low volume (0.05 amplitude) routed through `music_track`.
|
||||
@@ -38,7 +39,7 @@ use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||
/// Volume amplitude for the stock-recycle draw sound (half of normal 1.0).
|
||||
const RECYCLE_VOLUME: f64 = 0.5;
|
||||
|
||||
/// Volume amplitude for the ambient music loop placeholder.
|
||||
/// Volume amplitude for the ambient music loop.
|
||||
const AMBIENT_VOLUME: f64 = 0.05;
|
||||
|
||||
/// Converts a linear amplitude (0.0–1.0+) to the `Decibels` type used by
|
||||
@@ -101,7 +102,7 @@ pub struct MuteState {
|
||||
pub music_muted: bool,
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
/// 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`.
|
||||
pub struct AudioPlugin;
|
||||
|
||||
impl Plugin for AudioPlugin {
|
||||
|
||||
@@ -147,6 +147,7 @@ fn drive_auto_complete(
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut moves: MessageWriter<MoveRequestEvent>,
|
||||
mut redraw: MessageWriter<RequestRedraw>,
|
||||
) {
|
||||
if !state.active {
|
||||
return;
|
||||
@@ -154,6 +155,10 @@ fn drive_auto_complete(
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
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();
|
||||
if state.cooldown > 0.0 {
|
||||
@@ -174,9 +179,9 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::{Deck, Rank, Suit};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
@@ -214,7 +219,11 @@ mod tests {
|
||||
}
|
||||
g.set_test_tableau_cards(
|
||||
Tableau::Tableau1,
|
||||
vec![solitaire_core::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
|
||||
vec![solitaire_core::Card::new(
|
||||
Deck::Deck1,
|
||||
Suit::Clubs,
|
||||
Rank::Ace,
|
||||
)],
|
||||
);
|
||||
g.set_test_auto_completable(true);
|
||||
let expected = (
|
||||
|
||||
@@ -18,11 +18,6 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! # 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`
|
||||
//!
|
||||
//! `CardAnimation` and the legacy `CardAnim` can coexist in the same world but
|
||||
@@ -33,6 +28,7 @@
|
||||
use std::f32::consts::PI;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::RequestRedraw;
|
||||
|
||||
use super::curves::{MotionCurve, sample_curve};
|
||||
use super::timing::compute_duration;
|
||||
@@ -122,8 +118,6 @@ impl CardAnimation {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
if self.duration <= 0.0 {
|
||||
return self.end;
|
||||
@@ -134,90 +128,6 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -232,10 +142,18 @@ pub(crate) fn advance_card_animations(
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut q: Query<(Entity, &mut Transform, &mut CardAnimation)>,
|
||||
mut redraw: MessageWriter<RequestRedraw>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
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();
|
||||
|
||||
for (entity, mut transform, mut anim) in &mut q {
|
||||
@@ -283,27 +201,6 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -386,21 +283,4 @@ mod tests {
|
||||
.with_z_lift(12.0);
|
||||
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,9 +72,7 @@ pub struct HoverState {
|
||||
/// Describes a user action that arrived while cards were still animating.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BufferedInput {
|
||||
Move {
|
||||
from: MoveRequestEvent,
|
||||
},
|
||||
Move { from: MoveRequestEvent },
|
||||
Draw,
|
||||
Undo,
|
||||
}
|
||||
|
||||
@@ -31,38 +31,6 @@
|
||||
//! ));
|
||||
//! ```
|
||||
//!
|
||||
//! 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
|
||||
//!
|
||||
//! | Condition | Safe? |
|
||||
@@ -80,24 +48,21 @@ pub mod interaction;
|
||||
pub mod timing;
|
||||
pub mod tuning;
|
||||
|
||||
pub use animation::{CardAnimation, retarget_animation, win_scatter_targets};
|
||||
pub use animation::CardAnimation;
|
||||
pub use chain::AnimationChain;
|
||||
pub use curves::{MotionCurve, sample_curve};
|
||||
pub use diagnostics::{FrameTimeDiagnostics, WINDOW_SIZE as DIAG_WINDOW_SIZE};
|
||||
pub use interaction::{BufferedInput, HoverState, InputBuffer};
|
||||
pub use timing::{
|
||||
DEAL_INTERVAL_SECS, MAX_DURATION_SECS, MIN_DURATION_SECS, WIN_CASCADE_INTERVAL_SECS,
|
||||
cascade_delay, compute_duration, micro_vary,
|
||||
DEAL_INTERVAL_SECS, MAX_DURATION_SECS, MIN_DURATION_SECS, compute_duration, micro_vary,
|
||||
};
|
||||
pub use tuning::{AnimationTuning, InputPlatform};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::RequestRedraw;
|
||||
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, UndoRequestEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::layout::LayoutResource;
|
||||
use crate::resources::DragState;
|
||||
|
||||
use animation::advance_card_animations;
|
||||
@@ -166,63 +131,6 @@ impl Plugin for CardAnimationPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -307,6 +215,49 @@ 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]
|
||||
fn card_animation_instant_snaps_on_zero_duration() {
|
||||
let mut app = App::new();
|
||||
@@ -412,19 +363,4 @@ mod tests {
|
||||
let state = HoverState::default();
|
||||
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,17 +49,6 @@ pub fn micro_vary(duration: f32, entity_index: u32) -> f32 {
|
||||
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).
|
||||
pub const DEAL_INTERVAL_SECS: f32 = 0.022;
|
||||
|
||||
@@ -137,22 +126,4 @@ mod tests {
|
||||
"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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,7 @@ use crate::animation_plugin::EffectiveSlideDuration;
|
||||
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
|
||||
use crate::layout::LayoutResource;
|
||||
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.
|
||||
///
|
||||
@@ -197,4 +195,3 @@ pub(super) fn update_card_shadows_on_drag(
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #28 — Hint highlight tick system
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
|
||||
use bevy::color::Color;
|
||||
use solitaire_core::Card;
|
||||
use solitaire_core::game_state::GameState;
|
||||
@@ -272,5 +271,3 @@ pub(super) fn find_top_card_at(
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #28 — Stock-empty visual indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
|
||||
use bevy::color::Color;
|
||||
use bevy::sprite::Anchor;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
@@ -205,4 +204,3 @@ pub(super) fn add_android_corner_label(
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #34 — Card-flip animation systems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -15,10 +15,7 @@ use crate::font_plugin::FontResource;
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
use crate::resources::GameStateResource;
|
||||
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
|
||||
/// pending size on [`ResizeThrottle`].
|
||||
@@ -347,5 +344,3 @@ pub(super) fn fill_tableau_fan_on_startup(
|
||||
};
|
||||
crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ use std::collections::HashMap;
|
||||
|
||||
use bevy::color::Color;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::{KlondikePile, Tableau};
|
||||
use solitaire_core::Card;
|
||||
use solitaire_core::{KlondikePile, Tableau};
|
||||
|
||||
use crate::card_animation::CardAnimation;
|
||||
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
|
||||
@@ -577,10 +577,7 @@ impl Plugin for CardPlugin {
|
||||
// the chain each pair is a scheduler ambiguity (#143). All
|
||||
// members are cheap and mostly change-gated; sequential
|
||||
// execution is not a cost that matters here.
|
||||
.configure_sets(
|
||||
Update,
|
||||
LayoutSystem::UpdateOnResize.before(BoardVisuals),
|
||||
)
|
||||
.configure_sets(Update, LayoutSystem::UpdateOnResize.before(BoardVisuals))
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -597,8 +594,7 @@ impl Plugin for CardPlugin {
|
||||
clear_right_click_highlights_on_pause,
|
||||
tick_hint_highlight,
|
||||
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,
|
||||
snap_cards_on_window_resize,
|
||||
resize_android_corner_labels,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
|
||||
use bevy::color::Color;
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_core::game_state::GameState;
|
||||
@@ -12,10 +11,7 @@ use crate::font_plugin::FontResource;
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
use crate::resources::GameStateResource;
|
||||
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,
|
||||
/// to signal to the player that there are no more cards to draw. Pure white
|
||||
@@ -288,4 +284,3 @@ pub(super) fn update_stock_count_badge(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ use super::*;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use bevy::color::Color;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
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::card_animation::CardAnimation;
|
||||
@@ -700,4 +700,3 @@ pub(super) fn update_card_entity(
|
||||
commands.entity(entity).insert(new_children_key);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::layout::TABLEAU_FAN_FRAC;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
use solitaire_core::Deck;
|
||||
use bevy::window::WindowResized;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
use std::collections::HashSet;
|
||||
use crate::events::StateChangedEvent;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::layout::LayoutResource;
|
||||
use crate::layout::TABLEAU_FAN_FRAC;
|
||||
use crate::resources::DragState;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
use crate::ui_theme::TEXT_PRIMARY_HC;
|
||||
use bevy::window::WindowResized;
|
||||
use solitaire_core::Deck;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Convenience constructor — all unit tests use Deck1.
|
||||
fn make_card(suit: Suit, rank: Rank) -> Card {
|
||||
@@ -119,8 +118,7 @@ fn waste_draw_one_only_renders_top_card() {
|
||||
for _ in 0..3 {
|
||||
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);
|
||||
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
@@ -163,8 +161,7 @@ fn waste_draw_three_renders_up_to_three_fanned_cards() {
|
||||
"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 positions = card_positions(&g, &layout);
|
||||
@@ -216,8 +213,7 @@ fn waste_draw_three_fans_correctly_when_pile_smaller_than_visible() {
|
||||
let count = waste_pile.len();
|
||||
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 positions = card_positions(&g, &layout);
|
||||
|
||||
@@ -254,8 +250,7 @@ fn waste_draw_one_buffer_card_at_same_xy_as_top() {
|
||||
for _ in 0..3 {
|
||||
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 positions = card_positions(&g, &layout);
|
||||
let waste_rendered: Vec<_> = positions
|
||||
@@ -745,8 +740,7 @@ fn advance_past_resize_throttle(app: &mut App) {
|
||||
|
||||
fn fire_window_resize(app: &mut App, width: f32, height: f32) {
|
||||
// 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 {
|
||||
window,
|
||||
width,
|
||||
@@ -928,8 +922,7 @@ fn resize_in_place_updates_card_label_font_size() {
|
||||
// Sanity-check: the new font size matches FONT_SIZE_FRAC × the
|
||||
// post-resize card width, so the in-place path is using the
|
||||
// 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;
|
||||
assert!(
|
||||
(after - expected).abs() < 1e-3,
|
||||
@@ -1046,7 +1039,8 @@ fn shadow_offset_increases_during_drag() {
|
||||
q.iter(app.world())
|
||||
.next()
|
||||
.expect("fixture should spawn at least one CardEntity")
|
||||
.card.clone()
|
||||
.card
|
||||
.clone()
|
||||
};
|
||||
|
||||
// Pick a *different* card to act as the negative control —
|
||||
@@ -1101,9 +1095,7 @@ fn shadow_offset_increases_during_drag() {
|
||||
fn shadow_offset_for_card(app: &mut App, card: &Card) -> Vec2 {
|
||||
// Map every CardEntity to its (Entity, card).
|
||||
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())
|
||||
.find(|(_, c)| c.card == *card)
|
||||
.map(|(e, _)| e)
|
||||
@@ -1198,8 +1190,7 @@ fn stock_badge_updates_when_stock_count_changes() {
|
||||
assert_eq!(stock_badge_text(&mut app), "24");
|
||||
{
|
||||
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();
|
||||
game.0.set_test_stock_cards(stock);
|
||||
}
|
||||
@@ -1234,8 +1225,7 @@ fn image_set_with_distinct_back_handles() -> CardImageSet {
|
||||
// distinct dummy `Image`. We never render these; we only
|
||||
// compare ids.
|
||||
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 {
|
||||
faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())),
|
||||
backs,
|
||||
@@ -1493,8 +1483,7 @@ 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 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
|
||||
.iter()
|
||||
@@ -1543,8 +1532,7 @@ fn waste_cards_do_not_overlap_stock_column_on_portrait() {
|
||||
|
||||
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)
|
||||
.into_iter()
|
||||
@@ -1573,8 +1561,7 @@ 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 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
|
||||
.iter()
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
//! 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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,17 +14,19 @@ use crate::platform::{
|
||||
};
|
||||
use crate::{
|
||||
AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin,
|
||||
CardAnimationPlugin, CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin,
|
||||
DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
|
||||
HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin,
|
||||
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
|
||||
SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncProvider,
|
||||
TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin,
|
||||
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
||||
CardAnimationPlugin, CardPlugin, ChallengePlugin, CheatSheetPlugin, ContextualTipsPlugin,
|
||||
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
|
||||
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin,
|
||||
PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin,
|
||||
ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin, SelectionPlugin,
|
||||
SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin, StatsPlugin, SyncProvider, TablePlugin,
|
||||
ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin, UiFocusPlugin,
|
||||
UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WhatsNewPlugin, WinSummaryPlugin,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::{
|
||||
AnalyticsPlugin, AudioPlugin, AvatarPlugin, LeaderboardPlugin, SyncPlugin, SyncSetupPlugin,
|
||||
ThemeStorePlugin,
|
||||
};
|
||||
|
||||
/// Groups all Ferrous Solitaire gameplay plugins.
|
||||
@@ -92,6 +94,7 @@ impl Plugin for CoreGamePlugin {
|
||||
.add_plugins(FeedbackAnimPlugin)
|
||||
.add_plugins(CardAnimationPlugin)
|
||||
.add_plugins(AutoCompletePlugin)
|
||||
.add_plugins(SolutionPlaybackPlugin)
|
||||
.add_plugins(ReplayPlaybackPlugin)
|
||||
.add_plugins(ReplayOverlayPlugin)
|
||||
.add_plugins(StatsPlugin::default())
|
||||
@@ -108,9 +111,13 @@ impl Plugin for CoreGamePlugin {
|
||||
.add_plugins(HelpPlugin)
|
||||
.add_plugins(HomePlugin::default())
|
||||
.add_plugins(ProfilePlugin)
|
||||
.add_plugins(crate::you_hub_plugin::YouHubPlugin)
|
||||
.add_plugins(PausePlugin)
|
||||
.add_plugins(SettingsPlugin::default())
|
||||
.add_plugins(OnboardingPlugin)
|
||||
.add_plugins(WhatsNewPlugin)
|
||||
.add_plugins(ContextualTipsPlugin)
|
||||
.add_plugins(CheatSheetPlugin)
|
||||
.add_plugins(WinSummaryPlugin)
|
||||
.add_plugins(UiModalPlugin)
|
||||
.add_plugins(UiFocusPlugin)
|
||||
@@ -126,6 +133,7 @@ impl Plugin for CoreGamePlugin {
|
||||
.add_plugins(AudioPlugin)
|
||||
.add_plugins(SyncPlugin::new(sync_provider))
|
||||
.add_plugins(SyncSetupPlugin)
|
||||
.add_plugins(ThemeStorePlugin)
|
||||
.add_plugins(AnalyticsPlugin)
|
||||
.add_plugins(LeaderboardPlugin);
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
|
||||
use solitaire_core::Card;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
|
||||
use crate::card_plugin::RightClickHighlight;
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
@@ -437,7 +437,8 @@ fn tableau_or_stack_pos(
|
||||
base.x,
|
||||
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 visible_start = pile_len.saturating_sub(3);
|
||||
let slot = index.saturating_sub(visible_start) as f32;
|
||||
@@ -581,7 +582,10 @@ mod tests {
|
||||
|
||||
use crate::layout::compute_layout;
|
||||
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
|
||||
use solitaire_core::{
|
||||
DrawStockConfig,
|
||||
game_state::{GameMode, GameState},
|
||||
};
|
||||
|
||||
/// Builds an `App` with `MinimalPlugins` and the overlay system
|
||||
/// registered, plus the resources the system needs. Callers
|
||||
@@ -630,11 +634,7 @@ mod tests {
|
||||
// — same colour family, illegal. Tableau(2) must NOT be
|
||||
// highlighted.
|
||||
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 mut app = overlay_test_app(game);
|
||||
|
||||
@@ -54,7 +54,10 @@ impl DifficultyIndexResource {
|
||||
DifficultyLevel::Hard => &mut self.hard,
|
||||
DifficultyLevel::Expert => &mut self.expert,
|
||||
DifficultyLevel::Grandmaster => &mut self.grandmaster,
|
||||
DifficultyLevel::Random => unreachable!("Random has no catalog"),
|
||||
// Random has no catalog today, so seeds_for() already returned
|
||||
// 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()];
|
||||
*cursor = cursor.wrapping_add(1);
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
use bevy::prelude::Message;
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_core::{Card, Suit};
|
||||
use solitaire_core::game_state::GameMode;
|
||||
use solitaire_core::{Card, Suit};
|
||||
use solitaire_data::AchievementRecord;
|
||||
use solitaire_sync::SyncResponse;
|
||||
|
||||
/// Request to move `count` cards from `from` to `to`. Fired by input systems,
|
||||
/// consumed by `GamePlugin`.
|
||||
@@ -134,6 +133,12 @@ pub struct ManualSyncRequestEvent;
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
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
|
||||
/// credentials, and reset to `SyncBackend::Local`. Fired by the "Disconnect"
|
||||
/// button in the Settings sync section.
|
||||
@@ -153,6 +158,12 @@ pub struct DeleteAccountRequestEvent;
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
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"
|
||||
/// button alongside the existing `F1` accelerator so the overlay is
|
||||
/// reachable without a keyboard. Consumed by `help_plugin::toggle_help_screen`.
|
||||
@@ -231,15 +242,10 @@ pub struct ToggleSettingsRequestEvent;
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct ToggleLeaderboardRequestEvent;
|
||||
|
||||
/// Fired by `SyncPlugin` after a pull task resolves and the merged result has
|
||||
/// been persisted to disk. `Ok(SyncResponse)` carries the merged payload plus
|
||||
/// any `ConflictReport`s the merge produced. `Err(String)` carries a
|
||||
/// 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>);
|
||||
/// Request to toggle the Home mode launcher. Fired by the HUD
|
||||
/// Menu-popover "Home" row alongside the existing `M` accelerator.
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct ToggleHomeRequestEvent;
|
||||
|
||||
/// Generic informational toast message. Any system can fire this to display
|
||||
/// a short string to the player, e.g. "Locked — reach level 5".
|
||||
@@ -283,15 +289,6 @@ pub struct ForfeitEvent;
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
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
|
||||
/// and destination pile for visual highlighting.
|
||||
///
|
||||
|
||||
@@ -49,10 +49,11 @@ use solitaire_core::klondike_adapter::foundation_from_slot;
|
||||
use solitaire_data::AnimSpeed;
|
||||
|
||||
use crate::animation_plugin::CardAnim;
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::card_animation::{MotionCurve, sample_curve};
|
||||
use crate::card_plugin::{CardEntity, CardEntityIndex};
|
||||
use crate::events::{
|
||||
DrawRequestEvent, FoundationCompletedEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||
NewGameRequestEvent,
|
||||
DrawRequestEvent, FoundationCompletedEvent, HintVisualEvent, MoveRejectedEvent,
|
||||
MoveRequestEvent, NewGameRequestEvent, StateChangedEvent,
|
||||
};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::layout::LayoutResource;
|
||||
@@ -207,6 +208,8 @@ impl Plugin for FeedbackAnimPlugin {
|
||||
.add_message::<MoveRejectedEvent>()
|
||||
.add_message::<NewGameRequestEvent>()
|
||||
.add_message::<FoundationCompletedEvent>()
|
||||
.add_message::<HintVisualEvent>()
|
||||
.add_message::<StateChangedEvent>()
|
||||
.add_message::<RequestRedraw>()
|
||||
.add_systems(
|
||||
Update,
|
||||
@@ -224,6 +227,20 @@ impl Plugin for FeedbackAnimPlugin {
|
||||
start_deal_anim.after(GameMutation),
|
||||
start_foundation_flourish.after(GameMutation),
|
||||
),
|
||||
)
|
||||
// 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -276,10 +293,16 @@ fn tick_shake_anim(
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut anims: Query<(Entity, &mut Transform, &mut ShakeAnim)>,
|
||||
mut redraw: MessageWriter<RequestRedraw>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
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();
|
||||
for (entity, mut transform, mut anim) in &mut anims {
|
||||
anim.elapsed += dt;
|
||||
@@ -356,10 +379,16 @@ fn tick_settle_anim(
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut anims: Query<(Entity, &mut Transform, &mut SettleAnim)>,
|
||||
mut redraw: MessageWriter<RequestRedraw>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
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();
|
||||
for (entity, mut transform, mut anim) in &mut anims {
|
||||
anim.elapsed += dt;
|
||||
@@ -581,10 +610,16 @@ fn tick_foundation_flourish(
|
||||
(Entity, &mut Sprite, &mut FoundationMarkerFlourish),
|
||||
Without<FoundationFlourish>,
|
||||
>,
|
||||
mut redraw: MessageWriter<RequestRedraw>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
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();
|
||||
|
||||
// Advance the King's scale pulse.
|
||||
@@ -646,6 +681,147 @@ 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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -852,7 +1028,10 @@ mod tests {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(FeedbackAnimPlugin);
|
||||
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
|
||||
app.insert_resource(GameStateResource(GameState::new(
|
||||
1,
|
||||
DrawStockConfig::DrawOne,
|
||||
)));
|
||||
app.insert_resource(SettingsResource(Settings {
|
||||
reduce_motion_mode: true,
|
||||
..Settings::default()
|
||||
@@ -906,7 +1085,10 @@ mod tests {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(FeedbackAnimPlugin);
|
||||
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
|
||||
app.insert_resource(GameStateResource(GameState::new(
|
||||
1,
|
||||
DrawStockConfig::DrawOne,
|
||||
)));
|
||||
app.insert_resource(SettingsResource(Settings {
|
||||
reduce_motion_mode: true,
|
||||
..Settings::default()
|
||||
@@ -931,4 +1113,147 @@ mod tests {
|
||||
"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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,13 @@ use bevy::prelude::*;
|
||||
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
||||
use bevy::window::AppLifecycle;
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
|
||||
use solitaire_core::{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)]
|
||||
use solitaire_data::latest_replay_path;
|
||||
use solitaire_data::{
|
||||
@@ -81,6 +86,14 @@ pub struct NewGameRequestWriters;
|
||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||
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
|
||||
/// arrival order and any same-frame order is fine (#143).
|
||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||
@@ -188,7 +201,9 @@ impl Plugin for GamePlugin {
|
||||
)
|
||||
} else {
|
||||
(
|
||||
saved.unwrap_or_else(|| GameState::new(seed_from_system_time(), DrawStockConfig::DrawOne)),
|
||||
saved.unwrap_or_else(|| {
|
||||
GameState::new(seed_from_system_time(), DrawStockConfig::DrawOne)
|
||||
}),
|
||||
None,
|
||||
)
|
||||
};
|
||||
@@ -1055,11 +1070,16 @@ pub fn record_replay_on_win(
|
||||
if recording.moves.is_empty() {
|
||||
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
|
||||
// win condition is the last one in the list. Storing the
|
||||
// index explicitly lets the playback UI read the WIN MOVE
|
||||
// position directly instead of re-deriving it on every render.
|
||||
let win_move_index = recording.moves.len().checked_sub(1);
|
||||
let win_move_index = session_recording.len().checked_sub(1);
|
||||
let replay = Replay::new(
|
||||
game.0.seed,
|
||||
game.0.draw_mode(),
|
||||
@@ -1067,7 +1087,7 @@ pub fn record_replay_on_win(
|
||||
ev.time_seconds,
|
||||
ev.score,
|
||||
Utc::now().date_naive(),
|
||||
recording.moves.clone(),
|
||||
session_recording,
|
||||
)
|
||||
.with_win_move_index(win_move_index);
|
||||
let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) else {
|
||||
@@ -1324,7 +1344,10 @@ fn auto_save_game_state(
|
||||
// or there's a pending restore the player hasn't answered — saving
|
||||
// the fresh-deal placeholder we seeded GameStateResource with at
|
||||
// startup would clobber the real saved game on disk.
|
||||
if paused.is_some_and(|p| p.0) || game.0.is_won() || game.0.move_count() == 0 || pending.0.is_some()
|
||||
if paused.is_some_and(|p| p.0)
|
||||
|| game.0.is_won()
|
||||
|| game.0.move_count() == 0
|
||||
|| pending.0.is_some()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -372,9 +372,7 @@ fn moving_cards_off_face_up_card_does_not_fire_card_flipped_event() {
|
||||
});
|
||||
app.update();
|
||||
|
||||
let events = app
|
||||
.world()
|
||||
.resource::<Messages<CardFlippedEvent>>();
|
||||
let events = app.world().resource::<Messages<CardFlippedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
let fired: Vec<_> = cursor.read(events).collect();
|
||||
assert!(
|
||||
@@ -800,7 +798,10 @@ fn replay_recording_skips_undo() {
|
||||
1,
|
||||
"only the draw is recorded; the undo does not erase it nor add a new entry",
|
||||
);
|
||||
assert!(matches!(recording.moves[0], KlondikeInstruction::RotateStock));
|
||||
assert!(matches!(
|
||||
recording.moves[0],
|
||||
KlondikeInstruction::RotateStock
|
||||
));
|
||||
}
|
||||
|
||||
/// Starting a new game wipes the recording so the next deal begins
|
||||
@@ -852,17 +853,15 @@ fn replay_recording_freezes_into_replay_on_game_won() {
|
||||
let mut app = test_app(7654);
|
||||
app.insert_resource(ReplayPath(Some(path.clone())));
|
||||
|
||||
// Push two recorded instructions manually so we can verify they
|
||||
// survive the freeze/save round-trip without having to drive a
|
||||
// real win. Both are `RotateStock` — the only instruction
|
||||
// constructible without the runtime-only `klondike` pile-stack
|
||||
// types (which the engine intentionally does not depend on); the
|
||||
// round-trip shape is identical for any instruction variant.
|
||||
{
|
||||
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
|
||||
recording.moves.push(KlondikeInstruction::RotateStock);
|
||||
recording.moves.push(KlondikeInstruction::RotateStock);
|
||||
}
|
||||
// Drive two real draws so the *session* history (the source the
|
||||
// freeze now serialises from, via `GameState::recording()`) holds
|
||||
// two instructions. `RotateStock` is the only instruction the
|
||||
// engine can drive without the runtime-only `klondike` pile-stack
|
||||
// types; the round-trip shape is identical for any variant.
|
||||
app.world_mut().write_message(DrawRequestEvent);
|
||||
app.update();
|
||||
app.world_mut().write_message(DrawRequestEvent);
|
||||
app.update();
|
||||
|
||||
// Fire the win event the engine emits when the last foundation
|
||||
// completes — `record_replay_on_win` listens for it.
|
||||
@@ -872,8 +871,8 @@ fn replay_recording_freezes_into_replay_on_game_won() {
|
||||
});
|
||||
app.update();
|
||||
|
||||
let history = load_replay_history_from(&path)
|
||||
.expect("a winning replay must be persisted to ReplayPath");
|
||||
let history =
|
||||
load_replay_history_from(&path).expect("a winning replay must be persisted to ReplayPath");
|
||||
assert_eq!(
|
||||
history.replays.len(),
|
||||
1,
|
||||
@@ -894,9 +893,15 @@ fn replay_recording_freezes_into_replay_on_game_won() {
|
||||
loaded.time_seconds, 250,
|
||||
"time_seconds must come from the win event"
|
||||
);
|
||||
assert_eq!(loaded.moves.len(), 2, "every recorded move must round-trip");
|
||||
assert!(matches!(loaded.moves[0], KlondikeInstruction::RotateStock));
|
||||
assert!(matches!(loaded.moves[1], KlondikeInstruction::RotateStock));
|
||||
let instructions = loaded.recording.instructions();
|
||||
assert_eq!(instructions.len(), 2, "every recorded move must round-trip");
|
||||
assert!(matches!(instructions[0], 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"))]
|
||||
let _ = std::fs::remove_file(&path);
|
||||
@@ -1059,7 +1064,10 @@ fn new_game_with_solver_toggle_off_random_seed_path() {
|
||||
app.update();
|
||||
|
||||
// Game state was reseeded — move_count is 0 on the new game.
|
||||
assert_eq!(app.world().resource::<GameStateResource>().0.move_count(), 0);
|
||||
assert_eq!(
|
||||
app.world().resource::<GameStateResource>().0.move_count(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1133,7 +1141,10 @@ fn new_game_with_solver_toggle_on_retries_until_winnable() {
|
||||
// The chosen seed is non-deterministic (system time),
|
||||
// but the new game must have been started cleanly:
|
||||
// move_count back to 0, undo stack empty.
|
||||
assert_eq!(app.world().resource::<GameStateResource>().0.move_count(), 0);
|
||||
assert_eq!(
|
||||
app.world().resource::<GameStateResource>().0.move_count(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
app.world()
|
||||
.resource::<GameStateResource>()
|
||||
|
||||
@@ -86,13 +86,18 @@ fn toggle_help_screen(
|
||||
}
|
||||
|
||||
/// Click handler for the modal's "Done" button. F1 toggles the overlay
|
||||
/// the same way; this just exposes the close action to mouse / touch.
|
||||
/// the same way; Esc closes too, so dismissal matches every other
|
||||
/// modal (Phase C dismissal audit). Nothing ever stacks above Help,
|
||||
/// so Esc needs no topmost gate.
|
||||
fn handle_help_close_button(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
close_buttons: Query<&Interaction, (With<HelpCloseButton>, Changed<Interaction>)>,
|
||||
screens: Query<Entity, With<HelpScreen>>,
|
||||
) {
|
||||
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||
let clicked = close_buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||
let esc = keys.just_pressed(KeyCode::Escape) && !screens.is_empty();
|
||||
if !clicked && !esc {
|
||||
return;
|
||||
}
|
||||
for entity in &screens {
|
||||
@@ -583,4 +588,33 @@ mod tests {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1183
-310
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,169 @@
|
||||
//! 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 1–6 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,6 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
|
||||
|
||||
/// 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 approaches the top of the window — same UX as a video
|
||||
@@ -49,7 +47,11 @@ const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;
|
||||
/// `target` at a fixed rate so the visual transition is smooth across
|
||||
/// variable framerates.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub(super) fn update_action_fade(windows: Query<&Window>, time: Res<Time>, mut fade: ResMut<HudActionFade>) {
|
||||
pub(super) fn update_action_fade(
|
||||
windows: Query<&Window>,
|
||||
time: Res<Time>,
|
||||
mut fade: ResMut<HudActionFade>,
|
||||
) {
|
||||
let Ok(window) = windows.single() else {
|
||||
return;
|
||||
};
|
||||
@@ -107,12 +109,19 @@ pub(super) fn apply_action_fade(
|
||||
/// Visual feedback for every action button — paints idle / hover / pressed
|
||||
/// states by mutating `BackgroundColor` whenever the interaction state
|
||||
/// changes. One query covers all action buttons via the shared
|
||||
/// `ActionButton` marker.
|
||||
/// `ActionButton` marker. Glass tab-bar buttons are excluded: they carry
|
||||
/// 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)]
|
||||
pub(super) fn paint_action_buttons(
|
||||
mut buttons: Query<
|
||||
(&Interaction, &mut BackgroundColor),
|
||||
(With<ActionButton>, Changed<Interaction>),
|
||||
(
|
||||
With<ActionButton>,
|
||||
Without<GlassTabButton>,
|
||||
Changed<Interaction>,
|
||||
),
|
||||
>,
|
||||
) {
|
||||
for (interaction, mut bg) in &mut buttons {
|
||||
@@ -427,4 +436,3 @@ pub(super) fn lerp_text_color(from: Color, to: Color, t: f32) -> Color {
|
||||
from.alpha + (to.alpha - from.alpha) * t,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
|
||||
|
||||
/// `Changed<Interaction>` filter ensures we only react on the frame the
|
||||
/// interaction state transitions, avoiding repeat events while the button
|
||||
/// is held down. Each click handler fires the corresponding request event,
|
||||
@@ -32,6 +30,86 @@ 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(
|
||||
interaction_query: Query<&Interaction, (With<PauseButton>, Changed<Interaction>)>,
|
||||
mut pause: MessageWriter<PauseRequestEvent>,
|
||||
@@ -313,44 +391,65 @@ pub(super) fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&Font
|
||||
..default()
|
||||
};
|
||||
|
||||
// Each row carries a 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.
|
||||
let rows: [(MenuOption, &'static str, &'static str); 7] = [
|
||||
// One popover row: destination, label, hover tooltip.
|
||||
type MenuRow = (MenuOption, &'static str, &'static str);
|
||||
// Destinations grouped into labelled sections (Phase C of the menu
|
||||
// redesign): Play · You · Community · System. Each row carries a
|
||||
// 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] = [
|
||||
(
|
||||
MenuOption::Help,
|
||||
"Help",
|
||||
"Show controls, rules, and keyboard shortcuts.",
|
||||
"Play",
|
||||
&[(
|
||||
MenuOption::Home,
|
||||
"Home",
|
||||
"Pick a mode, continue, or start a new game.",
|
||||
)],
|
||||
),
|
||||
(
|
||||
MenuOption::Modes,
|
||||
"Game Modes",
|
||||
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
||||
"You",
|
||||
&[
|
||||
(
|
||||
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.",
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
MenuOption::Stats,
|
||||
"Stats",
|
||||
"Lifetime totals: wins, streaks, fastest time, best score.",
|
||||
"Community",
|
||||
&[(
|
||||
MenuOption::Leaderboard,
|
||||
"Leaderboard",
|
||||
"Top players from your sync server. Opt in from Profile.",
|
||||
)],
|
||||
),
|
||||
(
|
||||
MenuOption::Achievements,
|
||||
"Achievements",
|
||||
"Browse unlocked achievements and the rewards still ahead.",
|
||||
),
|
||||
(
|
||||
MenuOption::Profile,
|
||||
"Profile",
|
||||
"Your level, XP progress, and sync status.",
|
||||
),
|
||||
(
|
||||
MenuOption::Settings,
|
||||
"Settings",
|
||||
"Audio, animations, theme, draw mode, and sync.",
|
||||
),
|
||||
(
|
||||
MenuOption::Leaderboard,
|
||||
"Leaderboard",
|
||||
"Top players from your sync server. Opt in from Profile.",
|
||||
"System",
|
||||
&[
|
||||
(
|
||||
MenuOption::Settings,
|
||||
"Settings",
|
||||
"Audio, animations, theme, draw mode, and sync.",
|
||||
),
|
||||
(
|
||||
MenuOption::Help,
|
||||
"Help",
|
||||
"Show controls, rules, and keyboard shortcuts.",
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
@@ -375,27 +474,48 @@ pub(super) fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&Font
|
||||
ZIndex(Z_HUD_POPOVER),
|
||||
))
|
||||
.with_children(|panel| {
|
||||
for (option, label, tooltip) in rows {
|
||||
let section_font = TextFont {
|
||||
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
|
||||
.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),
|
||||
))
|
||||
.spawn(Node {
|
||||
padding: UiRect::axes(VAL_SPACE_3, Val::Px(2.0)),
|
||||
..default()
|
||||
})
|
||||
.with_children(|b| {
|
||||
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
|
||||
b.spawn((
|
||||
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)));
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -424,31 +544,28 @@ pub(super) fn handle_menu_option_click(
|
||||
interaction_query: Query<(&Interaction, &MenuOption), Changed<Interaction>>,
|
||||
popovers: Query<Entity, With<MenuPopover>>,
|
||||
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
||||
mut home: MessageWriter<ToggleHomeRequestEvent>,
|
||||
mut stats: MessageWriter<ToggleStatsRequestEvent>,
|
||||
mut achievements: MessageWriter<ToggleAchievementsRequestEvent>,
|
||||
mut profile: MessageWriter<ToggleProfileRequestEvent>,
|
||||
mut settings: MessageWriter<ToggleSettingsRequestEvent>,
|
||||
mut leaderboard: MessageWriter<ToggleLeaderboardRequestEvent>,
|
||||
mut help: MessageWriter<HelpRequestEvent>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
daily: Option<Res<DailyChallengeResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let mut clicked_any = false;
|
||||
let mut open_modes = false;
|
||||
for (interaction, option) in &interaction_query {
|
||||
if *interaction != Interaction::Pressed {
|
||||
continue;
|
||||
}
|
||||
clicked_any = true;
|
||||
match option {
|
||||
MenuOption::Home => {
|
||||
home.write(ToggleHomeRequestEvent);
|
||||
}
|
||||
MenuOption::Help => {
|
||||
help.write(HelpRequestEvent);
|
||||
}
|
||||
MenuOption::Modes => {
|
||||
open_modes = true;
|
||||
}
|
||||
MenuOption::Stats => {
|
||||
stats.write(ToggleStatsRequestEvent);
|
||||
}
|
||||
@@ -472,14 +589,6 @@ pub(super) fn handle_menu_option_click(
|
||||
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
|
||||
@@ -613,4 +722,3 @@ pub(super) fn toggle_hud_on_tap(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,14 +12,15 @@ use bevy::window::WindowResized;
|
||||
mod fx;
|
||||
mod interaction;
|
||||
mod spawn;
|
||||
pub(crate) mod tab_bar;
|
||||
mod updates;
|
||||
|
||||
pub use fx::*;
|
||||
use interaction::*;
|
||||
use spawn::*;
|
||||
use tab_bar::*;
|
||||
use updates::*;
|
||||
|
||||
|
||||
// On wasm32 AvatarPlugin is gated out; define a placeholder type so the
|
||||
// Option<Res<AvatarResource>> parameters below compile without changes.
|
||||
// The resource is never inserted on wasm, so every call resolves to None.
|
||||
@@ -29,11 +30,11 @@ struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
|
||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||
use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||
use crate::events::{
|
||||
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
|
||||
DrawRequestEvent, HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
|
||||
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
|
||||
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent,
|
||||
ToggleProfileRequestEvent, ToggleSettingsRequestEvent, ToggleStatsRequestEvent,
|
||||
UndoRequestEvent, WinStreakMilestoneEvent,
|
||||
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleHomeRequestEvent,
|
||||
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
|
||||
ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent,
|
||||
};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::game_plugin::{GameMutation, NewGameRequestWriters};
|
||||
@@ -314,6 +315,18 @@ pub struct UndoButton;
|
||||
#[derive(Component, Debug)]
|
||||
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`],
|
||||
/// mirroring the `F1` keyboard accelerator.
|
||||
#[derive(Component, Debug)]
|
||||
@@ -324,31 +337,27 @@ pub struct HelpButton;
|
||||
#[derive(Component, Debug)]
|
||||
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
|
||||
/// controls reference so both always agree.
|
||||
#[cfg(target_os = "android")]
|
||||
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")]
|
||||
const ACTION_BAR_LABELS: [&str; 7] = [
|
||||
"Menu",
|
||||
"Undo",
|
||||
"Pause",
|
||||
"Help",
|
||||
ANDROID_HINT_LABEL,
|
||||
"Mode",
|
||||
"New",
|
||||
];
|
||||
const TOUCH_HINT_LABEL: &str = ANDROID_HINT_LABEL;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
const ACTION_BAR_LABELS: [&str; 7] = [
|
||||
"Menu \u{2193}",
|
||||
"Undo",
|
||||
"Pause",
|
||||
"Help",
|
||||
"Hint",
|
||||
"Modes \u{2193}",
|
||||
"New Game",
|
||||
];
|
||||
const TOUCH_HINT_LABEL: &str = "Hint";
|
||||
#[cfg(target_os = "android")]
|
||||
const ACTION_BAR_COLUMN_GAP: Val = Val::Px(4.0);
|
||||
#[cfg(not(target_os = "android"))]
|
||||
@@ -387,8 +396,9 @@ pub enum ModeOption {
|
||||
}
|
||||
|
||||
/// Marker on the "Menu" action button. Click toggles the [`MenuPopover`]
|
||||
/// which exposes the Stats / Achievements / Profile / Settings /
|
||||
/// Leaderboard overlays without needing the S/A/P/O/L hotkeys.
|
||||
/// which exposes the Home / Profile / Stats / Achievements /
|
||||
/// Leaderboard / Settings / Help overlays without needing the
|
||||
/// M/P/S/A/L/O/F1 hotkeys.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct MenuButton;
|
||||
|
||||
@@ -414,16 +424,18 @@ struct MenuPopoverBackdrop;
|
||||
struct ModesPopoverBackdrop;
|
||||
|
||||
/// One row inside the [`MenuPopover`]. The variant selects which
|
||||
/// `Toggle*RequestEvent` the click handler fires.
|
||||
/// `Toggle*RequestEvent` the click handler fires. Rows render grouped
|
||||
/// under section headers (Play · You · Community · System); mode
|
||||
/// selection lives on Home, so there is no Modes row here.
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub enum MenuOption {
|
||||
Help,
|
||||
Modes,
|
||||
Home,
|
||||
Profile,
|
||||
Stats,
|
||||
Achievements,
|
||||
Profile,
|
||||
Settings,
|
||||
Leaderboard,
|
||||
Settings,
|
||||
Help,
|
||||
}
|
||||
|
||||
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
|
||||
@@ -453,6 +465,7 @@ impl Plugin for HudPlugin {
|
||||
// idempotent.
|
||||
app.add_message::<NewGameRequestEvent>()
|
||||
.add_message::<UndoRequestEvent>()
|
||||
.add_message::<DrawRequestEvent>()
|
||||
.add_message::<PauseRequestEvent>()
|
||||
.add_message::<HelpRequestEvent>()
|
||||
.add_message::<StartZenRequestEvent>()
|
||||
@@ -460,6 +473,7 @@ impl Plugin for HudPlugin {
|
||||
.add_message::<StartTimeAttackRequestEvent>()
|
||||
.add_message::<StartDailyChallengeRequestEvent>()
|
||||
.add_message::<ToggleStatsRequestEvent>()
|
||||
.add_message::<ToggleHomeRequestEvent>()
|
||||
.add_message::<ToggleAchievementsRequestEvent>()
|
||||
.add_message::<ToggleProfileRequestEvent>()
|
||||
.add_message::<ToggleSettingsRequestEvent>()
|
||||
@@ -468,6 +482,7 @@ impl Plugin for HudPlugin {
|
||||
.init_resource::<PreviousScore>()
|
||||
.init_resource::<HudActionFade>()
|
||||
.init_resource::<HudVisibility>()
|
||||
.init_resource::<UndoHoldState>()
|
||||
// Escape-close handlers for popovers read this; init defensively
|
||||
// so HudPlugin works under MinimalPlugins in tests.
|
||||
.init_resource::<ButtonInput<KeyCode>>()
|
||||
@@ -557,6 +572,14 @@ impl Plugin for HudPlugin {
|
||||
.in_set(crate::game_plugin::UndoRequestWriters)
|
||||
.ambiguous_with(crate::game_plugin::UndoRequestWriters)
|
||||
.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_help_button,
|
||||
handle_hint_button
|
||||
@@ -574,6 +597,12 @@ impl Plugin for HudPlugin {
|
||||
handle_menu_backdrop_click,
|
||||
close_menu_popover_on_escape,
|
||||
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),
|
||||
)
|
||||
.chain()
|
||||
.in_set(HudButtons)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::avatar_plugin::AvatarResource;
|
||||
|
||||
@@ -292,7 +291,9 @@ pub(super) fn spawn_avatar_child(
|
||||
const SIZE: f32 = 32.0;
|
||||
if let Some(handle) = avatar.and_then(|a| a.0.clone()) {
|
||||
// Logged-in with a downloaded avatar: keep the accent disc behind it.
|
||||
commands.entity(parent).insert(BackgroundColor(ACCENT_PRIMARY));
|
||||
commands
|
||||
.entity(parent)
|
||||
.insert(BackgroundColor(ACCENT_PRIMARY));
|
||||
// Image fills the circle container; border_radius clips it to a disc.
|
||||
commands.entity(parent).with_children(|b| {
|
||||
b.spawn((
|
||||
@@ -359,6 +360,7 @@ pub(super) fn handle_avatar_button(
|
||||
pub(super) fn spawn_action_buttons(
|
||||
font_res: Option<Res<FontResource>>,
|
||||
windows: Query<&Window>,
|
||||
images: Option<ResMut<Assets<Image>>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let action_font_size =
|
||||
@@ -369,9 +371,18 @@ pub(super) fn spawn_action_buttons(
|
||||
..default()
|
||||
};
|
||||
|
||||
// Bottom bar: full-width, centered, sits above the gesture-navigation zone.
|
||||
// Touch layout: the floating glass tab bar (pill + circular Menu).
|
||||
// `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
|
||||
// Android reports it (frames 1-3); initial value is 0.0.
|
||||
// the platform reports it; initial value is 0.0.
|
||||
commands
|
||||
.spawn((
|
||||
Node {
|
||||
@@ -399,83 +410,89 @@ pub(super) fn spawn_action_buttons(
|
||||
HudActionBar,
|
||||
))
|
||||
.with_children(|row| {
|
||||
// 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,
|
||||
);
|
||||
spawn_desktop_action_bar(row, &font);
|
||||
});
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// the same node geometry, idle colour, and `ActionButton` marker so
|
||||
/// `paint_action_buttons` can recolour all of them with one query.
|
||||
@@ -509,13 +526,34 @@ pub(super) fn spawn_action_button<M: Component>(
|
||||
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 {
|
||||
font: font.font.clone(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
let (pad, min_w, min_h) = action_button_metrics();
|
||||
|
||||
row.spawn((
|
||||
marker,
|
||||
ActionButton,
|
||||
@@ -549,4 +587,3 @@ pub(super) fn spawn_action_button<M: Component>(
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
//! 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;
|
||||
|
||||
/// 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;
|
||||
|
||||
/// 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,
|
||||
))
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -40,9 +40,16 @@ fn read_hud_text<M: Component>(app: &mut App) -> String {
|
||||
#[test]
|
||||
fn score_reflects_game_state() {
|
||||
let mut app = headless_app();
|
||||
let score = app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(20);
|
||||
let score = app
|
||||
.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.force_test_score(20);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudScore>(&mut app), format!("Score: {score}"));
|
||||
assert_eq!(
|
||||
read_hud_text::<HudScore>(&mut app),
|
||||
format!("Score: {score}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -192,7 +199,9 @@ fn challenge_time_color_zero_is_danger() {
|
||||
fn challenge_hud_empty_when_no_daily_resource() {
|
||||
// No DailyChallengeResource inserted → HudChallenge must be empty.
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.set_changed();
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
||||
}
|
||||
@@ -207,7 +216,9 @@ fn challenge_hud_shows_time_limit_when_resource_present() {
|
||||
target_score: None,
|
||||
max_time_secs: Some(300),
|
||||
});
|
||||
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.set_changed();
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00");
|
||||
}
|
||||
@@ -222,7 +233,9 @@ fn challenge_hud_shows_score_goal_when_resource_present() {
|
||||
target_score: Some(4000),
|
||||
max_time_secs: None,
|
||||
});
|
||||
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.set_changed();
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts");
|
||||
}
|
||||
@@ -238,7 +251,10 @@ fn challenge_hud_clears_on_win() {
|
||||
max_time_secs: Some(300),
|
||||
});
|
||||
// Mark the game as won — HudChallenge should be empty.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.set_test_won(true);
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.set_test_won(true);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
||||
}
|
||||
@@ -384,7 +400,10 @@ fn score_increase_above_threshold_spawns_floater_in_accent_primary() {
|
||||
set_manual_time_step(&mut app, 0.0);
|
||||
// Initial state has score=0; bumping by 50 (the threshold)
|
||||
// is the smallest jump that triggers the floater.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.force_test_score(50);
|
||||
app.update();
|
||||
|
||||
// One floater should now exist.
|
||||
@@ -405,7 +424,10 @@ fn score_increase_above_threshold_spawns_floater_in_accent_primary() {
|
||||
#[test]
|
||||
fn score_floater_despawns_after_full_lifetime() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.force_test_score(50);
|
||||
app.update();
|
||||
assert_eq!(count_with::<ScoreFloater>(&mut app), 1);
|
||||
|
||||
@@ -431,7 +453,10 @@ fn score_increase_below_threshold_does_not_spawn_floater() {
|
||||
let mut app = headless_app();
|
||||
// +5 mirrors a single tableau-to-foundation move; well below
|
||||
// the 50-point threshold so the floater path stays dormant.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(5);
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.force_test_score(5);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
count_with::<ScoreFloater>(&mut app),
|
||||
@@ -507,7 +532,10 @@ fn score_change_skips_pulse_and_floater_under_reduce_motion() {
|
||||
..Settings::default()
|
||||
}));
|
||||
// +100 would normally create both a ScorePulse and a ScoreFloater.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.force_test_score(50);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
count_with::<ScorePulse>(&mut app),
|
||||
@@ -642,7 +670,7 @@ fn hud_elements_carry_expected_tooltip_strings() {
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<UndoButton>(&mut app),
|
||||
"Take back your last move. Costs points and blocks No Undo."
|
||||
"Take back your last move. Hold to keep undoing. Costs points and blocks No Undo."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<PauseButton>(&mut app),
|
||||
@@ -758,8 +786,8 @@ fn popover_rows_carry_tooltip_strings() {
|
||||
menu_tooltips.len()
|
||||
);
|
||||
for expected in [
|
||||
"Pick a mode, continue, or start a new game.",
|
||||
"Show controls, rules, and keyboard shortcuts.",
|
||||
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
||||
"Lifetime totals: wins, streaks, fastest time, best score.",
|
||||
"Browse unlocked achievements and the rewards still ahead.",
|
||||
"Your level, XP progress, and sync status.",
|
||||
@@ -871,3 +899,237 @@ fn hud_focus_only_engages_when_button_hovered() {
|
||||
"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);
|
||||
}
|
||||
|
||||
/// 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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
use super::*;
|
||||
|
||||
use bevy::window::WindowResized;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::Suit;
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
|
||||
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) {
|
||||
if USE_TOUCH_UI_LAYOUT {
|
||||
// Tight 3 px horizontal padding (down from 4) trims 14 px off the row
|
||||
// total across 7 buttons, and a 44 px min_width (down from 52) lets the
|
||||
// shortest labels ("New", "Help") shrink to their text rather than
|
||||
// padding the row out past the 900 logical-px viewport. min_height
|
||||
// stays at 44 px to preserve the comfortable touch target.
|
||||
// Tight 3 px horizontal padding keeps the compact buttons narrow;
|
||||
// Phase F trimmed the touch bar to 5 buttons (2 compact + 3
|
||||
// primary), so width pressure is lower than the old 7-button row.
|
||||
// min_height stays at 44 px to preserve the comfortable touch
|
||||
// target on the compact buttons.
|
||||
(
|
||||
UiRect::axes(Val::Px(3.0), Val::Px(4.0)),
|
||||
Val::Px(44.0),
|
||||
@@ -609,4 +609,3 @@ pub(super) fn resize_action_bar_labels(
|
||||
font.font_size = new_size;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,15 +22,15 @@ use std::collections::HashMap;
|
||||
use bevy::ecs::system::SystemParam;
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::input::touch::{TouchInput, TouchPhase, Touches};
|
||||
use bevy::math::{Vec2, Vec3};
|
||||
use bevy::math::Vec2;
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
use bevy::window::{MonitorSelection, WindowMode};
|
||||
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
||||
use solitaire_core::{FOUNDATIONS, TABLEAUS};
|
||||
use solitaire_core::{Card, Suit};
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::{Card, Suit};
|
||||
use solitaire_core::{FOUNDATIONS, TABLEAUS};
|
||||
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
||||
|
||||
use crate::auto_complete_plugin::AutoCompleteState;
|
||||
use crate::card_animation::tuning::AnimationTuning;
|
||||
@@ -136,11 +136,22 @@ impl Plugin for InputPlugin {
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
handle_keyboard_core,
|
||||
// The three `DrawRequestEvent` writers join the
|
||||
// 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),
|
||||
handle_keyboard_hint,
|
||||
handle_keyboard_forfeit,
|
||||
handle_stock_click,
|
||||
handle_touch_stock_tap,
|
||||
handle_stock_click
|
||||
.in_set(crate::game_plugin::DrawRequestWriters)
|
||||
.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,
|
||||
// Mouse drag pipeline.
|
||||
start_drag,
|
||||
@@ -204,6 +215,7 @@ fn handle_keyboard_core(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
drag: Res<DragState>,
|
||||
mut ev: CoreKeyboardMessages<'_>,
|
||||
mut time_attack: Option<ResMut<TimeAttackResource>>,
|
||||
selection: Option<Res<SelectionState>>,
|
||||
@@ -216,6 +228,19 @@ fn handle_keyboard_core(
|
||||
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
|
||||
// are suppressed. The replay overlay owns Space (pause/resume) and the
|
||||
// arrow keys (step). Letting game input through would mutate
|
||||
@@ -394,7 +419,10 @@ pub fn emit_hint_visuals(
|
||||
|
||||
// Find the top face-up card in the source pile and highlight it.
|
||||
let source_cards = pile_cards(game, from);
|
||||
let top_card = source_cards.last().filter(|(_, face_up)| *face_up).map(|(c, _)| c.clone());
|
||||
let top_card = source_cards
|
||||
.last()
|
||||
.filter(|(_, face_up)| *face_up)
|
||||
.map(|(c, _)| c.clone());
|
||||
if let Some(card) = top_card {
|
||||
for (entity, card_entity, mut sprite) in card_entities.iter_mut() {
|
||||
if card_entity.card == card {
|
||||
@@ -459,11 +487,19 @@ pub fn emit_hint_visuals(
|
||||
fn handle_keyboard_forfeit(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
drag: Res<DragState>,
|
||||
mut requests: MessageWriter<ForfeitRequestEvent>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
@@ -831,9 +867,7 @@ fn end_drag(
|
||||
let origin_cards = pile_cards(&game.0, &origin);
|
||||
if !origin_cards.is_empty() {
|
||||
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;
|
||||
};
|
||||
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
|
||||
@@ -1070,8 +1104,7 @@ fn touch_end_drag(
|
||||
let origin_cards = pile_cards(&game.0, &origin);
|
||||
if !origin_cards.is_empty() {
|
||||
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 {
|
||||
continue;
|
||||
};
|
||||
@@ -1174,7 +1207,8 @@ fn card_position(
|
||||
y_offset -= layout.card_size.y * step;
|
||||
}
|
||||
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
|
||||
// card_plugin::card_positions(). Hit-testing uses the same `waste_fan_step`
|
||||
// so clicking the visually rightmost (top) card actually registers — a
|
||||
@@ -1248,7 +1282,10 @@ fn find_draggable_at(
|
||||
}
|
||||
(i, i + 1)
|
||||
};
|
||||
let cards: Vec<Card> = pile_cards[start..end].iter().map(|(c, _)| c.clone()).collect();
|
||||
let cards: Vec<Card> = pile_cards[start..end]
|
||||
.iter()
|
||||
.map(|(c, _)| c.clone())
|
||||
.collect();
|
||||
return Some((pile, start, cards));
|
||||
}
|
||||
}
|
||||
@@ -1366,17 +1403,37 @@ pub fn best_tableau_destination_for_stack(
|
||||
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`
|
||||
/// to the best legal destination.
|
||||
///
|
||||
/// Move priority:
|
||||
/// 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.
|
||||
/// The move covers exactly the face-up run headed by the clicked card —
|
||||
/// see [`auto_move_for_run`].
|
||||
///
|
||||
/// When a multi-card stack double-click finds no legal destination (Priority 2
|
||||
/// returns `None`), fires `MoveRejectedEvent` with `from == to == pile` so the
|
||||
/// invalid-move sound plays and the source pile cards shake as feedback.
|
||||
/// When the clicked run has no legal destination, fires `MoveRejectedEvent`
|
||||
/// with `from == to == pile` so the invalid-move sound plays and the source
|
||||
/// pile cards shake as feedback.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_double_click(
|
||||
buttons: Res<ButtonInput<MouseButton>>,
|
||||
@@ -1407,7 +1464,11 @@ fn handle_double_click(
|
||||
return;
|
||||
};
|
||||
|
||||
// The topmost card in the draggable run — used as the double-click key.
|
||||
// The clicked card heads the run and keys the double-click: two clicks
|
||||
// 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 {
|
||||
return;
|
||||
};
|
||||
@@ -1422,31 +1483,15 @@ fn handle_double_click(
|
||||
|
||||
let now = time.elapsed_secs();
|
||||
let prev = last_click
|
||||
.get(top_card)
|
||||
.get(clicked_card)
|
||||
.copied()
|
||||
.unwrap_or(f32::NEG_INFINITY);
|
||||
|
||||
if now - prev <= DOUBLE_CLICK_WINDOW {
|
||||
// Double-click confirmed.
|
||||
last_click.remove(top_card);
|
||||
last_click.remove(clicked_card);
|
||||
|
||||
// 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())
|
||||
if let Some((dest, count)) = auto_move_for_run(clicked_card, &pile, &game.0, card_ids.len())
|
||||
{
|
||||
moves.write(MoveRequestEvent {
|
||||
from: pile,
|
||||
@@ -1456,14 +1501,10 @@ fn handle_double_click(
|
||||
return;
|
||||
}
|
||||
|
||||
// Both priorities failed — play the invalid-move sound and shake
|
||||
// the source pile as feedback. `MoveRejectedEvent` with
|
||||
// `from == to` routes the shake to the source pile (which
|
||||
// `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.
|
||||
// No legal destination for the clicked run — play the invalid-move
|
||||
// sound and shake the source pile as feedback. `MoveRejectedEvent`
|
||||
// with `from == to` routes the shake to the source pile (which
|
||||
// `start_shake_anim` reads from `ev.to`).
|
||||
rejected.write(MoveRejectedEvent {
|
||||
from: pile,
|
||||
to: pile,
|
||||
@@ -1471,7 +1512,7 @@ fn handle_double_click(
|
||||
});
|
||||
} else {
|
||||
// Single click — record the time.
|
||||
last_click.insert(top_card.clone(), now);
|
||||
last_click.insert(clicked_card.clone(), now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1487,10 +1528,9 @@ fn handle_double_click(
|
||||
/// `cards`, and `origin_pile`; once `touch_end_drag` fires those fields
|
||||
/// are cleared and the tap/drag distinction is permanently lost.
|
||||
///
|
||||
/// Move priority:
|
||||
/// 1. Single top card to its best foundation (or tableau).
|
||||
/// 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.
|
||||
/// The move covers exactly the face-up run headed by the tapped card —
|
||||
/// see [`auto_move_for_run`]. Fires `MoveRejectedEvent` for audio + shake
|
||||
/// feedback when the tapped run has no legal destination.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_double_tap(
|
||||
mut touch_events: MessageReader<TouchInput>,
|
||||
@@ -1550,9 +1590,7 @@ fn handle_double_tap(
|
||||
return;
|
||||
}
|
||||
|
||||
let Some((found_card, found_face_up)) =
|
||||
pile_cards.iter().find(|(c, _)| c == top_card)
|
||||
else {
|
||||
let Some((_, found_face_up)) = pile_cards.iter().find(|(c, _)| c == top_card) else {
|
||||
return;
|
||||
};
|
||||
if !*found_face_up {
|
||||
@@ -1588,53 +1626,27 @@ fn handle_double_tap(
|
||||
|
||||
// --- One-tap auto-move (original behaviour) ---
|
||||
|
||||
// Priority 1: move single top card.
|
||||
if let Some(dest) = best_destination(found_card, &game.0) {
|
||||
// Move exactly the run headed by the tapped card.
|
||||
if let Some(tapped_card) = drag.cards.first()
|
||||
&& 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() {
|
||||
if ce.card == *top_card {
|
||||
if drag.cards.contains(&ce.card) {
|
||||
sprite.color = STATE_SUCCESS;
|
||||
commands.entity(entity).insert(HintHighlight {
|
||||
remaining: DOUBLE_TAP_FLASH_SECS,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
moves.write(MoveRequestEvent {
|
||||
from: *tapped_pile,
|
||||
to: dest,
|
||||
count: 1,
|
||||
count,
|
||||
});
|
||||
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 {
|
||||
from: *tapped_pile,
|
||||
to: *tapped_pile,
|
||||
@@ -1783,8 +1795,6 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const fn tableau_number(tableau: Tableau) -> u8 {
|
||||
match tableau {
|
||||
Tableau::Tableau1 => 1,
|
||||
@@ -1805,10 +1815,5 @@ pub fn find_hint(game: &GameState) -> Option<(KlondikePile, KlondikePile)> {
|
||||
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)]
|
||||
mod tests;
|
||||
|
||||
@@ -89,9 +89,11 @@ fn find_draggable_picks_waste_top_with_multiple_cards() {
|
||||
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
clear_test_piles(&mut game);
|
||||
let waste = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
|
||||
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
|
||||
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine)];
|
||||
let waste = vec![
|
||||
Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
|
||||
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
|
||||
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine),
|
||||
];
|
||||
game.set_test_waste_cards(waste.clone());
|
||||
|
||||
let top_index = waste.len() - 1; // 2 = the visible top
|
||||
@@ -99,7 +101,11 @@ fn find_draggable_picks_waste_top_with_multiple_cards() {
|
||||
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.1, top_index, "picks the top index, not the buffer");
|
||||
assert_eq!(result.2, vec![waste[top_index].clone()], "drags the top card only");
|
||||
assert_eq!(
|
||||
result.2,
|
||||
vec![waste[top_index].clone()],
|
||||
"drags the top card only"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -176,8 +182,7 @@ fn find_draggable_skips_face_down_cards() {
|
||||
// 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
|
||||
// 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);
|
||||
assert!(result.is_none(), "face-down cards should not be draggable");
|
||||
}
|
||||
@@ -195,8 +200,7 @@ 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
|
||||
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
|
||||
// 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)
|
||||
.expect("clicking the face-up card's visible centre must initiate a drag");
|
||||
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
|
||||
@@ -213,18 +217,14 @@ fn find_draggable_returns_run_when_picking_mid_stack() {
|
||||
let king = Card::new(D::Deck1, Suit::Spades, Rank::King);
|
||||
let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen);
|
||||
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);
|
||||
// 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
|
||||
// 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.
|
||||
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 (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
||||
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
|
||||
@@ -322,7 +322,11 @@ fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() {
|
||||
);
|
||||
let (pile, _start, ids) = result.unwrap();
|
||||
assert_eq!(pile, KlondikePile::Stock);
|
||||
assert_eq!(ids, vec![four_clubs], "only the top card is draggable from waste");
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec![four_clubs],
|
||||
"only the top card is draggable from waste"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -425,6 +429,118 @@ 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
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -558,8 +674,7 @@ fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
|
||||
fn rejected_drag_animation_targets_origin_resting_position() {
|
||||
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 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!(
|
||||
(anim.end - target_pos).length() < 1e-6,
|
||||
@@ -577,8 +692,7 @@ fn rejected_drag_animation_targets_origin_resting_position() {
|
||||
fn rejected_drag_animation_starts_from_drag_position() {
|
||||
let drag_pos = Vec2::new(640.0, 200.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!(
|
||||
(anim.start - drag_pos).length() < 1e-6,
|
||||
@@ -601,12 +715,8 @@ fn rejected_drag_animation_starts_from_drag_position() {
|
||||
/// the call site honest.
|
||||
#[test]
|
||||
fn rejected_drag_animation_uses_correct_duration() {
|
||||
let anim = build_drag_reject_animation(
|
||||
Vec2::new(640.0, 200.0),
|
||||
DRAG_Z,
|
||||
Vec2::new(80.0, -120.0),
|
||||
0,
|
||||
);
|
||||
let anim =
|
||||
build_drag_reject_animation(Vec2::new(640.0, 200.0), DRAG_Z, Vec2::new(80.0, -120.0), 0);
|
||||
assert!(
|
||||
(anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6,
|
||||
"drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \
|
||||
@@ -620,12 +730,8 @@ fn rejected_drag_animation_uses_correct_duration() {
|
||||
/// jittery rather than forgiving.
|
||||
#[test]
|
||||
fn rejected_drag_animation_uses_responsive_curve() {
|
||||
let anim = build_drag_reject_animation(
|
||||
Vec2::new(640.0, 200.0),
|
||||
DRAG_Z,
|
||||
Vec2::new(80.0, -120.0),
|
||||
0,
|
||||
);
|
||||
let anim =
|
||||
build_drag_reject_animation(Vec2::new(640.0, 200.0), DRAG_Z, Vec2::new(80.0, -120.0), 0);
|
||||
assert_eq!(
|
||||
anim.curve,
|
||||
MotionCurve::Responsive,
|
||||
@@ -683,10 +789,16 @@ fn pressing_h_spawns_pending_hint_task() {
|
||||
app.init_resource::<HintSolverConfig>();
|
||||
app.init_resource::<crate::pending_hint::PendingHintTask>();
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.insert_resource(LayoutResource(
|
||||
compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true),
|
||||
));
|
||||
app.insert_resource(GameStateResource(GameState::new(42, DrawStockConfig::DrawOne)));
|
||||
app.insert_resource(LayoutResource(compute_layout(
|
||||
Vec2::new(1280.0, 800.0),
|
||||
0.0,
|
||||
0.0,
|
||||
true,
|
||||
)));
|
||||
app.insert_resource(GameStateResource(GameState::new(
|
||||
42,
|
||||
DrawStockConfig::DrawOne,
|
||||
)));
|
||||
app.add_systems(Update, handle_keyboard_hint);
|
||||
|
||||
// Simulate the H key being pressed this frame.
|
||||
@@ -705,3 +817,123 @@ fn pressing_h_spawns_pending_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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,13 +24,13 @@ use crate::font_plugin::FontResource;
|
||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||
use crate::sync_plugin::SyncProviderResource;
|
||||
use crate::ui_modal::{
|
||||
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
||||
spawn_modal_button, spawn_modal_header,
|
||||
ButtonVariant, ModalScrim, ScrimDismissible, spawn_empty_state, spawn_modal,
|
||||
spawn_modal_actions, spawn_modal_button, spawn_modal_header,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, TEXT_DISABLED, TEXT_PRIMARY,
|
||||
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
|
||||
Z_MODAL_PANEL, Z_PAUSE_DIALOG,
|
||||
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, TEXT_DISABLED, TEXT_PRIMARY,
|
||||
TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
|
||||
Z_PAUSE_DIALOG,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -343,13 +343,21 @@ 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(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
close_buttons: Query<&Interaction, (With<LeaderboardCloseButton>, Changed<Interaction>)>,
|
||||
screens: Query<Entity, With<LeaderboardScreen>>,
|
||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<LeaderboardScreen>)>,
|
||||
mut closed_flag: ResMut<ClosedThisFrame>,
|
||||
) {
|
||||
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||
let clicked = 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;
|
||||
}
|
||||
for entity in &screens {
|
||||
@@ -528,11 +536,6 @@ fn spawn_leaderboard_screen(
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
let font_status = TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_BODY_LG,
|
||||
..default()
|
||||
};
|
||||
let font_row = TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_BODY,
|
||||
@@ -639,30 +642,31 @@ fn spawn_leaderboard_screen(
|
||||
.with_children(|body| {
|
||||
match data {
|
||||
LeaderboardResource::Idle => {
|
||||
body.spawn((
|
||||
Text::new("Fetching\u{2026}"),
|
||||
font_status.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
spawn_empty_state(
|
||||
body,
|
||||
"\u{21BB}",
|
||||
"Fetching the leaderboard\u{2026}",
|
||||
None,
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
LeaderboardResource::Error(_) => {
|
||||
body.spawn((
|
||||
Text::new("Couldn't reach the leaderboard. Try again later."),
|
||||
font_status.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
spawn_empty_state(
|
||||
body,
|
||||
"!",
|
||||
"Couldn't reach the leaderboard.",
|
||||
Some("Try again later."),
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
LeaderboardResource::Loaded(rows) if rows.is_empty() => {
|
||||
body.spawn((
|
||||
Text::new("Be the first on the leaderboard."),
|
||||
font_status.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
body.spawn((
|
||||
Text::new("Win a game and opt in to appear here."),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
spawn_empty_state(
|
||||
body,
|
||||
"\u{2660}",
|
||||
"Be the first on the leaderboard.",
|
||||
Some("Win a game and opt in to appear here."),
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
LeaderboardResource::Loaded(rows) => {
|
||||
// Column headers
|
||||
@@ -862,10 +866,8 @@ fn handle_display_name_confirm(
|
||||
.leaderboard_display_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| {
|
||||
if let SyncBackend::SolitaireServer {
|
||||
ref username,
|
||||
..
|
||||
} = settings.0.sync_backend
|
||||
if let SyncBackend::SolitaireServer { ref username, .. } =
|
||||
settings.0.sync_backend
|
||||
{
|
||||
username.chars().take(32).collect()
|
||||
} else {
|
||||
@@ -890,12 +892,18 @@ fn handle_display_name_confirm(
|
||||
}
|
||||
|
||||
/// 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(
|
||||
button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameCancelButton>)>,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
screens: Query<Entity, With<DisplayNameModal>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
if !button_q.iter().any(|i| *i == Interaction::Pressed) {
|
||||
let clicked = button_q.iter().any(|i| *i == Interaction::Pressed);
|
||||
let esc = keys.just_pressed(KeyCode::Escape) && !screens.is_empty();
|
||||
if !clicked && !esc {
|
||||
return;
|
||||
}
|
||||
for entity in &screens {
|
||||
|
||||
@@ -15,6 +15,8 @@ pub mod avatar_plugin;
|
||||
pub mod card_animation;
|
||||
pub mod card_plugin;
|
||||
pub mod challenge_plugin;
|
||||
pub mod cheat_sheet_plugin;
|
||||
pub mod contextual_tips_plugin;
|
||||
pub mod core_game_plugin;
|
||||
pub mod cursor_plugin;
|
||||
pub mod daily_challenge_plugin;
|
||||
@@ -26,6 +28,7 @@ pub mod font_plugin;
|
||||
pub mod game_plugin;
|
||||
pub mod help_plugin;
|
||||
pub mod home_plugin;
|
||||
pub mod hotkeys;
|
||||
pub mod hud_plugin;
|
||||
pub mod input_plugin;
|
||||
pub mod layout;
|
||||
@@ -46,6 +49,7 @@ pub mod safe_area;
|
||||
mod schedule_checks;
|
||||
pub mod selection_plugin;
|
||||
pub mod settings_plugin;
|
||||
pub mod solution_playback_plugin;
|
||||
pub mod splash_plugin;
|
||||
pub mod stats_plugin;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -54,14 +58,19 @@ pub mod sync_plugin;
|
||||
pub mod sync_setup_plugin;
|
||||
pub mod table_plugin;
|
||||
pub mod theme;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod theme_store_plugin;
|
||||
pub mod time_attack_plugin;
|
||||
pub mod touch_selection_plugin;
|
||||
pub mod ui_focus;
|
||||
pub mod ui_glass;
|
||||
pub mod ui_modal;
|
||||
pub mod ui_theme;
|
||||
pub mod ui_tooltip;
|
||||
pub mod weekly_goals_plugin;
|
||||
pub mod whats_new_plugin;
|
||||
pub mod win_summary_plugin;
|
||||
pub mod you_hub_plugin;
|
||||
|
||||
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -79,9 +88,8 @@ pub use avatar_plugin::{AvatarFetchEvent, AvatarPlugin, AvatarResource};
|
||||
pub use card_animation::{
|
||||
AnimationChain, AnimationTuning, BufferedInput, CardAnimation, CardAnimationPlugin,
|
||||
DEAL_INTERVAL_SECS, DIAG_WINDOW_SIZE, FrameTimeDiagnostics, HoverState, InputBuffer,
|
||||
InputPlatform, MAX_DURATION_SECS, MIN_DURATION_SECS, MotionCurve, WIN_CASCADE_INTERVAL_SECS,
|
||||
WinCascadePlugin, cascade_delay, compute_duration, micro_vary, retarget_animation,
|
||||
sample_curve, win_scatter_targets,
|
||||
InputPlatform, MAX_DURATION_SECS, MIN_DURATION_SECS, MotionCurve, compute_duration, micro_vary,
|
||||
sample_curve,
|
||||
};
|
||||
pub use card_plugin::{
|
||||
CardEntity, CardImageSet, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer,
|
||||
@@ -90,6 +98,8 @@ pub use card_plugin::{
|
||||
pub use challenge_plugin::{
|
||||
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 cursor_plugin::CursorPlugin;
|
||||
pub use daily_challenge_plugin::{
|
||||
@@ -103,7 +113,7 @@ pub use events::{
|
||||
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||
NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
||||
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
|
||||
StartTimeAttackRequestEvent, StartZenRequestEvent, StateChangedEvent, SyncCompleteEvent,
|
||||
StartTimeAttackRequestEvent, StartZenRequestEvent, StateChangedEvent,
|
||||
ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent,
|
||||
ToggleSettingsRequestEvent, ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent,
|
||||
XpAwardedEvent,
|
||||
@@ -159,6 +169,7 @@ pub use settings_plugin::{
|
||||
SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS,
|
||||
};
|
||||
pub use solitaire_data::SyncProvider;
|
||||
pub use solution_playback_plugin::{SolutionPlayback, SolutionPlaybackPlugin, SolutionSolveTask};
|
||||
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
|
||||
pub use stats_plugin::{
|
||||
LatestReplayPath, ReplayHistoryResource, ReplayNextButton, ReplayPrevButton,
|
||||
@@ -176,6 +187,8 @@ pub use theme::{
|
||||
ActiveTheme, CardTheme, CardThemeLoader, ThemeEntry, ThemePlugin, ThemeRegistry,
|
||||
ThemeRegistryPlugin, set_theme,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use theme_store_plugin::{ThemeStorePlugin, ThemeStoreScreen};
|
||||
pub use time_attack_plugin::{
|
||||
TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource,
|
||||
};
|
||||
@@ -188,6 +201,8 @@ pub use ui_modal::{
|
||||
};
|
||||
pub use ui_tooltip::{Tooltip, UiTooltipPlugin};
|
||||
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
|
||||
pub use whats_new_plugin::{WhatsNewPending, WhatsNewPlugin, WhatsNewScreen};
|
||||
pub use win_summary_plugin::{
|
||||
ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin, format_win_time,
|
||||
};
|
||||
pub use you_hub_plugin::{ActiveYouTab, YouHubPlugin, YouHubScreen, YouTab};
|
||||
|
||||
@@ -27,6 +27,7 @@ use solitaire_data::{Settings, save_settings_to};
|
||||
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||
use crate::splash_plugin::SplashRoot;
|
||||
use crate::ui_modal::{
|
||||
ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
||||
spawn_modal_header,
|
||||
@@ -36,7 +37,6 @@ use crate::ui_theme::{
|
||||
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION,
|
||||
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
||||
};
|
||||
use crate::splash_plugin::SplashRoot;
|
||||
use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -84,66 +84,11 @@ struct OnboardingSkipButton;
|
||||
pub struct OnboardingSlideIndex(pub u8);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slide data — hotkey rows are taken verbatim from `help_plugin.rs` so the
|
||||
// two screens stay in sync without a shared abstraction.
|
||||
// Slide data — the hotkey slide renders the essential subset of the
|
||||
// shared table in `crate::hotkeys` (Phase J unified the previously
|
||||
// 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 1–5 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -313,6 +258,10 @@ fn complete_onboarding(
|
||||
despawn_screen(commands, screens);
|
||||
if let Some(s) = settings {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -382,20 +331,27 @@ 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.
|
||||
fn spawn_slide_how_to_play(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
|
||||
spawn_modal_header(card, "Drag cards to play", 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_body_text(card, HOW_TO_PLAY_BODY, TEXT_SECONDARY, font_res);
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
@@ -435,8 +391,10 @@ fn spawn_slide_hotkeys(commands: &mut Commands, font_res: Option<&FontResource>)
|
||||
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
|
||||
spawn_modal_header(card, "Keyboard shortcuts", font_res);
|
||||
|
||||
// Vertical list of `key — description` rows, same chip style as HelpScreen.
|
||||
for row in HOTKEYS {
|
||||
// Vertical list of `key — description` rows, same chip style as
|
||||
// HelpScreen. Essential subset only — the full table lives on
|
||||
// the hold-`/` cheat sheet.
|
||||
for row in crate::hotkeys::HOTKEYS.iter().filter(|r| r.essential) {
|
||||
card.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
@@ -815,22 +773,15 @@ mod tests {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Hotkey list is non-empty (guards against accidental truncation)
|
||||
// Hotkey slide renders a non-empty essential subset (the table's own
|
||||
// integrity tests live in `crate::hotkeys`)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn hotkey_list_is_non_empty() {
|
||||
assert!(!HOTKEYS.is_empty(), "HOTKEYS must not be empty");
|
||||
}
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
fn hotkey_slide_subset_is_non_empty() {
|
||||
assert!(
|
||||
crate::hotkeys::HOTKEYS.iter().any(|r| r.essential),
|
||||
"the onboarding slide needs at least one essential hotkey"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,12 @@ struct PauseResumeButton;
|
||||
#[derive(Component, Debug)]
|
||||
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.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ForfeitConfirmScreen;
|
||||
@@ -107,6 +113,7 @@ impl Plugin for PausePlugin {
|
||||
.add_message::<PauseRequestEvent>()
|
||||
.add_message::<ForfeitRequestEvent>()
|
||||
.add_message::<ForfeitEvent>()
|
||||
.add_message::<crate::events::ShowSolutionRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.init_resource::<PausedResource>()
|
||||
.add_systems(
|
||||
@@ -125,6 +132,7 @@ impl Plugin for PausePlugin {
|
||||
handle_pause_draw_buttons,
|
||||
handle_pause_resume_button,
|
||||
handle_pause_forfeit_button,
|
||||
handle_pause_solution_button,
|
||||
handle_forfeit_request,
|
||||
handle_forfeit_confirm_buttons,
|
||||
handle_forfeit_keyboard,
|
||||
@@ -304,6 +312,22 @@ 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
|
||||
/// `ForfeitRequestEvent` so `handle_forfeit_request` can spawn the
|
||||
/// confirm modal — same code path as the `G` accelerator.
|
||||
@@ -498,6 +522,14 @@ fn spawn_pause_screen(
|
||||
ButtonVariant::Tertiary,
|
||||
font_res,
|
||||
);
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
PauseSolutionButton,
|
||||
"Show solution",
|
||||
None,
|
||||
ButtonVariant::Secondary,
|
||||
font_res,
|
||||
);
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
PauseResumeButton,
|
||||
@@ -969,7 +1001,10 @@ mod tests {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
|
||||
app.insert_resource(GameStateResource(GameState::new(
|
||||
1,
|
||||
DrawStockConfig::DrawOne,
|
||||
)));
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
@@ -178,9 +178,9 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::events::HintVisualEvent;
|
||||
use crate::input_plugin::HintSolverConfig;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
|
||||
/// Build a minimal Bevy app exercising only the polling system
|
||||
/// and the resources/messages it touches.
|
||||
@@ -249,10 +249,7 @@ mod tests {
|
||||
.into_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
|
||||
}
|
||||
@@ -267,9 +264,11 @@ mod tests {
|
||||
let mut app = pending_hint_app();
|
||||
app.insert_resource(GameStateResource(near_finished_state()));
|
||||
let cfg = *app.world().resource::<HintSolverConfig>();
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingHintTask>()
|
||||
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
|
||||
app.world_mut().resource_mut::<PendingHintTask>().spawn(
|
||||
near_finished_state(),
|
||||
cfg.moves_budget,
|
||||
cfg.states_budget,
|
||||
);
|
||||
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
|
||||
while app.world().resource::<PendingHintTask>().is_pending() {
|
||||
@@ -306,9 +305,11 @@ mod tests {
|
||||
let mut app = pending_hint_app();
|
||||
app.insert_resource(GameStateResource(near_finished_state()));
|
||||
let cfg = *app.world().resource::<HintSolverConfig>();
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingHintTask>()
|
||||
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
|
||||
app.world_mut().resource_mut::<PendingHintTask>().spawn(
|
||||
near_finished_state(),
|
||||
cfg.moves_budget,
|
||||
cfg.states_budget,
|
||||
);
|
||||
assert!(
|
||||
app.world().resource::<PendingHintTask>().is_pending(),
|
||||
"task is in flight after spawn",
|
||||
@@ -344,18 +345,22 @@ mod tests {
|
||||
let cfg = *app.world().resource::<HintSolverConfig>();
|
||||
|
||||
// First spawn.
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingHintTask>()
|
||||
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
|
||||
app.world_mut().resource_mut::<PendingHintTask>().spawn(
|
||||
near_finished_state(),
|
||||
cfg.moves_budget,
|
||||
cfg.states_budget,
|
||||
);
|
||||
let first_handle_present = app.world().resource::<PendingHintTask>().is_pending();
|
||||
assert!(first_handle_present);
|
||||
|
||||
// Second spawn. The `spawn` helper drops the prior task
|
||||
// before assigning the new one — at no point are two tasks
|
||||
// in flight.
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingHintTask>()
|
||||
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
|
||||
app.world_mut().resource_mut::<PendingHintTask>().spawn(
|
||||
near_finished_state(),
|
||||
cfg.moves_budget,
|
||||
cfg.states_budget,
|
||||
);
|
||||
// Resource still pending (the second task), but the first
|
||||
// is gone. We can't directly observe the first handle once
|
||||
// it's been overwritten — what we *can* assert is that the
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
//! summary in a single scrollable panel. Spawned on the first `P` keypress and
|
||||
//! despawned on the second.
|
||||
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
use chrono::{Duration, Local, NaiveDate};
|
||||
@@ -13,23 +12,19 @@ use solitaire_data::SyncBackend;
|
||||
|
||||
use crate::achievement_plugin::AchievementsResource;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::avatar_plugin::AvatarResource;
|
||||
pub(crate) use crate::avatar_plugin::AvatarResource;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[derive(bevy::prelude::Resource)]
|
||||
struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
|
||||
pub(crate) struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
|
||||
use crate::events::ToggleProfileRequestEvent;
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::{SyncStatus, SyncStatusResource};
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
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::{
|
||||
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, Z_MODAL_PANEL,
|
||||
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2,
|
||||
};
|
||||
|
||||
/// Number of days surfaced in the daily-challenge calendar row.
|
||||
@@ -60,13 +55,11 @@ pub struct DailyCalendarDot {
|
||||
pub is_today: bool,
|
||||
}
|
||||
|
||||
/// Registers the `P` key toggle for the profile overlay.
|
||||
/// Registers the Profile body's scroll handling. Opening/closing moved
|
||||
/// to the You hub (`you_hub_plugin`), which calls
|
||||
/// [`spawn_profile_body`] for this tab's content.
|
||||
pub struct ProfilePlugin;
|
||||
|
||||
/// Marker on the "Done" button inside the Profile modal.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ProfileCloseButton;
|
||||
|
||||
/// Marker on the scrollable body Node inside the Profile modal.
|
||||
///
|
||||
/// The Profile panel renders sync info, progression (incl. 14-day
|
||||
@@ -87,14 +80,9 @@ impl Plugin for ProfilePlugin {
|
||||
// profile-scroll system also runs cleanly under
|
||||
// `MinimalPlugins` in tests.
|
||||
.add_message::<MouseWheel>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
toggle_profile_screen,
|
||||
handle_profile_close_button,
|
||||
scroll_profile_panel,
|
||||
),
|
||||
);
|
||||
// Open/close/tab handling moved to `you_hub_plugin`
|
||||
// (Phase E) — this plugin now owns body content + scroll.
|
||||
.add_systems(Update, scroll_profile_panel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,70 +112,12 @@ fn scroll_profile_panel(
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_profile_close_button(
|
||||
mut commands: Commands,
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the Profile tab body inside the You hub's card. All markers
|
||||
/// (`ProfileScrollable`, `DailyCalendarDot`, …) are unchanged, so the
|
||||
/// scroll and test queries keep working.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn toggle_profile_screen(
|
||||
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,
|
||||
pub(crate) fn spawn_profile_body(
|
||||
card: &mut ChildSpawnerCommands,
|
||||
settings: Option<&SettingsResource>,
|
||||
sync_status: Option<&SyncStatusResource>,
|
||||
progress: Option<&ProgressResource>,
|
||||
@@ -208,9 +138,7 @@ fn spawn_profile_screen(
|
||||
..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,
|
||||
// progression (incl. a 14-day calendar), every unlocked
|
||||
// achievement (up to ~18), and a stats summary, which can
|
||||
@@ -472,20 +400,7 @@ fn spawn_profile_screen(
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
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.
|
||||
@@ -627,6 +542,7 @@ mod tests {
|
||||
use crate::settings_plugin::SettingsPlugin;
|
||||
use crate::stats_plugin::StatsPlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
use crate::ui_modal::ModalScrim;
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
@@ -637,7 +553,8 @@ mod tests {
|
||||
.add_plugins(ProgressPlugin::headless())
|
||||
.add_plugins(AchievementPlugin::headless())
|
||||
.add_plugins(SettingsPlugin::headless())
|
||||
.add_plugins(ProfilePlugin);
|
||||
.add_plugins(ProfilePlugin)
|
||||
.add_plugins(crate::you_hub_plugin::YouHubPlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
|
||||
@@ -47,10 +47,10 @@ use bevy::input::touch::Touches;
|
||||
use bevy::math::Vec2;
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::{FOUNDATIONS, TABLEAUS};
|
||||
use solitaire_core::Card;
|
||||
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::events::{MoveRejectedEvent, MoveRequestEvent};
|
||||
@@ -360,9 +360,6 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/// Builds the `(destination, anchor)` list for a fresh radial open.
|
||||
///
|
||||
/// `half_extents` is the window half-size in world space — icons are clamped
|
||||
@@ -381,8 +378,10 @@ fn build_radial_destinations(
|
||||
.map(|(i, d)| {
|
||||
let raw = radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX);
|
||||
let clamped = Vec2::new(
|
||||
raw.x.clamp(-half_extents.x + margin, half_extents.x - margin),
|
||||
raw.y.clamp(-half_extents.y + margin, half_extents.y - margin),
|
||||
raw.x
|
||||
.clamp(-half_extents.x + margin, half_extents.x - margin),
|
||||
raw.y
|
||||
.clamp(-half_extents.y + margin, half_extents.y - margin),
|
||||
);
|
||||
(d, clamped)
|
||||
})
|
||||
|
||||
@@ -122,12 +122,8 @@ pub(crate) fn format_move_body(instruction: &KlondikeInstruction) -> String {
|
||||
/// `▌ MOVE LOG · COMPLETE` in `Completed`.
|
||||
pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String {
|
||||
match state {
|
||||
ReplayPlaybackState::Playing { replay, cursor, .. } => {
|
||||
format!(
|
||||
"\u{258C} MOVE LOG \u{00B7} {}/{}",
|
||||
cursor,
|
||||
replay.moves.len()
|
||||
)
|
||||
ReplayPlaybackState::Playing { moves, cursor, .. } => {
|
||||
format!("\u{258C} MOVE LOG \u{00B7} {}/{}", cursor, moves.len())
|
||||
}
|
||||
ReplayPlaybackState::Completed => "\u{258C} MOVE LOG \u{00B7} COMPLETE".to_string(),
|
||||
ReplayPlaybackState::Inactive => String::new(),
|
||||
@@ -135,7 +131,7 @@ pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String {
|
||||
}
|
||||
|
||||
/// Pure helper — formats the kth-most-recently-applied move's row
|
||||
/// text. `k = 1` is the active row (`replay.moves[cursor - 1]`,
|
||||
/// text. `k = 1` is the active row (`moves[cursor - 1]`,
|
||||
/// displayed as `"{cursor} │ {body}"`). `k = 2` is the row above
|
||||
/// that (`moves[cursor - 2]` displayed as `"{cursor - 1} │ {body}"`),
|
||||
/// and so on.
|
||||
@@ -147,14 +143,14 @@ pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String {
|
||||
/// for k=1 and k=2 only, k=3 returns empty).
|
||||
/// - The move list is shorter than expected (defensive guard).
|
||||
pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> String {
|
||||
let ReplayPlaybackState::Playing { replay, cursor, .. } = state else {
|
||||
let ReplayPlaybackState::Playing { moves, cursor, .. } = state else {
|
||||
return String::new();
|
||||
};
|
||||
if k == 0 || k > *cursor {
|
||||
return String::new();
|
||||
}
|
||||
let zero_idx = *cursor - k;
|
||||
let Some(m) = replay.moves.get(zero_idx) else {
|
||||
let Some(m) = moves.get(zero_idx) else {
|
||||
return String::new();
|
||||
};
|
||||
let display_idx = *cursor - k + 1;
|
||||
@@ -162,7 +158,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`
|
||||
/// is the move that will apply next (`replay.moves[cursor]`,
|
||||
/// is the move that will apply next (`moves[cursor]`,
|
||||
/// displayed as `cursor + 1`); `k = 2` is the move after that,
|
||||
/// and so on.
|
||||
///
|
||||
@@ -174,14 +170,14 @@ pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> St
|
||||
/// replay — late in the move list, the trailing next rows
|
||||
/// stay empty).
|
||||
pub(crate) fn format_kth_next_row(state: &ReplayPlaybackState, k: usize) -> String {
|
||||
let ReplayPlaybackState::Playing { replay, cursor, .. } = state else {
|
||||
let ReplayPlaybackState::Playing { moves, cursor, .. } = state else {
|
||||
return String::new();
|
||||
};
|
||||
if k == 0 {
|
||||
return String::new();
|
||||
}
|
||||
let zero_idx = *cursor + k - 1;
|
||||
let Some(m) = replay.moves.get(zero_idx) else {
|
||||
let Some(m) = moves.get(zero_idx) else {
|
||||
return String::new();
|
||||
};
|
||||
let display_idx = *cursor + k;
|
||||
@@ -246,7 +242,11 @@ pub(crate) fn format_suit_glyph(suit: Suit) -> &'static str {
|
||||
/// known card, or `"--"` for an absent top card (empty pile).
|
||||
pub(crate) fn format_card_short(card: Option<&(Card, bool)>) -> String {
|
||||
match card {
|
||||
Some((c, _)) => format!("{}{}", format_rank_short(c.rank()), format_suit_glyph(c.suit())),
|
||||
Some((c, _)) => format!(
|
||||
"{}{}",
|
||||
format_rank_short(c.rank()),
|
||||
format_suit_glyph(c.suit())
|
||||
),
|
||||
None => "--".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::*;
|
||||
@@ -27,960 +25,6 @@ pub(crate) struct ReplayScrubKeyHold {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -383,7 +383,7 @@ pub struct ReplayOverlayMoveLogPrevRow {
|
||||
/// 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`),
|
||||
/// (`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.
|
||||
///
|
||||
@@ -1258,11 +1258,11 @@ fn keybind_footer_hint_text() -> &'static str {
|
||||
/// `win_move_index >= total` (defensive — shouldn't happen) doesn't
|
||||
/// position the marker outside the track.
|
||||
fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option<f32> {
|
||||
let ReplayPlaybackState::Playing { replay, .. } = state else {
|
||||
let ReplayPlaybackState::Playing { replay, moves, .. } = state else {
|
||||
return None;
|
||||
};
|
||||
let idx = replay.win_move_index?;
|
||||
let total = replay.moves.len();
|
||||
let total = moves.len();
|
||||
if total == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::*;
|
||||
use chrono::NaiveDate;
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
||||
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, SessionRecording, Tableau};
|
||||
use solitaire_core::{Rank, Suit};
|
||||
use solitaire_data::Replay;
|
||||
|
||||
@@ -17,9 +17,11 @@ fn synthetic_replay(move_count: usize) -> Replay {
|
||||
120,
|
||||
1_000,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
|
||||
(0..move_count)
|
||||
.map(|_| KlondikeInstruction::RotateStock)
|
||||
.collect(),
|
||||
SessionRecording::from_instructions_unchecked(
|
||||
42,
|
||||
DrawStockConfig::DrawOne,
|
||||
(0..move_count).map(|_| KlondikeInstruction::RotateStock),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -93,12 +95,7 @@ fn overlay_spawns_when_playback_starts() {
|
||||
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
|
||||
@@ -117,12 +114,7 @@ fn overlay_progress_text_reflects_cursor() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 5,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
|
||||
@@ -138,12 +130,7 @@ fn overlay_stop_button_click_clears_playback() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(overlay_root_count(&mut app), 1);
|
||||
@@ -195,12 +182,7 @@ fn floating_chip_spawns_and_despawns_with_overlay() {
|
||||
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(5),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -232,12 +214,7 @@ fn overlay_despawns_when_playback_returns_to_inactive() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(3),
|
||||
cursor: 1,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(3), 1, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(overlay_root_count(&mut app), 1);
|
||||
@@ -260,12 +237,7 @@ fn overlay_text_changes_on_completed() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(7),
|
||||
cursor: 7,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(7), 7, 0.0, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(banner_text(&mut app), "\u{258C} replay");
|
||||
@@ -314,30 +286,30 @@ fn scrub_pct_covers_state_corners() {
|
||||
assert_eq!(scrub_pct(&ReplayPlaybackState::Inactive), 0.0);
|
||||
assert_eq!(scrub_pct(&ReplayPlaybackState::Completed), 100.0);
|
||||
assert_eq!(
|
||||
scrub_pct(&ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
}),
|
||||
scrub_pct(&ReplayPlaybackState::playing(
|
||||
synthetic_replay(10),
|
||||
0,
|
||||
0.5,
|
||||
false,
|
||||
)),
|
||||
0.0,
|
||||
);
|
||||
assert_eq!(
|
||||
scrub_pct(&ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 5,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
}),
|
||||
scrub_pct(&ReplayPlaybackState::playing(
|
||||
synthetic_replay(10),
|
||||
5,
|
||||
0.5,
|
||||
false,
|
||||
)),
|
||||
50.0,
|
||||
);
|
||||
assert_eq!(
|
||||
scrub_pct(&ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 10,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
}),
|
||||
scrub_pct(&ReplayPlaybackState::playing(
|
||||
synthetic_replay(10),
|
||||
10,
|
||||
0.5,
|
||||
false,
|
||||
)),
|
||||
100.0,
|
||||
);
|
||||
}
|
||||
@@ -367,12 +339,12 @@ fn format_game_caption_covers_state_corners() {
|
||||
// Mar = 31, Apr = 30, May 2 = 122). Synthetic_replay always
|
||||
// uses this date so the assertion is stable.
|
||||
assert_eq!(
|
||||
format_game_caption(&ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 5,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
}),
|
||||
format_game_caption(&ReplayPlaybackState::playing(
|
||||
synthetic_replay(10),
|
||||
5,
|
||||
0.5,
|
||||
false,
|
||||
)),
|
||||
Some("GAME #2026-122".to_string()),
|
||||
);
|
||||
|
||||
@@ -381,12 +353,7 @@ fn format_game_caption_covers_state_corners() {
|
||||
let mut early_january = synthetic_replay(10);
|
||||
early_january.recorded_at = NaiveDate::from_ymd_opt(2026, 1, 5).expect("valid date");
|
||||
assert_eq!(
|
||||
format_game_caption(&ReplayPlaybackState::Playing {
|
||||
replay: early_january,
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
}),
|
||||
format_game_caption(&ReplayPlaybackState::playing(early_january, 0, 0.5, false,)),
|
||||
Some("GAME #2026-005".to_string()),
|
||||
);
|
||||
}
|
||||
@@ -399,12 +366,7 @@ fn overlay_game_caption_shows_replay_date() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(game_caption_text(&mut app), "GAME #2026-122");
|
||||
@@ -431,12 +393,7 @@ fn overlay_scrub_fill_tracks_cursor() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(8),
|
||||
cursor: 2,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(8), 2, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -447,12 +404,7 @@ fn overlay_scrub_fill_tracks_cursor() {
|
||||
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(8),
|
||||
cursor: 6,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(8), 6, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -497,12 +449,7 @@ fn win_move_marker_pct_is_none_for_completed() {
|
||||
fn win_move_marker_pct_is_none_when_replay_lacks_field() {
|
||||
// Synthetic replay constructor leaves win_move_index as None
|
||||
// (legacy / pre-`ab857bb` path).
|
||||
let state = ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
};
|
||||
let state = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false);
|
||||
assert_eq!(win_move_marker_pct(&state), None);
|
||||
}
|
||||
|
||||
@@ -511,12 +458,12 @@ fn win_move_marker_pct_is_some_at_correct_position() {
|
||||
// 10 moves, win at index 9 → marker sits at 90 % of the track.
|
||||
// Matches the recording semantic: cursor reaches the marker
|
||||
// exactly when the about-to-apply move IS the win move.
|
||||
let state = ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10).with_win_move_index(Some(9)),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
};
|
||||
let state = ReplayPlaybackState::playing(
|
||||
synthetic_replay(10).with_win_move_index(Some(9)),
|
||||
0,
|
||||
0.5,
|
||||
false,
|
||||
);
|
||||
assert_eq!(win_move_marker_pct(&state), Some(90.0));
|
||||
}
|
||||
|
||||
@@ -524,12 +471,12 @@ fn win_move_marker_pct_is_some_at_correct_position() {
|
||||
fn win_move_marker_pct_clamps_to_track_bounds() {
|
||||
// Defensive: if a malformed replay carried `win_move_index >=
|
||||
// total`, the marker must still sit on the track, not past it.
|
||||
let state = ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(5).with_win_move_index(Some(99)),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
};
|
||||
let state = ReplayPlaybackState::playing(
|
||||
synthetic_replay(5).with_win_move_index(Some(99)),
|
||||
0,
|
||||
0.5,
|
||||
false,
|
||||
);
|
||||
assert_eq!(win_move_marker_pct(&state), Some(100.0));
|
||||
}
|
||||
|
||||
@@ -538,12 +485,12 @@ fn marker_spawned_when_replay_has_win_move_index() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(8).with_win_move_index(Some(7)),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(
|
||||
synthetic_replay(8).with_win_move_index(Some(7)),
|
||||
0,
|
||||
0.5,
|
||||
false,
|
||||
),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -559,12 +506,7 @@ fn marker_not_spawned_when_replay_lacks_win_move_index() {
|
||||
// Default constructor → win_move_index: None (legacy replay).
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(8),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(8), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -579,12 +521,12 @@ fn marker_despawns_when_replay_state_returns_to_inactive() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(8).with_win_move_index(Some(7)),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(
|
||||
synthetic_replay(8).with_win_move_index(Some(7)),
|
||||
0,
|
||||
0.5,
|
||||
false,
|
||||
),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(win_marker_count(&mut app), 1);
|
||||
@@ -608,12 +550,12 @@ fn win_move_marker_carries_hc_background_marker() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(8).with_win_move_index(Some(7)),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(
|
||||
synthetic_replay(8).with_win_move_index(Some(7)),
|
||||
0,
|
||||
0.5,
|
||||
false,
|
||||
),
|
||||
);
|
||||
app.update();
|
||||
|
||||
@@ -669,12 +611,7 @@ fn scrub_notches_spawn_with_overlay() {
|
||||
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -693,12 +630,7 @@ fn scrub_notches_carry_high_contrast_background_marker() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
|
||||
@@ -723,12 +655,7 @@ fn scrub_track_carries_high_contrast_background_marker() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
|
||||
@@ -759,12 +686,7 @@ fn scrub_notches_despawn_with_overlay() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(scrub_notch_count(&mut app), 5);
|
||||
@@ -821,12 +743,7 @@ fn scrub_notch_labels_spawn_with_overlay() {
|
||||
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -844,12 +761,7 @@ fn scrub_notch_labels_carry_helper_strings() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
|
||||
@@ -870,12 +782,7 @@ fn scrub_notch_labels_despawn_with_overlay() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(scrub_notch_label_count(&mut app), 5);
|
||||
@@ -947,12 +854,7 @@ fn keybind_footer_spawns_with_overlay() {
|
||||
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -970,12 +872,7 @@ fn keybind_footer_paints_helper_strings() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
|
||||
@@ -999,12 +896,7 @@ fn keybind_footer_carries_high_contrast_border_marker() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
|
||||
@@ -1025,12 +917,7 @@ fn keybind_footer_despawns_with_overlay() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(keybind_footer_count(&mut app), 1);
|
||||
@@ -1054,12 +941,7 @@ fn scrub_notches_spawn_even_without_win_marker() {
|
||||
// Default constructor → win_move_index: None.
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(8),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(8), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -1144,12 +1026,7 @@ fn format_move_body_handles_stock_cycle() {
|
||||
/// `Inactive` → empty.
|
||||
#[test]
|
||||
fn format_move_log_header_covers_state_branches() {
|
||||
let playing = ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 3,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
};
|
||||
let playing = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false);
|
||||
assert_eq!(
|
||||
format_move_log_header(&playing),
|
||||
"\u{258C} MOVE LOG \u{00B7} 3/10"
|
||||
@@ -1167,24 +1044,14 @@ fn format_move_log_header_covers_state_branches() {
|
||||
/// `replay.moves[N - 1]` and the row reads `"N | ..."`.
|
||||
#[test]
|
||||
fn format_active_move_row_handles_cursor_zero_and_positive() {
|
||||
let cursor_zero = ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
};
|
||||
let cursor_zero = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false);
|
||||
assert_eq!(
|
||||
format_active_move_row(&cursor_zero),
|
||||
"",
|
||||
"cursor=0 means no move applied yet; row stays empty",
|
||||
);
|
||||
|
||||
let cursor_three = ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 3,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
};
|
||||
let cursor_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false);
|
||||
// synthetic_replay produces all StockClicks, so the body
|
||||
// is "stock cycle". The displayed index is 3 (cursor),
|
||||
// matching the most-recently-applied move at moves[2].
|
||||
@@ -1208,12 +1075,7 @@ fn move_log_panel_spawns_with_overlay() {
|
||||
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -1232,12 +1094,7 @@ fn move_log_panel_header_paints_helper_string() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(8),
|
||||
cursor: 2,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(8), 2, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -1254,12 +1111,7 @@ fn move_log_active_row_repaints_on_cursor_advance() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -1271,12 +1123,7 @@ fn move_log_active_row_repaints_on_cursor_advance() {
|
||||
// Advance cursor to 2 (most-recently-applied move is moves[1]).
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 2,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 2, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -1292,12 +1139,7 @@ fn move_log_active_row_repaints_on_cursor_advance() {
|
||||
/// early in a replay don't paint stale text.
|
||||
#[test]
|
||||
fn format_kth_recent_row_handles_in_range_and_out_of_range() {
|
||||
let state_at_three = ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 3,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
};
|
||||
let state_at_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false);
|
||||
// k=1 → active (most recent applied). cursor=3 → display=3.
|
||||
assert_eq!(
|
||||
format_kth_recent_row(&state_at_three, 1),
|
||||
@@ -1348,12 +1190,7 @@ fn move_log_prev_rows_spawn_with_panel() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 3,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -1371,12 +1208,7 @@ fn move_log_prev_rows_paint_helper_strings_at_spawn() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 5,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
|
||||
@@ -1401,12 +1233,7 @@ 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).
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 2,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 2, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -1422,12 +1249,7 @@ fn move_log_prev_rows_repaint_on_cursor_advance() {
|
||||
// Advance to cursor=5 — both offsets now have history.
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 5,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -1465,12 +1287,7 @@ fn move_log_next_row_text_at_offset(app: &mut App, offset: u8) -> String {
|
||||
/// alongside in-range correctness.
|
||||
#[test]
|
||||
fn format_kth_next_row_handles_in_range_and_out_of_range() {
|
||||
let state_at_three = ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 3,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
};
|
||||
let state_at_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false);
|
||||
// k=1 → moves[3], display=4
|
||||
assert_eq!(
|
||||
format_kth_next_row(&state_at_three, 1),
|
||||
@@ -1498,12 +1315,7 @@ fn move_log_next_rows_spawn_with_panel() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 3,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -1520,12 +1332,7 @@ fn move_log_next_rows_paint_helper_strings_at_spawn() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 5,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
|
||||
@@ -1549,12 +1356,7 @@ fn move_log_next_rows_underfill_at_replay_end() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 9,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 9, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -1578,12 +1380,7 @@ fn active_row_wrapper_carries_accent_primary_background() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 3,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
|
||||
@@ -1621,12 +1418,7 @@ fn active_row_text_uses_high_contrast_color_for_highlight() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 3,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
|
||||
@@ -1647,24 +1439,14 @@ fn active_row_text_uses_high_contrast_color_for_highlight() {
|
||||
/// dropping it has to also update this test.
|
||||
#[test]
|
||||
fn active_row_format_includes_focus_prefix() {
|
||||
let state = ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 5,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
};
|
||||
let state = ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false);
|
||||
let row = format_active_move_row(&state);
|
||||
assert!(
|
||||
row.starts_with('\u{25B6}'),
|
||||
"active-row format must start with ▶ focus marker; got {row:?}",
|
||||
);
|
||||
// Cursor=0 still returns empty, never just the prefix.
|
||||
let cursor_zero = ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
};
|
||||
let cursor_zero = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false);
|
||||
assert_eq!(
|
||||
format_active_move_row(&cursor_zero),
|
||||
"",
|
||||
@@ -1679,12 +1461,7 @@ fn move_log_panel_despawns_with_overlay() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(move_log_panel_count(&mut app), 1);
|
||||
@@ -1728,21 +1505,11 @@ fn unique_button<M: Component>(app: &mut App) -> Entity {
|
||||
}
|
||||
|
||||
fn pressed_paused_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState {
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(replay_len),
|
||||
cursor,
|
||||
secs_to_next: 0.5,
|
||||
paused: true,
|
||||
}
|
||||
ReplayPlaybackState::playing(synthetic_replay(replay_len), cursor, 0.5, true)
|
||||
}
|
||||
|
||||
fn running_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState {
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(replay_len),
|
||||
cursor,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
}
|
||||
ReplayPlaybackState::playing(synthetic_replay(replay_len), cursor, 0.5, false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2191,12 +1958,7 @@ fn dim_layer_spawns_and_despawns_with_overlay() {
|
||||
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(5),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -2255,12 +2017,7 @@ fn mini_tableau_panel_spawns_and_despawns_with_overlay() {
|
||||
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(5),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
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
|
||||
// the most-recently-applied move sits at `cursor - 1`.
|
||||
let dest_pile = match state.as_ref() {
|
||||
ReplayPlaybackState::Playing { replay, cursor, .. } if *cursor > 0 => {
|
||||
ReplayPlaybackState::Playing { moves, cursor, .. } if *cursor > 0 => {
|
||||
// The destination pile is recoverable directly from the
|
||||
// instruction — no live state needed. `RotateStock` has no
|
||||
// destination (the chip hides over the stock pile).
|
||||
match &replay.moves[cursor - 1] {
|
||||
match &moves[cursor - 1] {
|
||||
KlondikeInstruction::DstFoundation(dst) => {
|
||||
Some(KlondikePile::Foundation(dst.foundation))
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! In-engine replay playback core.
|
||||
//!
|
||||
//! When the player clicks "Watch replay" on the Stats overlay, the live
|
||||
//! game state is reset to the deal seeded from the replay's `seed` /
|
||||
//! `mode` / `draw_mode`, and the engine ticks through `replay.moves` at a
|
||||
//! steady cadence — firing the canonical [`MoveRequestEvent`] /
|
||||
//! [`DrawRequestEvent`] for each one. The existing animation pipeline
|
||||
//! plays back identically to a live game.
|
||||
//! game state is reset to the recorded deal — rebuilt directly from
|
||||
//! `replay.recording` via `GameState::from_recording`, never by
|
||||
//! re-dealing the seed — and the engine ticks through the recording's
|
||||
//! instruction list at a steady cadence, firing the canonical
|
||||
//! [`MoveRequestEvent`] / [`DrawRequestEvent`] for each one. The
|
||||
//! existing animation pipeline plays back identically to a live game.
|
||||
//!
|
||||
//! ## Public surface
|
||||
//!
|
||||
@@ -95,7 +96,7 @@ pub const REPLAY_COMPLETION_LINGER_SECS: f32 = 5.0;
|
||||
/// 3. The tick system [`tick_replay_playback`] advances `cursor` once
|
||||
/// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event
|
||||
/// for each [`KlondikeInstruction`].
|
||||
/// 4. When `cursor == replay.moves.len()`, the state transitions to
|
||||
/// 4. When `cursor == moves.len()`, the state transitions to
|
||||
/// [`Completed`](Self::Completed). It lingers for
|
||||
/// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by
|
||||
/// [`auto_clear_completed_replay`]) before returning to
|
||||
@@ -109,14 +110,20 @@ pub enum ReplayPlaybackState {
|
||||
#[default]
|
||||
Inactive,
|
||||
/// A replay is currently being played back. The overlay reads
|
||||
/// `replay.moves.len()` for the denominator of the progress
|
||||
/// `moves.len()` for the denominator of the progress
|
||||
/// indicator and `cursor` for the numerator.
|
||||
Playing {
|
||||
/// The replay being played back. Owned so the state is the
|
||||
/// only place playback metadata lives — no separate resource
|
||||
/// needed.
|
||||
replay: Replay,
|
||||
/// Index of the next move to apply, in `[0, replay.moves.len()]`.
|
||||
/// needed. Boxed: the v4 recording embeds the dealt board and
|
||||
/// would otherwise dwarf the other variants
|
||||
/// (`clippy::large_enum_variant`).
|
||||
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,
|
||||
/// Seconds remaining until the next move is dispatched.
|
||||
secs_to_next: f32,
|
||||
@@ -138,6 +145,19 @@ pub enum 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.
|
||||
pub fn is_playing(&self) -> bool {
|
||||
matches!(self, Self::Playing { .. })
|
||||
@@ -156,7 +176,7 @@ impl ReplayPlaybackState {
|
||||
/// the total is no longer available in `Completed`.
|
||||
pub fn progress(&self) -> Option<(usize, usize)> {
|
||||
match self {
|
||||
Self::Playing { replay, cursor, .. } => Some((*cursor, replay.moves.len())),
|
||||
Self::Playing { moves, cursor, .. } => Some((*cursor, moves.len())),
|
||||
Self::Inactive | Self::Completed => None,
|
||||
}
|
||||
}
|
||||
@@ -191,7 +211,11 @@ pub fn start_replay_playback(
|
||||
) {
|
||||
use solitaire_core::game_state::GameState;
|
||||
|
||||
let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode, replay.mode);
|
||||
// The recording carries the dealt board and the session config
|
||||
// (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));
|
||||
|
||||
// Initial `secs_to_next` uses the constant rather than reading
|
||||
@@ -201,7 +225,8 @@ pub fn start_replay_playback(
|
||||
// unusually short setting; subsequent ticks read the live setting
|
||||
// every frame via [`tick_replay_playback`].
|
||||
**state = ReplayPlaybackState::Playing {
|
||||
replay,
|
||||
replay: Box::new(replay),
|
||||
moves,
|
||||
cursor: 0,
|
||||
secs_to_next: REPLAY_MOVE_INTERVAL_SECS,
|
||||
paused: false,
|
||||
@@ -256,7 +281,7 @@ pub fn step_replay_playback(
|
||||
draws_writer: &mut MessageWriter<DrawRequestEvent>,
|
||||
) -> bool {
|
||||
let ReplayPlaybackState::Playing {
|
||||
replay,
|
||||
moves,
|
||||
cursor,
|
||||
paused: true,
|
||||
..
|
||||
@@ -264,10 +289,10 @@ pub fn step_replay_playback(
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if *cursor >= replay.moves.len() {
|
||||
if *cursor >= moves.len() {
|
||||
return false;
|
||||
}
|
||||
let instruction = replay.moves[*cursor];
|
||||
let instruction = moves[*cursor];
|
||||
dispatch_instruction(instruction, *cursor, game, moves_writer, draws_writer);
|
||||
*cursor += 1;
|
||||
true
|
||||
@@ -362,7 +387,7 @@ pub fn step_backwards_replay_playback(
|
||||
/// Drains `secs_to_next` by `time.delta_secs()`. When the countdown
|
||||
/// expires, fires the canonical event for the move at `cursor`,
|
||||
/// increments `cursor`, and resets `secs_to_next`. When `cursor`
|
||||
/// reaches `replay.moves.len()`, transitions to
|
||||
/// reaches `moves.len()`, transitions to
|
||||
/// [`ReplayPlaybackState::Completed`].
|
||||
///
|
||||
/// The advance loop is a `while`, not an `if`, so coarse time steps
|
||||
@@ -384,10 +409,11 @@ fn tick_replay_playback(
|
||||
let mut transition_to_completed = false;
|
||||
|
||||
if let ReplayPlaybackState::Playing {
|
||||
replay,
|
||||
moves,
|
||||
cursor,
|
||||
secs_to_next,
|
||||
paused,
|
||||
..
|
||||
} = state.as_mut()
|
||||
{
|
||||
// While paused, the cursor and the timer freeze together —
|
||||
@@ -397,8 +423,8 @@ fn tick_replay_playback(
|
||||
// path.
|
||||
if !*paused {
|
||||
*secs_to_next -= dt;
|
||||
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
|
||||
let instruction = replay.moves[*cursor];
|
||||
while *secs_to_next <= 0.0 && *cursor < moves.len() {
|
||||
let instruction = moves[*cursor];
|
||||
dispatch_instruction(
|
||||
instruction,
|
||||
*cursor,
|
||||
@@ -410,7 +436,7 @@ fn tick_replay_playback(
|
||||
*secs_to_next += interval;
|
||||
}
|
||||
|
||||
if *cursor >= replay.moves.len() {
|
||||
if *cursor >= moves.len() {
|
||||
transition_to_completed = true;
|
||||
}
|
||||
}
|
||||
@@ -562,8 +588,7 @@ mod tests {
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use bevy::time::TimeUpdateStrategy;
|
||||
use chrono::NaiveDate;
|
||||
use solitaire_core::KlondikeInstruction;
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||
use solitaire_core::{DrawStockConfig, SessionRecording, game_state::GameMode};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
|
||||
@@ -612,11 +637,11 @@ mod tests {
|
||||
60,
|
||||
500,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![
|
||||
KlondikeInstruction::RotateStock,
|
||||
KlondikeInstruction::RotateStock,
|
||||
KlondikeInstruction::RotateStock,
|
||||
],
|
||||
SessionRecording::from_instructions_unchecked(
|
||||
12345,
|
||||
DrawStockConfig::DrawOne,
|
||||
[KlondikeInstruction::RotateStock; 3],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -673,11 +698,14 @@ mod tests {
|
||||
let state = app.world().resource::<ReplayPlaybackState>();
|
||||
match state {
|
||||
ReplayPlaybackState::Playing {
|
||||
cursor, replay: r, ..
|
||||
cursor,
|
||||
replay: r,
|
||||
moves,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(*cursor, 0);
|
||||
assert_eq!(r.seed, replay.seed);
|
||||
assert_eq!(r.moves.len(), 3);
|
||||
assert_eq!(moves.len(), 3);
|
||||
}
|
||||
other => panic!("expected Playing, got {other:?}"),
|
||||
}
|
||||
@@ -778,7 +806,11 @@ mod tests {
|
||||
10,
|
||||
100,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![KlondikeInstruction::RotateStock],
|
||||
SessionRecording::from_instructions_unchecked(
|
||||
42,
|
||||
DrawStockConfig::DrawOne,
|
||||
[KlondikeInstruction::RotateStock],
|
||||
),
|
||||
);
|
||||
start_playback(&mut app, one_move);
|
||||
app.update();
|
||||
@@ -887,7 +919,11 @@ mod tests {
|
||||
10,
|
||||
100,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![KlondikeInstruction::RotateStock; 10],
|
||||
SessionRecording::from_instructions_unchecked(
|
||||
7,
|
||||
DrawStockConfig::DrawOne,
|
||||
[KlondikeInstruction::RotateStock; 10],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ use std::sync::Arc;
|
||||
use bevy::math::Vec2;
|
||||
use bevy::prelude::Resource;
|
||||
use chrono::{DateTime, Utc};
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_core::Card;
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_core::game_state::GameState;
|
||||
|
||||
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
|
||||
|
||||
@@ -82,7 +82,8 @@ impl Plugin for SafeAreaInsetsPlugin {
|
||||
);
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
app.init_resource::<android::SafeAreaPollTries>()
|
||||
app.add_message::<crate::events::StateChangedEvent>()
|
||||
.init_resource::<android::SafeAreaPollTries>()
|
||||
.add_systems(Update, android::refresh_insets)
|
||||
.add_systems(Update, android::rearm_on_resumed)
|
||||
.add_systems(Update, android::refresh_surface_size);
|
||||
@@ -100,9 +101,10 @@ impl Plugin for SafeAreaInsetsPlugin {
|
||||
fn apply_safe_area_anchors(
|
||||
insets: Res<SafeAreaInsets>,
|
||||
windows: Query<&Window>,
|
||||
ui_scale: Option<Res<UiScale>>,
|
||||
mut q: Query<(&SafeAreaAnchoredTop, &mut Node)>,
|
||||
) {
|
||||
if !insets.is_changed() {
|
||||
if !insets.is_changed() && !ui_scale.as_ref().is_some_and(|s| s.is_changed()) {
|
||||
return;
|
||||
}
|
||||
// Android's WindowInsets API returns physical pixels; Bevy UI's Val::Px
|
||||
@@ -118,19 +120,31 @@ fn apply_safe_area_anchors(
|
||||
);
|
||||
}
|
||||
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 {
|
||||
node.top = Val::Px(anchor.base_top + top_logical);
|
||||
node.top = Val::Px(anchor.base_top + top_logical / ui);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// [`SafeAreaAnchoredBottom`] whenever [`SafeAreaInsets`] changes.
|
||||
fn apply_safe_area_bottom_anchors(
|
||||
insets: Res<SafeAreaInsets>,
|
||||
windows: Query<&Window>,
|
||||
ui_scale: Option<Res<UiScale>>,
|
||||
mut q: Query<(&SafeAreaAnchoredBottom, &mut Node)>,
|
||||
) {
|
||||
if !insets.is_changed() {
|
||||
if !insets.is_changed() && !ui_scale.as_ref().is_some_and(|s| s.is_changed()) {
|
||||
return;
|
||||
}
|
||||
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
||||
@@ -143,8 +157,12 @@ fn apply_safe_area_bottom_anchors(
|
||||
);
|
||||
}
|
||||
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 {
|
||||
node.bottom = Val::Px(anchor.base_bottom + bottom_logical);
|
||||
node.bottom = Val::Px(anchor.base_bottom + bottom_logical / ui);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,19 +180,23 @@ fn apply_safe_area_bottom_anchors(
|
||||
fn apply_safe_area_to_modal_scrims(
|
||||
insets: Res<SafeAreaInsets>,
|
||||
windows: Query<&Window>,
|
||||
ui_scale: Option<Res<UiScale>>,
|
||||
mut scrims: Query<&mut Node, With<ModalScrim>>,
|
||||
new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
|
||||
) {
|
||||
let has_new = !new_scrims.is_empty();
|
||||
if !insets.is_changed() && !has_new {
|
||||
if !insets.is_changed() && !has_new && !ui_scale.as_ref().is_some_and(|s| s.is_changed()) {
|
||||
return;
|
||||
}
|
||||
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());
|
||||
// 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.
|
||||
let top_logical = (insets.top / scale).min(window_height * 0.25);
|
||||
let bottom_logical = (insets.bottom / scale).min(window_height * 0.25);
|
||||
// Physical insets are pre-divided by the UI scale — see
|
||||
// `apply_safe_area_anchors`.
|
||||
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 {
|
||||
// Set both edges so the scrim's content box equals the usable area
|
||||
// between the status bar and the gesture/navigation bar. With
|
||||
@@ -330,11 +352,16 @@ mod android {
|
||||
/// `on_window_resized` in `table_plugin` recomputes the board layout,
|
||||
/// 3. re-arms the inset poller, because a screen change almost always
|
||||
/// 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(
|
||||
mut frame: Local<u32>,
|
||||
mut windows: Query<(Entity, &mut Window)>,
|
||||
mut resize_events: MessageWriter<WindowResized>,
|
||||
mut state_events: MessageWriter<crate::events::StateChangedEvent>,
|
||||
mut poll: ResMut<SafeAreaPollTries>,
|
||||
) {
|
||||
const POLL_INTERVAL_FRAMES: u32 = 30; // ~0.5 s @ 60 fps
|
||||
@@ -381,6 +408,7 @@ mod android {
|
||||
width: decor_w as f32 / scale,
|
||||
height: decor_h as f32 / scale,
|
||||
});
|
||||
state_events.write(crate::events::StateChangedEvent);
|
||||
poll.0 = 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -90,9 +90,7 @@ mod tests {
|
||||
.and_then(|prefix| prefix.split_whitespace().last())
|
||||
.and_then(|n| n.parse::<usize>().ok());
|
||||
parsed.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"ambiguity panic message no longer parseable (Bevy upgrade?): {msg}"
|
||||
)
|
||||
panic!("ambiguity panic message no longer parseable (Bevy upgrade?): {msg}")
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
@@ -37,9 +37,9 @@
|
||||
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::Card;
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
|
||||
use crate::card_plugin::CardEntityIndex;
|
||||
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
|
||||
@@ -487,8 +487,10 @@ fn handle_selection_keys(
|
||||
1
|
||||
};
|
||||
let start = source_cards.len().saturating_sub(count);
|
||||
let lifted_cards: Vec<Card> =
|
||||
source_cards[start..].iter().map(|(c, _)| c.clone()).collect();
|
||||
let lifted_cards: Vec<Card> = source_cards[start..]
|
||||
.iter()
|
||||
.map(|(c, _)| c.clone())
|
||||
.collect();
|
||||
let Some((bottom, _)) = source_cards.get(start) else {
|
||||
return;
|
||||
};
|
||||
@@ -597,10 +599,7 @@ fn face_up_run_len(cards: &[(Card, bool)]) -> usize {
|
||||
/// This is intentionally separated from [`best_destination`] so the Enter
|
||||
/// handler can attempt a foundation move first and fall through to a
|
||||
/// 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())?;
|
||||
for foundation in [
|
||||
Foundation::Foundation1,
|
||||
@@ -697,13 +696,7 @@ fn update_selection_highlight(
|
||||
if let Some(ref pile) = source_pile
|
||||
&& 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.
|
||||
@@ -714,13 +707,7 @@ fn update_selection_highlight(
|
||||
// in destination-pick mode and the focused index is observable
|
||||
// via the resource.
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1047,10 +1034,7 @@ mod tests {
|
||||
press_key(&mut app, KeyCode::Tab);
|
||||
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
|
||||
// available pile (Tableau(0)) is selected.
|
||||
assert_eq!(selected, Some(KlondikePile::Tableau(Tableau::Tableau1)));
|
||||
@@ -1216,16 +1200,10 @@ mod tests {
|
||||
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);
|
||||
app.update();
|
||||
let after = app
|
||||
.world()
|
||||
.resource::<SelectionState>()
|
||||
.selected_pile;
|
||||
let after = app.world().resource::<SelectionState>().selected_pile;
|
||||
|
||||
assert_eq!(
|
||||
before, after,
|
||||
|
||||
@@ -50,15 +50,21 @@ pub(super) fn handle_volume_keys(
|
||||
}
|
||||
|
||||
/// Opens or closes the Settings panel — `O` keyboard accelerator or
|
||||
/// `ToggleSettingsRequestEvent` from the HUD Menu popover.
|
||||
/// `ToggleSettingsRequestEvent` from the HUD Menu popover. Esc closes
|
||||
/// 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(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut requests: MessageReader<ToggleSettingsRequestEvent>,
|
||||
mut screen: ResMut<SettingsScreen>,
|
||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
|
||||
) {
|
||||
let button_clicked = requests.read().count() > 0;
|
||||
if keys.just_pressed(KeyCode::KeyO) || button_clicked {
|
||||
screen.0 = !screen.0;
|
||||
} else if keys.just_pressed(KeyCode::Escape) && screen.0 && other_modal_scrims.is_empty() {
|
||||
screen.0 = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,6 +363,14 @@ pub(super) fn handle_settings_buttons(
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
// 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 => {
|
||||
settings.0.winnable_deals_only = !settings.0.winnable_deals_only;
|
||||
persist(&path, &settings.0);
|
||||
@@ -407,6 +421,10 @@ pub(super) fn handle_settings_buttons(
|
||||
| SettingsButton::DeleteAccount => {
|
||||
// Handled by `handle_sync_buttons`.
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
SettingsButton::OpenThemeStore => {
|
||||
// Handled by `handle_sync_buttons`.
|
||||
}
|
||||
SettingsButton::Done => {
|
||||
screen.0 = false;
|
||||
}
|
||||
@@ -414,6 +432,25 @@ 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,
|
||||
/// and Delete Account. Split from `handle_settings_buttons` to stay within
|
||||
/// Bevy's 16-parameter system limit.
|
||||
@@ -423,6 +460,9 @@ pub(super) fn handle_sync_buttons(
|
||||
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
|
||||
mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
|
||||
#[cfg(not(target_arch = "wasm32"))] mut open_theme_store: MessageWriter<
|
||||
crate::events::ThemeStoreOpenRequestEvent,
|
||||
>,
|
||||
mut screen: ResMut<SettingsScreen>,
|
||||
) {
|
||||
for (interaction, button) in &interaction_query {
|
||||
@@ -439,6 +479,13 @@ pub(super) fn handle_sync_buttons(
|
||||
screen.0 = false;
|
||||
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 => {
|
||||
logout_sync.write(SyncLogoutRequestEvent);
|
||||
}
|
||||
|
||||
@@ -139,6 +139,10 @@ struct ReduceMotionText;
|
||||
#[derive(Component, Debug)]
|
||||
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.
|
||||
#[derive(Component, Debug)]
|
||||
struct TooltipDelayText;
|
||||
@@ -170,6 +174,51 @@ struct SmartDefaultSizeText;
|
||||
#[derive(Component, Debug)]
|
||||
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.
|
||||
#[derive(Component, Debug)]
|
||||
struct SettingsPanelScrollable;
|
||||
@@ -232,6 +281,10 @@ enum SettingsButton {
|
||||
/// (auto-move on tap, default) and `TapToSelect` (first tap selects
|
||||
/// a card/stack, second tap on a target pile moves it).
|
||||
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
|
||||
/// random Classic-mode deals are filtered through
|
||||
/// [`solitaire_core::game_state::GameState::solve_fresh_deal`] until one is provably
|
||||
@@ -250,6 +303,9 @@ enum SettingsButton {
|
||||
/// Scan `user_theme_dir()` for new `.zip` files and import each one.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
ScanThemes,
|
||||
/// Open the in-game theme-store modal (closes Settings first).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
OpenThemeStore,
|
||||
SyncNow,
|
||||
/// Open the sync-server Connect modal (shown when backend = Local).
|
||||
ConnectSync,
|
||||
@@ -306,6 +362,7 @@ impl SettingsButton {
|
||||
SettingsButton::ToggleHighContrast => 61,
|
||||
SettingsButton::ToggleReduceMotion => 62,
|
||||
SettingsButton::ToggleTouchInputMode => 63,
|
||||
SettingsButton::CycleUiScale => 64,
|
||||
// Picker rows — every swatch in a row shares the row's
|
||||
// priority so entity-index tiebreaking yields left → right.
|
||||
SettingsButton::SelectCardBack(_) => 70,
|
||||
@@ -313,6 +370,8 @@ impl SettingsButton {
|
||||
SettingsButton::SelectTheme(_) => 85,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
SettingsButton::ScanThemes => 86,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
SettingsButton::OpenThemeStore => 87,
|
||||
// Sync section
|
||||
SettingsButton::SyncNow => 90,
|
||||
SettingsButton::ConnectSync => 91,
|
||||
@@ -364,6 +423,7 @@ impl Plugin for SettingsPlugin {
|
||||
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
|
||||
.init_resource::<SettingsScreen>()
|
||||
.init_resource::<SettingsScrollPos>()
|
||||
.init_resource::<ActiveSettingsTab>()
|
||||
.init_resource::<PendingWindowGeometry>()
|
||||
.add_message::<SettingsChangedEvent>()
|
||||
.add_message::<ManualSyncRequestEvent>()
|
||||
@@ -371,6 +431,7 @@ impl Plugin for SettingsPlugin {
|
||||
.add_message::<SyncLogoutRequestEvent>()
|
||||
.add_message::<DeleteAccountRequestEvent>()
|
||||
.add_message::<ToggleSettingsRequestEvent>()
|
||||
.add_message::<crate::events::ThemeStoreOpenRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<MouseWheel>()
|
||||
.add_message::<TouchInput>()
|
||||
@@ -397,6 +458,11 @@ impl Plugin for SettingsPlugin {
|
||||
handle_volume_keys,
|
||||
record_window_geometry_changes,
|
||||
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()
|
||||
.in_set(SettingsMutation),
|
||||
@@ -417,7 +483,15 @@ impl Plugin for SettingsPlugin {
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
sync_settings_panel_visibility,
|
||||
// Chained: a tab click must observe this frame's
|
||||
// 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_sync_buttons,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -432,6 +506,7 @@ impl Plugin for SettingsPlugin {
|
||||
update_high_contrast_backgrounds.run_if(resource_changed::<SettingsResource>),
|
||||
update_reduce_motion_text,
|
||||
update_touch_input_mode_text,
|
||||
update_ui_scale_text,
|
||||
update_tooltip_delay_text,
|
||||
update_time_bonus_multiplier_text,
|
||||
update_replay_move_interval_text,
|
||||
|
||||
@@ -98,6 +98,25 @@ 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]
|
||||
fn pressing_o_toggles_settings_screen_flag() {
|
||||
let mut app = headless_app();
|
||||
@@ -121,6 +140,31 @@ 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
|
||||
#[test]
|
||||
fn cycle_unlocked_wraps_at_end() {
|
||||
@@ -243,40 +287,40 @@ fn headless_app_with_focus() -> App {
|
||||
fn settings_buttons_get_focusable_marker() {
|
||||
let mut app = headless_app_with_focus();
|
||||
|
||||
// Open the panel.
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
app.update();
|
||||
// Two more ticks: the first runs `sync_settings_panel_visibility`
|
||||
// and queues the spawn commands; the second flushes them and
|
||||
// runs `attach_focusable_to_settings_buttons`.
|
||||
app.update();
|
||||
app.update();
|
||||
// Walk every tab: each rebuild must leave no bespoke
|
||||
// `SettingsButton` (not `Done`, which is also a `ModalButton`)
|
||||
// without a `Focusable`.
|
||||
let mut total_tagged = 0usize;
|
||||
for (i, tab) in SettingsTab::ALL.into_iter().enumerate() {
|
||||
if i == 0 {
|
||||
open_settings_on(&mut app, tab);
|
||||
} else {
|
||||
switch_settings_tab(&mut app, tab);
|
||||
}
|
||||
|
||||
// Every bespoke `SettingsButton` (not `Done`, which is also a
|
||||
// `ModalButton`) must carry a `Focusable`.
|
||||
let untagged: Vec<&SettingsButton> = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
|
||||
.iter(app.world())
|
||||
.collect();
|
||||
let untagged: Vec<&SettingsButton> = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
|
||||
.iter(app.world())
|
||||
.collect();
|
||||
assert!(
|
||||
untagged.is_empty(),
|
||||
"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!(
|
||||
untagged.is_empty(),
|
||||
"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}"
|
||||
total_tagged >= 20,
|
||||
"expected the five tabs to spawn many bespoke buttons; got {total_tagged}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -290,41 +334,45 @@ fn settings_buttons_get_focusable_marker() {
|
||||
fn settings_buttons_carry_tooltip() {
|
||||
let mut app = headless_app_with_focus();
|
||||
|
||||
// Open the panel and let spawn + child-flush run.
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
app.update();
|
||||
app.update();
|
||||
app.update();
|
||||
// Walk every tab: no bespoke `SettingsButton` (i.e. excluding
|
||||
// `Done`, which is also a `ModalButton`) may be missing a
|
||||
// `Tooltip` on any of them.
|
||||
let mut total_tipped = 0usize;
|
||||
for (i, tab) in SettingsTab::ALL.into_iter().enumerate() {
|
||||
if i == 0 {
|
||||
open_settings_on(&mut app, tab);
|
||||
} else {
|
||||
switch_settings_tab(&mut app, tab);
|
||||
}
|
||||
|
||||
// No bespoke `SettingsButton` (i.e. excluding `Done`, which is
|
||||
// also a `ModalButton`) may be missing a `Tooltip`.
|
||||
let untipped: Vec<&SettingsButton> = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>()
|
||||
.iter(app.world())
|
||||
.collect();
|
||||
let untipped: Vec<&SettingsButton> = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>()
|
||||
.iter(app.world())
|
||||
.collect();
|
||||
assert!(
|
||||
untipped.is_empty(),
|
||||
"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!(
|
||||
untipped.is_empty(),
|
||||
"every bespoke Settings button must carry Tooltip; missing: {:?}",
|
||||
untipped
|
||||
total_tipped >= 20,
|
||||
"expected the five tabs to spawn many tooltipped buttons; got {total_tipped}"
|
||||
);
|
||||
|
||||
// And there must be at least 6 tipped buttons so the assertion
|
||||
// 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}"
|
||||
);
|
||||
// The loop ends on the Account tab, where with default (Local)
|
||||
// settings the Connect button spawns.
|
||||
|
||||
// Spot-check: with default (Local) settings the Connect button
|
||||
// spawns. We verify its tooltip carries the canonical microcopy.
|
||||
// We verify its tooltip carries the canonical microcopy.
|
||||
let connect_tip = app
|
||||
.world_mut()
|
||||
.query::<(&SettingsButton, &Tooltip)>()
|
||||
@@ -342,10 +390,8 @@ fn settings_buttons_carry_tooltip() {
|
||||
fn settings_picker_rows_get_focus_row_marker() {
|
||||
let mut app = headless_app_with_focus();
|
||||
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
app.update();
|
||||
app.update();
|
||||
app.update();
|
||||
// Picker rows live on the Appearance tab.
|
||||
open_settings_on(&mut app, SettingsTab::Appearance);
|
||||
|
||||
// Two picker rows are spawned (card-back + background); each
|
||||
// must carry the FocusRow marker.
|
||||
@@ -360,6 +406,45 @@ 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
|
||||
/// at least one theme and the [`ThemeThumbnailCache`] holds a
|
||||
/// fully-populated [`ThemeThumbnailPair`] for that theme's id, the
|
||||
@@ -404,11 +489,9 @@ fn theme_picker_chip_includes_thumbnail_sprite_when_thumbnails_loaded() {
|
||||
);
|
||||
app.insert_resource(cache);
|
||||
|
||||
// Open the panel and let the spawn + child-flush systems run.
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
app.update();
|
||||
app.update();
|
||||
app.update();
|
||||
// Open the panel on the Appearance tab (the theme picker's home)
|
||||
// and let the spawn + child-flush systems run.
|
||||
open_settings_on(&mut app, SettingsTab::Appearance);
|
||||
|
||||
// Find every ImageNode tagged with ThemeThumbnailMarker — the
|
||||
// theme picker chip for "test_theme" must contribute exactly
|
||||
@@ -653,3 +736,60 @@ fn scroll_clamps_offset_to_zero_at_top() {
|
||||
"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);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::theme::{ImportError, import_theme, refresh_registry};
|
||||
use crate::ui_focus::FocusRow;
|
||||
use crate::ui_modal::{
|
||||
ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header,
|
||||
spawn_tab_chip,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM,
|
||||
@@ -45,13 +46,34 @@ pub(super) fn spawn_settings_panel(
|
||||
scroll_offset: f32,
|
||||
font_res: Option<&FontResource>,
|
||||
theme_overrides_back: bool,
|
||||
active_tab: SettingsTab,
|
||||
) {
|
||||
spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, "Settings", font_res);
|
||||
|
||||
// Scrollable body — contains every section so tall content stays
|
||||
// reachable on short windows. The Done button below stays fixed
|
||||
// outside the scroll so it's always one click away.
|
||||
// Tab chips — one group of rows on screen at a time, no
|
||||
// scroll-hunting (Phase A, docs/ui-redesign-2026-07.md).
|
||||
card.spawn((
|
||||
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((
|
||||
SettingsPanelScrollable,
|
||||
SettingsScrollNode,
|
||||
@@ -65,198 +87,20 @@ pub(super) fn spawn_settings_panel(
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|body| {
|
||||
// --- Audio ---
|
||||
section_label(body, "Audio", font_res);
|
||||
volume_row(
|
||||
.with_children(|body| match active_tab {
|
||||
SettingsTab::Audio => spawn_audio_tab(body, settings, font_res),
|
||||
SettingsTab::Gameplay => spawn_gameplay_tab(body, settings, font_res),
|
||||
SettingsTab::Appearance => spawn_appearance_tab(
|
||||
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 ---
|
||||
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",
|
||||
settings,
|
||||
unlocked_card_backs,
|
||||
unlocked_backgrounds,
|
||||
settings.selected_background,
|
||||
SettingsButton::SelectBackground,
|
||||
"Choose your felt art. New felts unlock at higher levels.",
|
||||
themes,
|
||||
theme_overrides_back,
|
||||
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);
|
||||
),
|
||||
SettingsTab::Accessibility => spawn_accessibility_tab(body, settings, font_res),
|
||||
SettingsTab::Account => spawn_account_tab(body, settings, sync_status, font_res),
|
||||
});
|
||||
|
||||
// Done is the only action — primary so the player always knows
|
||||
@@ -274,6 +118,253 @@ 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,
|
||||
"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,
|
||||
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);
|
||||
}
|
||||
|
||||
/// Section divider — small lavender label inside the scrollable body.
|
||||
pub(super) fn section_label(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
@@ -1193,6 +1284,31 @@ 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(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
label: &str,
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::ui_theme::{BORDER_SUBTLE_HC, HighContrastBackground, HighContrastBord
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn sync_settings_panel_visibility(
|
||||
screen: Res<SettingsScreen>,
|
||||
active_tab: Res<ActiveSettingsTab>,
|
||||
panels: Query<Entity, With<SettingsPanel>>,
|
||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
|
||||
scroll_nodes: Query<&ScrollPosition, With<SettingsScrollNode>>,
|
||||
@@ -35,60 +36,17 @@ pub(super) fn sync_settings_panel_visibility(
|
||||
}
|
||||
if screen.0 {
|
||||
if panels.is_empty() && other_modal_scrims.is_empty() {
|
||||
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(
|
||||
build_panel(
|
||||
&mut commands,
|
||||
&settings.0,
|
||||
&status_label,
|
||||
unlocked_backs,
|
||||
unlocked_bgs,
|
||||
&themes,
|
||||
scroll_pos.0,
|
||||
sync_status.as_deref(),
|
||||
progress.as_deref(),
|
||||
font_res.as_deref(),
|
||||
theme_overrides_back,
|
||||
theme_registry.as_deref(),
|
||||
theme_thumbs.as_deref(),
|
||||
card_images.as_deref(),
|
||||
scroll_pos.0,
|
||||
active_tab.0,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -102,6 +60,116 @@ 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>,
|
||||
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,
|
||||
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>>,
|
||||
) {
|
||||
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(),
|
||||
0.0,
|
||||
active_tab.0,
|
||||
);
|
||||
}
|
||||
|
||||
/// Keeps the sync-status text node current while the panel is open.
|
||||
pub(super) fn update_sync_status_text(
|
||||
sync_status: Option<Res<SyncStatusResource>>,
|
||||
@@ -447,6 +515,58 @@ 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
|
||||
/// is the *enabled* state (i.e. the inverse of the underlying
|
||||
/// `disable_smart_default_size` field) so reading the label gives
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
//! "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",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
use solitaire_data::{
|
||||
@@ -25,17 +24,13 @@ use crate::events::{
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::platform::ClipboardBackendResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::time_attack_plugin::TimeAttackResource;
|
||||
use crate::ui_modal::{
|
||||
ButtonVariant, ModalButton, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
||||
spawn_modal_button, spawn_modal_header,
|
||||
};
|
||||
use crate::ui_modal::{ButtonVariant, ModalButton, spawn_modal_button};
|
||||
use crate::ui_theme::{
|
||||
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,
|
||||
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
|
||||
TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
|
||||
};
|
||||
|
||||
/// Bevy resource wrapping the current stats.
|
||||
@@ -225,8 +220,9 @@ impl Plugin for StatsPlugin {
|
||||
.before(GameMutation)
|
||||
.before(update_stats_on_new_game),
|
||||
)
|
||||
.add_systems(Update, toggle_stats_screen.after(GameMutation))
|
||||
.add_systems(Update, handle_stats_close_button)
|
||||
// Open/close/tab handling moved to `you_hub_plugin`
|
||||
// (Phase E) — this plugin now owns body content, replay
|
||||
// selector behavior, and scroll.
|
||||
.add_systems(Update, refresh_replay_history_on_win.after(GameMutation))
|
||||
.add_systems(Update, handle_watch_replay_button)
|
||||
.add_systems(Update, handle_copy_share_link_button)
|
||||
@@ -632,70 +628,17 @@ fn handle_forfeit(
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker on the "Done" button inside the Stats modal. Click despawns
|
||||
/// the overlay; `S` keyboard shortcut toggles it the same way.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct StatsCloseButton;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
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,
|
||||
/// Builds the Stats tab body inside the You hub's card: the 8-cell
|
||||
/// grid plus per-mode bests, progression, weekly goals, unlocks, and
|
||||
/// the optional Time Attack line. The replay selector lives on its own
|
||||
/// hub tab now — see [`spawn_replays_body`]. All markers
|
||||
/// (`StatsCell`, `PerModeBestsRow`, `StatsScrollable`) are unchanged.
|
||||
pub(crate) fn spawn_stats_body(
|
||||
card: &mut ChildSpawnerCommands,
|
||||
stats: &StatsSnapshot,
|
||||
progress: Option<&PlayerProgress>,
|
||||
time_attack: Option<&TimeAttackResource>,
|
||||
font_res: Option<&FontResource>,
|
||||
replays: &[Replay],
|
||||
selected_index: usize,
|
||||
) {
|
||||
// --- primary stat cells ---
|
||||
// First-launch zero-state: when no games have been played yet, render
|
||||
@@ -756,9 +699,7 @@ fn spawn_stats_screen(
|
||||
..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
|
||||
// multiple sections (per-mode bests, progression, weekly goals,
|
||||
// unlocks, optional Time Attack, latest replay caption) and
|
||||
@@ -778,25 +719,17 @@ fn spawn_stats_screen(
|
||||
},
|
||||
))
|
||||
.with_children(|body| {
|
||||
// First-launch caption — sits above the grid as gentle nudge so
|
||||
// First-launch empty state (Phase L) — sits above the grid so
|
||||
// the wall of em-dashes reads as "nothing to track yet" rather
|
||||
// than as broken state.
|
||||
if is_first_launch {
|
||||
body.spawn((
|
||||
Text::new("Play a game to start tracking stats."),
|
||||
TextFont {
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_SECONDARY),
|
||||
Node {
|
||||
margin: UiRect {
|
||||
bottom: VAL_SPACE_2,
|
||||
..default()
|
||||
},
|
||||
..default()
|
||||
},
|
||||
));
|
||||
crate::ui_modal::spawn_empty_state(
|
||||
body,
|
||||
"\u{2663}",
|
||||
"Play a game to start tracking stats.",
|
||||
None,
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
|
||||
// --- primary stat cells grid ---
|
||||
@@ -935,7 +868,59 @@ fn spawn_stats_screen(
|
||||
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 ---
|
||||
// Prev / Next chips step through the full replay history;
|
||||
// `repaint_replay_selector_caption` and
|
||||
@@ -1019,12 +1004,21 @@ fn spawn_stats_screen(
|
||||
));
|
||||
});
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
// The Watch Replay button is always rendered so the
|
||||
// affordance is discoverable from a fresh install. When no
|
||||
// replay exists, the click handler surfaces a clear
|
||||
// "No replay recorded yet" toast rather than silently
|
||||
// doing nothing.
|
||||
// Tab-local actions. The Watch Replay button is always
|
||||
// rendered so the affordance is discoverable from a fresh
|
||||
// install — with no replay, its click handler surfaces a
|
||||
// "No replay recorded yet" toast rather than silently doing
|
||||
// nothing. Same policy for Copy share link (toast explains
|
||||
// when no shareable upload exists).
|
||||
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(
|
||||
actions,
|
||||
WatchReplayButton,
|
||||
@@ -1033,11 +1027,6 @@ fn spawn_stats_screen(
|
||||
ButtonVariant::Secondary,
|
||||
font_res,
|
||||
);
|
||||
// Copy share link only renders when a sharable URL is in
|
||||
// hand. The button is intentionally absent (rather than
|
||||
// disabled) when no upload has happened yet — keeps the
|
||||
// action bar free of dead controls in the local-only and
|
||||
// first-launch cases.
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
CopyShareLinkButton,
|
||||
@@ -1046,18 +1035,8 @@ fn spawn_stats_screen(
|
||||
ButtonVariant::Secondary,
|
||||
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
|
||||
@@ -1290,6 +1269,7 @@ mod tests {
|
||||
// ProgressResource is an optional dependency for the stats screen;
|
||||
// include it so toggle tests exercise the progression panel.
|
||||
app.add_plugins(crate::progress_plugin::ProgressPlugin::headless());
|
||||
app.add_plugins(crate::you_hub_plugin::YouHubPlugin);
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
@@ -1500,10 +1480,8 @@ mod tests {
|
||||
#[test]
|
||||
fn zen_win_event_updates_zen_best_score_only() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.mode = solitaire_core::game_state::GameMode::Zen;
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.mode =
|
||||
solitaire_core::game_state::GameMode::Zen;
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 1800,
|
||||
@@ -1834,6 +1812,25 @@ mod 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]
|
||||
fn selector_row_spawns_when_stats_screen_opens() {
|
||||
let mut app = headless_app();
|
||||
@@ -1842,10 +1839,7 @@ mod tests {
|
||||
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
|
||||
hist.0.replays.push(make_test_replay(90, None));
|
||||
}
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyS);
|
||||
app.update();
|
||||
open_replays_tab(&mut app);
|
||||
|
||||
let prev = app
|
||||
.world_mut()
|
||||
@@ -1880,10 +1874,7 @@ mod tests {
|
||||
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
|
||||
hist.0.replays.push(make_test_replay(120, None));
|
||||
}
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyS);
|
||||
app.update();
|
||||
open_replays_tab(&mut app);
|
||||
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
@@ -1903,10 +1894,7 @@ mod tests {
|
||||
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
|
||||
hist.0.replays.push(make_test_replay(65, None)); // 65s → "1:05"
|
||||
}
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyS);
|
||||
app.update();
|
||||
open_replays_tab(&mut app);
|
||||
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
@@ -1957,7 +1945,11 @@ mod tests {
|
||||
time_seconds,
|
||||
0,
|
||||
date,
|
||||
vec![],
|
||||
solitaire_core::SessionRecording::from_instructions_unchecked(
|
||||
1,
|
||||
solitaire_core::DrawStockConfig::DrawOne,
|
||||
[],
|
||||
),
|
||||
);
|
||||
r.share_url = share_url;
|
||||
r
|
||||
|
||||
@@ -22,12 +22,11 @@ use solitaire_data::{
|
||||
AchievementRecord, PlayerProgress, Replay, StatsSnapshot, SyncError, SyncProvider,
|
||||
save_achievements_to, save_progress_to, save_replay_history_to, save_stats_to,
|
||||
};
|
||||
use solitaire_sync::{SyncPayload, SyncResponse, merge};
|
||||
use solitaire_sync::{SyncPayload, merge};
|
||||
|
||||
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
|
||||
use crate::events::{
|
||||
GameWonEvent, ManualSyncRequestEvent, SyncCompleteEvent, SyncConfigureRequestEvent,
|
||||
WarningToastEvent,
|
||||
GameWonEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent, WarningToastEvent,
|
||||
};
|
||||
use crate::game_plugin::RecordingReplay;
|
||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
||||
@@ -108,7 +107,6 @@ impl Plugin for SyncPlugin {
|
||||
.init_resource::<PullTask>()
|
||||
.init_resource::<PendingReplayUpload>()
|
||||
.add_message::<ManualSyncRequestEvent>()
|
||||
.add_message::<SyncCompleteEvent>()
|
||||
.add_message::<SyncConfigureRequestEvent>()
|
||||
.add_message::<WarningToastEvent>();
|
||||
|
||||
@@ -198,7 +196,6 @@ fn poll_pull_result(
|
||||
achievements_path: Res<AchievementsStoragePath>,
|
||||
mut progress: ResMut<ProgressResource>,
|
||||
progress_path: Res<ProgressStoragePath>,
|
||||
mut complete_writer: MessageWriter<SyncCompleteEvent>,
|
||||
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||
) {
|
||||
@@ -213,7 +210,7 @@ fn poll_pull_result(
|
||||
match result {
|
||||
Ok(remote) => {
|
||||
let local = build_payload(&stats.0, &achievements.0, &progress.0);
|
||||
let (merged, conflicts) = merge(&local, &remote);
|
||||
let (merged, _conflicts) = merge(&local, &remote);
|
||||
|
||||
// Persist merged state atomically.
|
||||
if let Some(p) = &stats_path.0
|
||||
@@ -233,17 +230,10 @@ fn poll_pull_result(
|
||||
}
|
||||
|
||||
// Update in-world resources.
|
||||
let now = Utc::now();
|
||||
stats.0 = merged.stats.clone();
|
||||
achievements.0 = merged.achievements.clone();
|
||||
progress.0 = merged.progress.clone();
|
||||
status.0 = SyncStatus::LastSynced(now);
|
||||
|
||||
complete_writer.write(SyncCompleteEvent(Ok(SyncResponse {
|
||||
merged,
|
||||
server_time: now,
|
||||
conflicts,
|
||||
})));
|
||||
stats.0 = merged.stats;
|
||||
achievements.0 = merged.achievements;
|
||||
progress.0 = merged.progress;
|
||||
status.0 = SyncStatus::LastSynced(Utc::now());
|
||||
}
|
||||
Err(SyncError::UnsupportedPlatform) => {
|
||||
// No backend configured — not an error, just leave status as Idle.
|
||||
@@ -266,8 +256,7 @@ fn poll_pull_result(
|
||||
if matches!(e, SyncError::Auth(_)) {
|
||||
configure_sync.write(SyncConfigureRequestEvent);
|
||||
}
|
||||
status.0 = SyncStatus::Error(msg.clone());
|
||||
complete_writer.write(SyncCompleteEvent(Err(msg)));
|
||||
status.0 = SyncStatus::Error(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,9 +290,9 @@ fn push_on_exit(
|
||||
exit_events.clear();
|
||||
|
||||
let payload = build_payload(&stats.0, &achievements.0, &progress.0);
|
||||
let result = rt
|
||||
.0
|
||||
.block_on(async { tokio::time::timeout(EXIT_PUSH_TIMEOUT, provider.0.push(&payload)).await });
|
||||
let result = rt.0.block_on(async {
|
||||
tokio::time::timeout(EXIT_PUSH_TIMEOUT, provider.0.push(&payload)).await
|
||||
});
|
||||
match result {
|
||||
Ok(Ok(_)) | Ok(Err(SyncError::UnsupportedPlatform)) => {}
|
||||
Ok(Err(e)) => warn!("sync push on exit failed: {e}"),
|
||||
@@ -346,7 +335,11 @@ fn push_replay_on_win(
|
||||
ev.time_seconds,
|
||||
ev.score,
|
||||
Utc::now().date_naive(),
|
||||
recording.moves.clone(),
|
||||
// The live session is the authoritative recording; it
|
||||
// serialises via the upstream card_game serializers so web
|
||||
// playback rebuilds the exact deal instead of re-dealing
|
||||
// the seed.
|
||||
game.0.recording(),
|
||||
);
|
||||
let provider = provider.0.clone();
|
||||
let rt = rt.0.clone();
|
||||
@@ -632,7 +625,11 @@ mod tests {
|
||||
60,
|
||||
500,
|
||||
chrono::NaiveDate::from_ymd_opt(2026, 5, 6).expect("valid date"),
|
||||
vec![],
|
||||
solitaire_core::SessionRecording::from_instructions_unchecked(
|
||||
7,
|
||||
DrawStockConfig::DrawOne,
|
||||
[],
|
||||
),
|
||||
);
|
||||
let history = ReplayHistory {
|
||||
schema_version: solitaire_data::REPLAY_HISTORY_SCHEMA_VERSION,
|
||||
@@ -667,9 +664,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// In-memory contract: replays[0].share_url is now Some(url).
|
||||
let live = app
|
||||
.world()
|
||||
.resource::<ReplayHistoryResource>();
|
||||
let live = app.world().resource::<ReplayHistoryResource>();
|
||||
assert_eq!(
|
||||
live.0.replays.first().and_then(|r| r.share_url.clone()),
|
||||
Some(url.clone()),
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::WindowResized;
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_core::{FOUNDATIONS, TABLEAUS};
|
||||
use solitaire_core::Suit;
|
||||
use solitaire_core::{FOUNDATIONS, TABLEAUS};
|
||||
|
||||
use crate::events::{HintVisualEvent, StateChangedEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
@@ -385,7 +385,11 @@ fn on_window_resized(
|
||||
>,
|
||||
mut marker_outlines: Query<
|
||||
&mut Sprite,
|
||||
(Without<PileMarker>, Without<TableBackground>, Without<Text2d>),
|
||||
(
|
||||
Without<PileMarker>,
|
||||
Without<TableBackground>,
|
||||
Without<Text2d>,
|
||||
),
|
||||
>,
|
||||
mut marker_labels: Query<&mut TextFont, With<Text2d>>,
|
||||
) {
|
||||
@@ -437,8 +441,7 @@ fn on_window_resized(
|
||||
// must be re-derived here too or a resize (fold/unfold, rotation)
|
||||
// leaves them at the stale size — visible as oversized grey
|
||||
// frames on empty piles.
|
||||
let outline_size =
|
||||
new_layout.card_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
|
||||
let outline_size = new_layout.card_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
|
||||
let font_size = new_layout.card_size.x * 0.28;
|
||||
for child in children.into_iter().flatten() {
|
||||
if let Ok(mut outline) = marker_outlines.get_mut(*child) {
|
||||
@@ -593,8 +596,6 @@ fn pile_cards(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -703,7 +704,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
assert_eq!(outlines, 12, "all 12 markers carry an outline child");
|
||||
assert!(labels >= 11, "tableau + foundation markers carry watermarks");
|
||||
assert!(
|
||||
labels >= 11,
|
||||
"tableau + foundation markers carry watermarks"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -939,10 +943,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn suit_symbol_all_four_are_distinct() {
|
||||
let symbols: Vec<&str> = Suit::SUITS
|
||||
.iter()
|
||||
.map(suit_symbol)
|
||||
.collect();
|
||||
let symbols: Vec<&str> = Suit::SUITS.iter().map(suit_symbol).collect();
|
||||
let unique: std::collections::HashSet<&&str> = symbols.iter().collect();
|
||||
assert_eq!(unique.len(), 4, "all four suit symbols must be distinct");
|
||||
}
|
||||
|
||||
@@ -116,24 +116,32 @@ impl Plugin for ThemePlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the manifest URL for `theme_id`: the bundled themes
|
||||
/// (`"dark"`, `"classic"`) load from the embedded source; any other id
|
||||
/// is a user theme served from the `themes://` directory source.
|
||||
fn theme_manifest_url(theme_id: &str) -> String {
|
||||
bundled_theme_url(theme_id)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("themes://{theme_id}/theme.ron"))
|
||||
}
|
||||
|
||||
/// Kicks off the initial theme load — the one named by
|
||||
/// `Settings::selected_theme_id` if available, falling back to the
|
||||
/// embedded default. The actual rasterisation runs asynchronously on
|
||||
/// the asset task pool; the sync system below picks up the
|
||||
/// `LoadedWithDependencies` event when every face + back is ready.
|
||||
/// fresh-install default from `solitaire_data::Settings`. The actual
|
||||
/// rasterisation runs asynchronously on the asset task pool; the sync
|
||||
/// system below picks up the `LoadedWithDependencies` event when every
|
||||
/// face + back is ready.
|
||||
fn load_initial_theme(
|
||||
asset_server: Res<AssetServer>,
|
||||
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let default_id = solitaire_data::Settings::default().selected_theme_id;
|
||||
let id = settings
|
||||
.as_deref()
|
||||
.map(|s| s.0.selected_theme_id.as_str())
|
||||
.unwrap_or("classic");
|
||||
let url = bundled_theme_url(id)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("themes://{id}/theme.ron"));
|
||||
let handle: Handle<CardTheme> = asset_server.load(url);
|
||||
.unwrap_or(&default_id);
|
||||
let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(id));
|
||||
commands.insert_resource(ActiveTheme(handle));
|
||||
}
|
||||
|
||||
@@ -162,10 +170,7 @@ fn react_to_settings_theme_change(
|
||||
return;
|
||||
}
|
||||
|
||||
let url = bundled_theme_url(new_id)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("themes://{new_id}/theme.ron"));
|
||||
let handle: Handle<CardTheme> = asset_server.load(url);
|
||||
let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(new_id));
|
||||
commands.insert_resource(ActiveTheme(handle));
|
||||
}
|
||||
|
||||
@@ -252,12 +257,11 @@ fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet
|
||||
image_set.theme_back = Some(theme.back.clone());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Switches the active theme to the one served at
|
||||
/// `themes://<theme_id>/theme.ron`. Returns the new `Handle<CardTheme>`
|
||||
/// so callers can poll `Assets<CardTheme>` if they want to wait for
|
||||
/// the load before changing UI state.
|
||||
/// Switches the active theme to `theme_id` — the embedded source for
|
||||
/// the bundled `"dark"` / `"classic"` themes, `themes://` for user
|
||||
/// themes. Returns the new `Handle<CardTheme>` so callers can poll
|
||||
/// `Assets<CardTheme>` if they want to wait for the load before
|
||||
/// changing UI state.
|
||||
///
|
||||
/// The handle is also written to the [`ActiveTheme`] resource — the
|
||||
/// per-frame sync system picks up the `LoadedWithDependencies` event
|
||||
@@ -268,8 +272,7 @@ pub fn set_theme(
|
||||
asset_server: &AssetServer,
|
||||
theme_id: &str,
|
||||
) -> Handle<CardTheme> {
|
||||
let url = format!("themes://{theme_id}/theme.ron");
|
||||
let handle: Handle<CardTheme> = asset_server.load(url);
|
||||
let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(theme_id));
|
||||
commands.insert_resource(ActiveTheme(handle.clone()));
|
||||
handle
|
||||
}
|
||||
@@ -477,13 +480,43 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_theme_url_format_matches_themes_source() {
|
||||
// The format string is the only behavioural surface of
|
||||
// set_theme that doesn't require an App. We assert the URL
|
||||
// shape so a future refactor doesn't accidentally change the
|
||||
// path layout.
|
||||
let url = format!("themes://{}/theme.ron", "user_uploaded");
|
||||
assert_eq!(url, "themes://user_uploaded/theme.ron");
|
||||
fn theme_manifest_url_routes_bundled_ids_to_embedded_source() {
|
||||
// The bundled themes live in the binary, not in the user
|
||||
// themes directory — resolving them to `themes://` would
|
||||
// NotFound and silently leave the previous theme active.
|
||||
assert_eq!(
|
||||
theme_manifest_url("dark"),
|
||||
crate::assets::DARK_THEME_MANIFEST_URL
|
||||
);
|
||||
assert_eq!(
|
||||
theme_manifest_url("classic"),
|
||||
crate::assets::CLASSIC_THEME_MANIFEST_URL
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_manifest_url_routes_user_ids_to_themes_source() {
|
||||
// We assert the URL shape so a future refactor doesn't
|
||||
// accidentally change the path layout of the user-themes
|
||||
// source.
|
||||
assert_eq!(
|
||||
theme_manifest_url("user_uploaded"),
|
||||
"themes://user_uploaded/theme.ron"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_theme_fallback_matches_settings_default() {
|
||||
// `load_initial_theme` falls back to the data-crate default
|
||||
// when `SettingsResource` is absent. That default must always
|
||||
// name a bundled theme, or a fresh minimal setup would resolve
|
||||
// to a nonexistent user theme (see the v0.33.0 "classic"
|
||||
// fallback drift in CHANGELOG.md).
|
||||
let default_id = solitaire_data::Settings::default().selected_theme_id;
|
||||
assert!(
|
||||
bundled_theme_url(&default_id).is_some(),
|
||||
"default theme id {default_id:?} must be a bundled theme"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test 1: the bundled dark theme always has embedded SVG bytes
|
||||
|
||||
@@ -0,0 +1,906 @@
|
||||
//! In-game theme store: browse the sync server's theme catalog and
|
||||
//! install themes without leaving the app.
|
||||
//!
|
||||
//! Settings → Cosmetic → "Browse theme store" fires
|
||||
//! [`crate::events::ThemeStoreOpenRequestEvent`]; this plugin opens a
|
||||
//! modal listing the catalog (fetched on [`AsyncComputeTaskPool`]) and
|
||||
//! handles per-theme Install buttons. An install downloads the archive
|
||||
//! (SHA-256-verified by [`ThemeStoreClient`]), writes it atomically
|
||||
//! into `user_theme_dir()`, and runs it through the same hardened
|
||||
//! [`import_theme`] pipeline as a manually dropped zip — the store
|
||||
//! never bypasses the importer's validation.
|
||||
//!
|
||||
//! Native-only: downloads need `reqwest`/Tokio and the importer is
|
||||
//! filesystem-based; the plugin is gated out on wasm32 alongside
|
||||
//! `SyncPlugin`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bevy::asset::RenderAssetUsages;
|
||||
use bevy::prelude::*;
|
||||
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
||||
use thiserror::Error;
|
||||
|
||||
use solitaire_data::theme_store_client::{ThemeStoreClient, ThemeStoreError};
|
||||
use solitaire_data::{Settings, SyncBackend};
|
||||
use solitaire_sync::ThemeCatalogEntry;
|
||||
|
||||
use crate::assets::user_theme_dir;
|
||||
use crate::events::{InfoToastEvent, ThemeStoreOpenRequestEvent, WarningToastEvent};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::resources::TokioRuntimeResource;
|
||||
use crate::settings_plugin::{SettingsPanel, SettingsResource};
|
||||
use crate::theme::{ImportError, ThemeRegistry, import_theme, refresh_registry};
|
||||
use crate::ui_modal::{
|
||||
ButtonVariant, ModalScrim, ScrimDismissible, spawn_empty_state, spawn_modal,
|
||||
spawn_modal_actions, spawn_modal_button, spawn_modal_header,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3,
|
||||
Z_MODAL_PANEL,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Everything that can go wrong between clicking Install and the theme
|
||||
/// appearing in the picker.
|
||||
#[derive(Debug, Error)]
|
||||
enum ThemeInstallError {
|
||||
/// Catalog fetch or archive download failed (network, HTTP status,
|
||||
/// size/checksum verification).
|
||||
#[error(transparent)]
|
||||
Download(#[from] ThemeStoreError),
|
||||
/// The verified archive could not be written into the themes dir.
|
||||
#[error("could not save archive: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
/// The downloaded archive failed importer validation.
|
||||
#[error(transparent)]
|
||||
Import(#[from] ImportError),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Components & resources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Marker on the theme-store modal's scrim root.
|
||||
#[derive(Component)]
|
||||
pub struct ThemeStoreScreen;
|
||||
|
||||
/// Marker on the modal's Close button.
|
||||
#[derive(Component)]
|
||||
struct ThemeStoreCloseButton;
|
||||
|
||||
/// Per-row Install button carrying its catalog entry.
|
||||
#[derive(Component)]
|
||||
struct ThemeStoreInstallButton(ThemeCatalogEntry);
|
||||
|
||||
/// Catalog fetch lifecycle. The modal renders directly from this state
|
||||
/// and is rebuilt (despawn + respawn, mirroring the leaderboard panel)
|
||||
/// whenever it changes.
|
||||
#[derive(Resource, Default)]
|
||||
enum CatalogState {
|
||||
/// No fetch has run since the modal was last opened.
|
||||
#[default]
|
||||
Idle,
|
||||
Loading,
|
||||
Loaded(Vec<ThemeCatalogEntry>),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// In-flight catalog fetch, polled without blocking.
|
||||
#[derive(Resource, Default)]
|
||||
struct CatalogTask(Option<Task<Result<Vec<ThemeCatalogEntry>, ThemeStoreError>>>);
|
||||
|
||||
/// Result of one download + import: `Ok(Some(id))` on a fresh install,
|
||||
/// `Ok(None)` when the theme was already installed (id collision) —
|
||||
/// informational, not an error.
|
||||
type InstallResult = Result<Option<String>, ThemeInstallError>;
|
||||
|
||||
/// In-flight download + import. `String` is the theme id, for toasts.
|
||||
#[derive(Resource, Default)]
|
||||
struct InstallTask(Option<(String, Task<InstallResult>)>);
|
||||
|
||||
/// Server base URL captured when the modal opens. Catalog
|
||||
/// `download_url`s are server-relative, so installs join them onto
|
||||
/// this. `None` while the modal has never been opened.
|
||||
#[derive(Resource, Default)]
|
||||
struct StoreBaseUrl(Option<String>);
|
||||
|
||||
/// Decoded preview thumbnails by theme id (Phase H). Session-lifetime:
|
||||
/// previews survive closing and reopening the store, so a revisit
|
||||
/// renders instantly without refetching.
|
||||
#[derive(Resource, Default)]
|
||||
struct PreviewCache(HashMap<String, Handle<Image>>);
|
||||
|
||||
/// One in-flight preview download: raw PNG bytes or the fetch error.
|
||||
type PreviewFetch = Task<Result<Vec<u8>, ThemeStoreError>>;
|
||||
|
||||
/// In-flight preview downloads, one per catalog entry that advertises
|
||||
/// a `preview_url` not already in [`PreviewCache`]. `String` is the
|
||||
/// theme id the bytes belong to.
|
||||
#[derive(Resource, Default)]
|
||||
struct PreviewTasks(Vec<(String, PreviewFetch)>);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Bevy plugin for the in-game theme store. See the module docs.
|
||||
pub struct ThemeStorePlugin;
|
||||
|
||||
impl Plugin for ThemeStorePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<CatalogState>()
|
||||
.init_resource::<CatalogTask>()
|
||||
.init_resource::<InstallTask>()
|
||||
.init_resource::<StoreBaseUrl>()
|
||||
.init_resource::<PreviewCache>()
|
||||
.init_resource::<PreviewTasks>()
|
||||
// Esc-close reads keyboard input; register defensively so
|
||||
// the plugin works under MinimalPlugins in tests.
|
||||
.init_resource::<ButtonInput<KeyCode>>()
|
||||
.add_message::<ThemeStoreOpenRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<WarningToastEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
handle_open_request,
|
||||
poll_catalog_task,
|
||||
poll_preview_tasks,
|
||||
handle_install_buttons,
|
||||
poll_install_task,
|
||||
handle_close_button,
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Systems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Resolves the store's server base URL from the player's sync
|
||||
/// backend. The store rides the same self-hosted server as sync;
|
||||
/// without one configured there is nothing to browse.
|
||||
fn store_base_url(settings: &Settings) -> Option<String> {
|
||||
match &settings.sync_backend {
|
||||
SyncBackend::SolitaireServer { url, .. } => Some(url.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the store modal and kicks off the catalog fetch.
|
||||
///
|
||||
/// Mirrors `open_sync_setup_modal`: the Settings button closes the
|
||||
/// settings panel in the same frame it fires the event, and despawns
|
||||
/// are deferred, so the settings scrim is excluded from the
|
||||
/// another-modal-is-open guard.
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||
fn handle_open_request(
|
||||
mut events: MessageReader<ThemeStoreOpenRequestEvent>,
|
||||
existing: Query<(), With<ThemeStoreScreen>>,
|
||||
other_modal_scrims: Query<
|
||||
(),
|
||||
(
|
||||
With<ModalScrim>,
|
||||
Without<ThemeStoreScreen>,
|
||||
Without<SettingsPanel>,
|
||||
),
|
||||
>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
rt: Option<Res<TokioRuntimeResource>>,
|
||||
mut catalog_state: ResMut<CatalogState>,
|
||||
mut catalog_task: ResMut<CatalogTask>,
|
||||
mut store_base: ResMut<StoreBaseUrl>,
|
||||
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||
previews: Res<PreviewCache>,
|
||||
mut commands: Commands,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
events.clear();
|
||||
if !existing.is_empty() || !other_modal_scrims.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(base_url) = settings.as_deref().and_then(|s| store_base_url(&s.0)) else {
|
||||
warning_toast.write(WarningToastEvent(
|
||||
"Theme store needs a server — connect one in Settings → Sync.".to_string(),
|
||||
));
|
||||
return;
|
||||
};
|
||||
let Some(rt) = rt else {
|
||||
warning_toast.write(WarningToastEvent(
|
||||
"Theme store unavailable — no network runtime.".to_string(),
|
||||
));
|
||||
return;
|
||||
};
|
||||
|
||||
store_base.0 = Some(base_url.clone());
|
||||
*catalog_state = CatalogState::Loading;
|
||||
let rt = rt.0.clone();
|
||||
catalog_task.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
|
||||
rt.block_on(async { ThemeStoreClient::new(base_url).fetch_catalog().await })
|
||||
}));
|
||||
|
||||
spawn_store_modal(
|
||||
&mut commands,
|
||||
&catalog_state,
|
||||
None,
|
||||
&previews,
|
||||
font_res.as_deref(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Polls the catalog fetch; on completion updates [`CatalogState`],
|
||||
/// kicks off preview downloads for entries not yet in the cache, and
|
||||
/// rebuilds the modal if it is still open.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn poll_catalog_task(
|
||||
mut catalog_task: ResMut<CatalogTask>,
|
||||
mut catalog_state: ResMut<CatalogState>,
|
||||
screens: Query<Entity, With<ThemeStoreScreen>>,
|
||||
registry: Option<Res<ThemeRegistry>>,
|
||||
rt: Option<Res<TokioRuntimeResource>>,
|
||||
store_base: Res<StoreBaseUrl>,
|
||||
previews: Res<PreviewCache>,
|
||||
mut preview_tasks: ResMut<PreviewTasks>,
|
||||
mut commands: Commands,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
let Some(task) = catalog_task.0.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(result) = future::block_on(future::poll_once(task)) else {
|
||||
return;
|
||||
};
|
||||
catalog_task.0 = None;
|
||||
|
||||
*catalog_state = match result {
|
||||
Ok(entries) => {
|
||||
// Fetch previews for anything new. Decorative — failures
|
||||
// just leave the row text-only, so errors only log.
|
||||
if let (Some(rt), Some(base_url)) = (rt.as_ref(), store_base.0.as_deref()) {
|
||||
for entry in entries
|
||||
.iter()
|
||||
.filter(|e| e.preview_url.is_some() && !previews.0.contains_key(&e.id))
|
||||
{
|
||||
let rt = rt.0.clone();
|
||||
let base_url = base_url.to_owned();
|
||||
let entry = entry.clone();
|
||||
preview_tasks.0.push((
|
||||
entry.id.clone(),
|
||||
AsyncComputeTaskPool::get().spawn(async move {
|
||||
rt.block_on(async {
|
||||
ThemeStoreClient::new(base_url).fetch_preview(&entry).await
|
||||
})
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
CatalogState::Loaded(entries)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("theme store: catalog fetch failed: {e}");
|
||||
CatalogState::Error(e.to_string())
|
||||
}
|
||||
};
|
||||
|
||||
rebuild_open_modal(
|
||||
&screens,
|
||||
&mut commands,
|
||||
&catalog_state,
|
||||
registry.as_deref(),
|
||||
&previews,
|
||||
font_res.as_deref(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Polls in-flight preview downloads. Each finished PNG is decoded into
|
||||
/// an [`Image`] asset and cached by theme id; the open modal rebuilds
|
||||
/// once per frame that added at least one preview, so thumbnails pop in
|
||||
/// as they arrive. Failures log and leave the row text-only. Skipped
|
||||
/// entirely when `Assets<Image>` is absent (`MinimalPlugins` tests).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn poll_preview_tasks(
|
||||
mut preview_tasks: ResMut<PreviewTasks>,
|
||||
mut previews: ResMut<PreviewCache>,
|
||||
images: Option<ResMut<Assets<Image>>>,
|
||||
catalog_state: Res<CatalogState>,
|
||||
screens: Query<Entity, With<ThemeStoreScreen>>,
|
||||
registry: Option<Res<ThemeRegistry>>,
|
||||
mut commands: Commands,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
if preview_tasks.0.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(mut images) = images else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut added = false;
|
||||
preview_tasks.0.retain_mut(|(id, task)| {
|
||||
let Some(result) = future::block_on(future::poll_once(task)) else {
|
||||
return true; // still downloading
|
||||
};
|
||||
match result {
|
||||
Ok(bytes) => match image::load_from_memory(&bytes) {
|
||||
Ok(dyn_img) => {
|
||||
let handle = images.add(Image::from_dynamic(
|
||||
dyn_img,
|
||||
true,
|
||||
RenderAssetUsages::RENDER_WORLD,
|
||||
));
|
||||
previews.0.insert(id.clone(), handle);
|
||||
added = true;
|
||||
}
|
||||
Err(e) => warn!("theme store: preview for '{id}' failed to decode: {e}"),
|
||||
},
|
||||
Err(e) => warn!("theme store: preview fetch for '{id}' failed: {e}"),
|
||||
}
|
||||
false
|
||||
});
|
||||
|
||||
if added {
|
||||
rebuild_open_modal(
|
||||
&screens,
|
||||
&mut commands,
|
||||
&catalog_state,
|
||||
registry.as_deref(),
|
||||
&previews,
|
||||
font_res.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a download + import task when an Install button is pressed.
|
||||
/// One install at a time; repeat clicks while busy are ignored.
|
||||
fn handle_install_buttons(
|
||||
interactions: Query<(&Interaction, &ThemeStoreInstallButton), Changed<Interaction>>,
|
||||
rt: Option<Res<TokioRuntimeResource>>,
|
||||
store_base: Res<StoreBaseUrl>,
|
||||
mut install_task: ResMut<InstallTask>,
|
||||
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
for (interaction, button) in &interactions {
|
||||
if *interaction != Interaction::Pressed || install_task.0.is_some() {
|
||||
continue;
|
||||
}
|
||||
let (Some(rt), Some(base_url)) = (rt.as_ref(), store_base.0.clone()) else {
|
||||
continue;
|
||||
};
|
||||
let entry = button.0.clone();
|
||||
info_toast.write(InfoToastEvent(format!("Downloading '{}'…", entry.name)));
|
||||
let rt = rt.0.clone();
|
||||
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||
let bytes = rt
|
||||
.block_on(async { ThemeStoreClient::new(base_url).download_theme(&entry).await })?;
|
||||
install_verified_archive(&entry.id, &bytes)
|
||||
});
|
||||
install_task.0 = Some((button.0.id.clone(), task));
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads land here (already SHA-256-verified): write the archive
|
||||
/// atomically into the user themes dir (`.tmp` + rename, §2.5), then
|
||||
/// run the standard importer. Returns `Ok(None)` when the theme was
|
||||
/// already installed.
|
||||
fn install_verified_archive(id: &str, bytes: &[u8]) -> Result<Option<String>, ThemeInstallError> {
|
||||
let themes_dir = user_theme_dir();
|
||||
std::fs::create_dir_all(&themes_dir)?;
|
||||
let final_path = themes_dir.join(format!("{id}.zip"));
|
||||
let tmp_path = themes_dir.join(format!("{id}.zip.tmp"));
|
||||
std::fs::write(&tmp_path, bytes)?;
|
||||
std::fs::rename(&tmp_path, &final_path)?;
|
||||
|
||||
match import_theme(&final_path) {
|
||||
Ok(theme_id) => Ok(Some(theme_id.as_str().to_string())),
|
||||
Err(ImportError::IdCollision { .. }) => Ok(None),
|
||||
Err(e) => {
|
||||
// Don't leave a zip that fails validation lying around for
|
||||
// the folder-scan flow to re-try on every scan.
|
||||
let _ = std::fs::remove_file(&final_path);
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls the install task; on completion refreshes the theme registry,
|
||||
/// toasts the outcome, and rebuilds the modal so the row flips to
|
||||
/// "Installed".
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn poll_install_task(
|
||||
mut install_task: ResMut<InstallTask>,
|
||||
mut registry: Option<ResMut<ThemeRegistry>>,
|
||||
catalog_state: Res<CatalogState>,
|
||||
screens: Query<Entity, With<ThemeStoreScreen>>,
|
||||
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||
previews: Res<PreviewCache>,
|
||||
mut commands: Commands,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
let Some((id, task)) = install_task.0.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(result) = future::block_on(future::poll_once(task)) else {
|
||||
return;
|
||||
};
|
||||
let id = id.clone();
|
||||
install_task.0 = None;
|
||||
|
||||
match result {
|
||||
Ok(Some(theme_id)) => {
|
||||
if let Some(registry) = registry.as_deref_mut() {
|
||||
refresh_registry(registry, &user_theme_dir());
|
||||
}
|
||||
info_toast.write(InfoToastEvent(format!(
|
||||
"Theme '{theme_id}' installed — pick it in Settings → Cosmetic."
|
||||
)));
|
||||
}
|
||||
Ok(None) => {
|
||||
info_toast.write(InfoToastEvent(format!("Theme '{id}' already installed.")));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("theme store: install of '{id}' failed: {e}");
|
||||
warning_toast.write(WarningToastEvent(format!("Install failed: {e}")));
|
||||
}
|
||||
}
|
||||
|
||||
rebuild_open_modal(
|
||||
&screens,
|
||||
&mut commands,
|
||||
&catalog_state,
|
||||
registry.as_deref(),
|
||||
&previews,
|
||||
font_res.as_deref(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Despawns the store modal when Close is pressed or on Esc (Phase C
|
||||
/// dismissal audit). The store only ever stacks over Settings and
|
||||
/// nothing stacks over the store, so it owns Esc whenever it is open
|
||||
/// (Settings' own Esc handler is gated on being topmost).
|
||||
fn handle_close_button(
|
||||
interactions: Query<&Interaction, (Changed<Interaction>, With<ThemeStoreCloseButton>)>,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
screens: Query<Entity, With<ThemeStoreScreen>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let clicked = interactions.iter().any(|i| *i == Interaction::Pressed);
|
||||
let esc = keys.just_pressed(KeyCode::Escape) && !screens.is_empty();
|
||||
if !clicked && !esc {
|
||||
return;
|
||||
}
|
||||
for entity in &screens {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modal rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Despawn + respawn the modal for every open screen (Bevy despawns
|
||||
/// are deferred, so this is safe within one frame).
|
||||
fn rebuild_open_modal(
|
||||
screens: &Query<Entity, With<ThemeStoreScreen>>,
|
||||
commands: &mut Commands,
|
||||
catalog_state: &CatalogState,
|
||||
registry: Option<&ThemeRegistry>,
|
||||
previews: &PreviewCache,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
for entity in screens {
|
||||
commands.entity(entity).despawn();
|
||||
spawn_store_modal(commands, catalog_state, registry, previews, font_res);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the store modal for the current catalog state.
|
||||
fn spawn_store_modal(
|
||||
commands: &mut Commands,
|
||||
catalog_state: &CatalogState,
|
||||
registry: Option<&ThemeRegistry>,
|
||||
previews: &PreviewCache,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let body_font = TextFont {
|
||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
};
|
||||
let caption_font = TextFont {
|
||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
|
||||
let scrim = spawn_modal(commands, ThemeStoreScreen, Z_MODAL_PANEL + 1, |card| {
|
||||
spawn_modal_header(card, "Theme Store", font_res);
|
||||
|
||||
match catalog_state {
|
||||
CatalogState::Idle | CatalogState::Loading => {
|
||||
spawn_empty_state(
|
||||
card,
|
||||
"\u{21BB}",
|
||||
"Loading the catalog\u{2026}",
|
||||
None,
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
CatalogState::Error(msg) => {
|
||||
spawn_empty_state(
|
||||
card,
|
||||
"!",
|
||||
"Couldn't load the catalog.",
|
||||
Some(msg.as_str()),
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
CatalogState::Loaded(entries) if entries.is_empty() => {
|
||||
spawn_empty_state(
|
||||
card,
|
||||
"#",
|
||||
"The server has no themes yet.",
|
||||
Some("Themes dropped into the server's theme_store folder appear here."),
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
CatalogState::Loaded(entries) => {
|
||||
for entry in entries {
|
||||
let installed =
|
||||
registry.is_some_and(|registry| registry.find(&entry.id).is_some());
|
||||
spawn_store_row(
|
||||
card,
|
||||
entry,
|
||||
installed,
|
||||
previews.0.get(&entry.id),
|
||||
&body_font,
|
||||
&caption_font,
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
ThemeStoreCloseButton,
|
||||
"Close",
|
||||
None,
|
||||
ButtonVariant::Primary,
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
});
|
||||
commands.entity(scrim).insert(ScrimDismissible);
|
||||
}
|
||||
|
||||
/// Height of a row's preview thumbnail in logical pixels; the width
|
||||
/// follows the theme's own `card_aspect` so a preview is never
|
||||
/// stretched.
|
||||
const PREVIEW_THUMB_HEIGHT_PX: f32 = 72.0;
|
||||
|
||||
/// One catalog row: preview thumbnail (when downloaded) + name +
|
||||
/// author/size caption on the left, Install button (or "Installed"
|
||||
/// caption) on the right.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn spawn_store_row(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
entry: &ThemeCatalogEntry,
|
||||
installed: bool,
|
||||
preview: Option<&Handle<Image>>,
|
||||
body_font: &TextFont,
|
||||
caption_font: &TextFont,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
parent
|
||||
.spawn((
|
||||
Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
justify_content: JustifyContent::SpaceBetween,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_3,
|
||||
padding: UiRect::vertical(VAL_SPACE_2),
|
||||
border: UiRect::bottom(Val::Px(1.0)),
|
||||
..default()
|
||||
},
|
||||
BorderColor::all(BORDER_SUBTLE),
|
||||
))
|
||||
.with_children(|row| {
|
||||
row.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_3,
|
||||
..default()
|
||||
})
|
||||
.with_children(|left| {
|
||||
if let Some(handle) = preview {
|
||||
let (aw, ah) = entry.card_aspect;
|
||||
let width = PREVIEW_THUMB_HEIGHT_PX * aw.max(1) as f32 / ah.max(1) as f32;
|
||||
left.spawn((
|
||||
ImageNode::new(handle.clone()),
|
||||
Node {
|
||||
width: Val::Px(width),
|
||||
height: Val::Px(PREVIEW_THUMB_HEIGHT_PX),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
}
|
||||
left.spawn(Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_2,
|
||||
..default()
|
||||
})
|
||||
.with_children(|col| {
|
||||
col.spawn((
|
||||
Text::new(entry.name.clone()),
|
||||
body_font.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
col.spawn((
|
||||
Text::new(format!(
|
||||
"by {} — {} KB",
|
||||
entry.author,
|
||||
entry.size_bytes.div_ceil(1024)
|
||||
)),
|
||||
caption_font.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
});
|
||||
});
|
||||
if installed {
|
||||
row.spawn((
|
||||
Text::new("Installed"),
|
||||
caption_font.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
} else {
|
||||
spawn_modal_button(
|
||||
row,
|
||||
ThemeStoreInstallButton(entry.clone()),
|
||||
"Install",
|
||||
None,
|
||||
ButtonVariant::Secondary,
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy::ecs::message::Messages;
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(ThemeStorePlugin);
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
fn settings_with_server() -> SettingsResource {
|
||||
SettingsResource(Settings {
|
||||
sync_backend: SyncBackend::SolitaireServer {
|
||||
url: "http://127.0.0.1:1".to_string(),
|
||||
username: "tester".to_string(),
|
||||
avatar_url: None,
|
||||
},
|
||||
..Settings::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn screen_count(app: &mut App) -> usize {
|
||||
app.world_mut()
|
||||
.query::<&ThemeStoreScreen>()
|
||||
.iter(app.world())
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_base_url_requires_server_backend() {
|
||||
assert_eq!(store_base_url(&Settings::default()), None);
|
||||
assert_eq!(
|
||||
store_base_url(&settings_with_server().0).as_deref(),
|
||||
Some("http://127.0.0.1:1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_request_without_server_warns_and_spawns_nothing() {
|
||||
let mut app = headless_app();
|
||||
// Default settings → SyncBackend::Local → no store.
|
||||
app.insert_resource(SettingsResource(Settings::default()));
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
|
||||
.write(ThemeStoreOpenRequestEvent);
|
||||
app.update();
|
||||
|
||||
assert_eq!(screen_count(&mut app), 0);
|
||||
assert!(
|
||||
!app.world()
|
||||
.resource::<Messages<WarningToastEvent>>()
|
||||
.is_empty(),
|
||||
"player must be told why the store did not open"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_request_without_runtime_warns_and_spawns_nothing() {
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(settings_with_server());
|
||||
// No TokioRuntimeResource inserted.
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
|
||||
.write(ThemeStoreOpenRequestEvent);
|
||||
app.update();
|
||||
|
||||
assert_eq!(screen_count(&mut app), 0);
|
||||
assert!(
|
||||
!app.world()
|
||||
.resource::<Messages<WarningToastEvent>>()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_request_with_server_spawns_modal_in_loading_state() {
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(settings_with_server());
|
||||
app.insert_resource(TokioRuntimeResource::new().expect("test runtime"));
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
|
||||
.write(ThemeStoreOpenRequestEvent);
|
||||
app.update();
|
||||
|
||||
assert_eq!(screen_count(&mut app), 1);
|
||||
assert!(matches!(
|
||||
*app.world().resource::<CatalogState>(),
|
||||
CatalogState::Loading | CatalogState::Error(_)
|
||||
));
|
||||
assert_eq!(
|
||||
app.world().resource::<StoreBaseUrl>().0.as_deref(),
|
||||
Some("http://127.0.0.1:1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_open_request_does_not_stack_a_second_modal() {
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(settings_with_server());
|
||||
app.insert_resource(TokioRuntimeResource::new().expect("test runtime"));
|
||||
for _ in 0..2 {
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
|
||||
.write(ThemeStoreOpenRequestEvent);
|
||||
app.update();
|
||||
}
|
||||
assert_eq!(screen_count(&mut app), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_catalog_fetch_rebuilds_modal_with_error_state() {
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(settings_with_server());
|
||||
app.insert_resource(TokioRuntimeResource::new().expect("test runtime"));
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
|
||||
.write(ThemeStoreOpenRequestEvent);
|
||||
app.update();
|
||||
|
||||
// 127.0.0.1:1 refuses connections; the fetch task must resolve
|
||||
// to an error within a bounded number of frames and the modal
|
||||
// must survive the rebuild.
|
||||
for _ in 0..200 {
|
||||
app.update();
|
||||
if matches!(
|
||||
*app.world().resource::<CatalogState>(),
|
||||
CatalogState::Error(_)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
assert!(matches!(
|
||||
*app.world().resource::<CatalogState>(),
|
||||
CatalogState::Error(_)
|
||||
));
|
||||
assert_eq!(screen_count(&mut app), 1);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase H part 2: preview thumbnails
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn catalog_entry(id: &str, preview: bool) -> ThemeCatalogEntry {
|
||||
ThemeCatalogEntry {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
author: "Test".into(),
|
||||
version: "1.0.0".into(),
|
||||
card_aspect: (2, 3),
|
||||
size_bytes: 1024,
|
||||
sha256: "00".into(),
|
||||
download_url: format!("/api/themes/{id}/download"),
|
||||
preview_url: preview.then(|| format!("/api/themes/{id}/preview")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A row whose theme id is in the [`PreviewCache`] renders an
|
||||
/// `ImageNode` thumbnail; a row without one stays text-only.
|
||||
#[test]
|
||||
fn loaded_row_shows_preview_thumbnail_only_when_cached() {
|
||||
let mut app = headless_app();
|
||||
let cached = catalog_entry("with-preview", true);
|
||||
let uncached = catalog_entry("without-preview", false);
|
||||
|
||||
let mut cache = PreviewCache::default();
|
||||
cache
|
||||
.0
|
||||
.insert(cached.id.clone(), Handle::<Image>::default());
|
||||
let state = CatalogState::Loaded(vec![cached, uncached]);
|
||||
|
||||
{
|
||||
let world = app.world_mut();
|
||||
let mut commands = world.commands();
|
||||
spawn_store_modal(&mut commands, &state, None, &cache, None);
|
||||
}
|
||||
app.update();
|
||||
|
||||
let thumbs = app
|
||||
.world_mut()
|
||||
.query::<&ImageNode>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(
|
||||
thumbs, 1,
|
||||
"exactly the cached entry must render a preview thumbnail"
|
||||
);
|
||||
}
|
||||
|
||||
/// The thumbnail width follows the theme's own card aspect so a
|
||||
/// preview never renders stretched.
|
||||
#[test]
|
||||
fn preview_thumbnail_width_follows_card_aspect() {
|
||||
let mut app = headless_app();
|
||||
let mut entry = catalog_entry("wide", true);
|
||||
entry.card_aspect = (1, 1); // square theme art
|
||||
let mut cache = PreviewCache::default();
|
||||
cache.0.insert(entry.id.clone(), Handle::<Image>::default());
|
||||
let state = CatalogState::Loaded(vec![entry]);
|
||||
|
||||
{
|
||||
let world = app.world_mut();
|
||||
let mut commands = world.commands();
|
||||
spawn_store_modal(&mut commands, &state, None, &cache, None);
|
||||
}
|
||||
app.update();
|
||||
|
||||
let node = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Node, With<ImageNode>>()
|
||||
.single(app.world())
|
||||
.expect("thumbnail node must exist");
|
||||
assert_eq!(node.height, Val::Px(PREVIEW_THUMB_HEIGHT_PX));
|
||||
assert_eq!(
|
||||
node.width,
|
||||
Val::Px(PREVIEW_THUMB_HEIGHT_PX),
|
||||
"a 1:1 aspect must yield a square thumbnail"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,8 @@
|
||||
|
||||
use bevy::ecs::message::MessageReader;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_core::Card;
|
||||
use solitaire_core::KlondikePile;
|
||||
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::events::StateChangedEvent;
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! "Liquid glass" material for floating UI surfaces.
|
||||
//!
|
||||
//! Produces the translucent, light-catching treatment used by the floating
|
||||
//! touch tab bar (`hud_plugin::tab_bar`): a semi-transparent fill with a
|
||||
//! vertical sheen, a specular rim highlight around the border, and a soft
|
||||
//! drop shadow underneath. The table shows through the fill *sharp* — Bevy's
|
||||
//! UI pipeline has no backdrop-blur concept, so this module approximates
|
||||
//! frosted glass with layered gradients instead of sampling what's behind
|
||||
//! the node. That keeps it a plain `bevy_ui` bundle: no custom shaders, no
|
||||
//! extra render passes, and identical behaviour on desktop, Android, and
|
||||
//! WebGL2.
|
||||
//!
|
||||
//! # Swapping the treatment
|
||||
//!
|
||||
//! Layout code depends only on [`glass_surface`] and [`GLASS_BORDER_PX`].
|
||||
//! A future real-refraction material (e.g. a `UiMaterial` sampling a blurred
|
||||
//! render target) can replace this module's internals without touching any
|
||||
//! caller — that boundary is the reason this file exists separately from
|
||||
//! `hud_plugin/tab_bar.rs`.
|
||||
//!
|
||||
//! # Departure from the Terminal design system
|
||||
//!
|
||||
//! `ui_theme` documents HUD chrome as opaque "status-line" panels. The glass
|
||||
//! bar is a deliberate, user-approved exception for the floating touch tab
|
||||
//! bar only; do not reuse this material for modals or the top HUD band
|
||||
//! without a matching design decision.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::ui::{
|
||||
BackgroundGradient, BorderGradient, BoxShadow, ColorStop, LinearGradient, ShadowStyle,
|
||||
};
|
||||
|
||||
/// Border width every glass surface must reserve in its `Node::border` so
|
||||
/// the rim gradient has a strip to paint into.
|
||||
pub const GLASS_BORDER_PX: f32 = 1.0;
|
||||
|
||||
/// Base fill at the *bottom* of the glass sheet — near-black at ~62%
|
||||
/// opacity so the felt and cards remain visible through the bar while
|
||||
/// keeping icon/label contrast comfortable.
|
||||
const GLASS_FILL_BOTTOM: Color = Color::srgba(0.055, 0.055, 0.063, 0.62);
|
||||
|
||||
/// Fill at the *top* of the sheet — the same glass lifted towards white,
|
||||
/// reading as the curved surface catching overhead light.
|
||||
const GLASS_FILL_TOP: Color = Color::srgba(0.20, 0.21, 0.23, 0.66);
|
||||
|
||||
/// Rim highlight at the top edge — the bright specular line where a real
|
||||
/// glass pill would catch the light source.
|
||||
const GLASS_RIM_TOP: Color = Color::srgba(1.0, 1.0, 1.0, 0.38);
|
||||
|
||||
/// Rim at the midpoint — almost gone, so the highlight reads as a glint
|
||||
/// rather than a drawn outline.
|
||||
const GLASS_RIM_MID: Color = Color::srgba(1.0, 1.0, 1.0, 0.06);
|
||||
|
||||
/// Rim at the bottom edge — a faint secondary catch-light, as on the
|
||||
/// underside of a curved surface above a bright table.
|
||||
const GLASS_RIM_BOTTOM: Color = Color::srgba(1.0, 1.0, 1.0, 0.16);
|
||||
|
||||
/// Drop shadow under the floating surface.
|
||||
const GLASS_SHADOW: Color = Color::srgba(0.0, 0.0, 0.0, 0.35);
|
||||
|
||||
/// Visual components for a floating glass surface.
|
||||
///
|
||||
/// The caller owns the `Node` and must set two fields for the material to
|
||||
/// render correctly:
|
||||
/// - `border: UiRect::all(Val::Px(GLASS_BORDER_PX))` — the rim gradient
|
||||
/// paints into the border strip and is invisible without it.
|
||||
/// - `border_radius` — half the node height for a fully-round pill.
|
||||
///
|
||||
/// Everything here is fragment-shader work inside Bevy's stock UI pipeline,
|
||||
/// so it is safe on WebGL2 and adds no per-frame cost beyond ordinary nodes.
|
||||
pub fn glass_surface() -> impl Bundle {
|
||||
(
|
||||
// Sheen: one top-to-bottom linear gradient carries both the fill and
|
||||
// the lighting so there is a single source of truth for the surface
|
||||
// colour (a separate `BackgroundColor` would just be painted over).
|
||||
BackgroundGradient(vec![
|
||||
LinearGradient::new(
|
||||
LinearGradient::TO_BOTTOM,
|
||||
vec![
|
||||
ColorStop::new(GLASS_FILL_TOP, Val::Percent(0.0)),
|
||||
ColorStop::new(GLASS_FILL_BOTTOM, Val::Percent(60.0)),
|
||||
],
|
||||
)
|
||||
.into(),
|
||||
]),
|
||||
// Specular rim: bright at the top edge, fading out through the
|
||||
// sides, with a faint return at the bottom.
|
||||
BorderGradient(vec![
|
||||
LinearGradient::new(
|
||||
LinearGradient::TO_BOTTOM,
|
||||
vec![
|
||||
ColorStop::new(GLASS_RIM_TOP, Val::Percent(0.0)),
|
||||
ColorStop::new(GLASS_RIM_MID, Val::Percent(55.0)),
|
||||
ColorStop::new(GLASS_RIM_BOTTOM, Val::Percent(100.0)),
|
||||
],
|
||||
)
|
||||
.into(),
|
||||
]),
|
||||
// Soft shadow underneath sells the "floating above the table" read.
|
||||
BoxShadow(vec![ShadowStyle {
|
||||
color: GLASS_SHADOW,
|
||||
x_offset: Val::Px(0.0),
|
||||
y_offset: Val::Px(6.0),
|
||||
spread_radius: Val::Px(0.0),
|
||||
blur_radius: Val::Px(16.0),
|
||||
}]),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The bundle must insert all three visual components — a regression
|
||||
/// here (e.g. a refactor dropping the rim) would silently flatten the
|
||||
/// glass into a plain translucent box.
|
||||
#[test]
|
||||
fn glass_surface_inserts_sheen_rim_and_shadow() {
|
||||
let mut world = World::new();
|
||||
let e = world.spawn(glass_surface()).id();
|
||||
assert!(world.get::<BackgroundGradient>(e).is_some());
|
||||
assert!(world.get::<BorderGradient>(e).is_some());
|
||||
assert!(world.get::<BoxShadow>(e).is_some());
|
||||
}
|
||||
|
||||
/// The fill must stay translucent (that is the whole point of glass) but
|
||||
/// opaque enough that text keeps contrast over busy card art.
|
||||
#[test]
|
||||
fn glass_fill_alpha_stays_in_readable_band() {
|
||||
for fill in [GLASS_FILL_TOP, GLASS_FILL_BOTTOM] {
|
||||
let a = fill.alpha();
|
||||
assert!(
|
||||
(0.4..=0.85).contains(&a),
|
||||
"glass fill alpha {a} outside readable 0.4–0.85 band"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The rim gradient must actually glint: strictly brightest at the top,
|
||||
/// dimmest in the middle.
|
||||
#[test]
|
||||
fn rim_highlight_peaks_at_top() {
|
||||
assert!(GLASS_RIM_TOP.alpha() > GLASS_RIM_BOTTOM.alpha());
|
||||
assert!(GLASS_RIM_BOTTOM.alpha() > GLASS_RIM_MID.alpha());
|
||||
}
|
||||
}
|
||||
@@ -60,9 +60,9 @@ use crate::settings_plugin::SettingsResource;
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI,
|
||||
BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder,
|
||||
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, SCRIM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG,
|
||||
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, VAL_SPACE_5,
|
||||
scaled_duration,
|
||||
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, RADIUS_SM, SCRIM, STATE_SUCCESS, TEXT_DISABLED,
|
||||
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_DISPLAY,
|
||||
TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, VAL_SPACE_5, scaled_duration,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -178,6 +178,32 @@ pub fn spawn_modal<M: Component, F>(
|
||||
z_panel: i32,
|
||||
build_card: F,
|
||||
) -> Entity
|
||||
where
|
||||
F: FnOnce(&mut ChildSpawnerCommands),
|
||||
{
|
||||
spawn_modal_sized(
|
||||
commands,
|
||||
plugin_marker,
|
||||
z_panel,
|
||||
MODAL_CARD_MAX_WIDTH,
|
||||
build_card,
|
||||
)
|
||||
}
|
||||
|
||||
/// Default maximum card width in logical pixels — every [`spawn_modal`]
|
||||
/// caller gets this. Surfaces that lay out side-by-side panes (e.g. the
|
||||
/// two-pane Home on wide viewports) can raise it via [`spawn_modal_sized`].
|
||||
pub const MODAL_CARD_MAX_WIDTH: f32 = 720.0;
|
||||
|
||||
/// [`spawn_modal`] with an explicit card `max_width`. Behaviour is
|
||||
/// otherwise identical — same scrim, enter animation, and card chrome.
|
||||
pub fn spawn_modal_sized<M: Component, F>(
|
||||
commands: &mut Commands,
|
||||
plugin_marker: M,
|
||||
z_panel: i32,
|
||||
max_width: f32,
|
||||
build_card: F,
|
||||
) -> Entity
|
||||
where
|
||||
F: FnOnce(&mut ChildSpawnerCommands),
|
||||
{
|
||||
@@ -235,7 +261,7 @@ where
|
||||
padding: UiRect::all(VAL_SPACE_5),
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_LG)),
|
||||
max_width: Val::Px(720.0),
|
||||
max_width: Val::Px(max_width),
|
||||
align_items: AlignItems::Stretch,
|
||||
..default()
|
||||
},
|
||||
@@ -302,6 +328,80 @@ pub fn spawn_modal_body_text(
|
||||
parent.spawn((ModalBody, Text::new(text.into()), font, TextColor(color)));
|
||||
}
|
||||
|
||||
/// Standard empty / loading / error surface (Phase L): a large glyph,
|
||||
/// a headline, and an optional secondary detail line, centred in the
|
||||
/// available space. Every async or possibly-empty surface (leaderboard,
|
||||
/// replays, theme store, first-launch stats) renders through this so
|
||||
/// "nothing here yet" reads identically everywhere.
|
||||
///
|
||||
/// A next-step action is composition, not configuration: spawn a
|
||||
/// [`spawn_modal_button`] (or any control) in the same parent right
|
||||
/// after this call.
|
||||
///
|
||||
/// `glyph` MUST come from FiraMono-covered ranges — card suits
|
||||
/// (U+2660–2666), Arrows (U+2190–21FF), or ASCII. The Geometric Shapes
|
||||
/// block renders as tofu on Android (§10 / §11).
|
||||
pub fn spawn_empty_state(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
glyph: &str,
|
||||
headline: &str,
|
||||
detail: Option<&str>,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||
let font_glyph = TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_DISPLAY,
|
||||
..default()
|
||||
};
|
||||
let font_headline = TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_BODY_LG,
|
||||
..default()
|
||||
};
|
||||
let font_detail = TextFont {
|
||||
font: font_handle,
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
};
|
||||
|
||||
parent
|
||||
.spawn((
|
||||
EmptyStateBlock,
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
align_items: AlignItems::Center,
|
||||
row_gap: VAL_SPACE_2,
|
||||
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_5),
|
||||
width: Val::Percent(100.0),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|block| {
|
||||
block.spawn((
|
||||
Text::new(glyph.to_string()),
|
||||
font_glyph,
|
||||
TextColor(TEXT_DISABLED),
|
||||
));
|
||||
block.spawn((
|
||||
Text::new(headline.to_string()),
|
||||
font_headline,
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
if let Some(detail) = detail {
|
||||
block.spawn((
|
||||
Text::new(detail.to_string()),
|
||||
font_detail,
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Marker on every [`spawn_empty_state`] block, mostly for tests.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct EmptyStateBlock;
|
||||
|
||||
/// Spawns the bottom actions row — flex-row with primary right-aligned.
|
||||
/// The closure populates the row's buttons via `spawn_modal_button`.
|
||||
///
|
||||
@@ -402,6 +502,46 @@ pub fn spawn_modal_button<M: Component>(
|
||||
});
|
||||
}
|
||||
|
||||
/// One tab chip for a tabbed modal (Settings, the You hub). The active
|
||||
/// chip is filled + bright with a success-green border; inactive chips
|
||||
/// are quiet outlines. `marker` is the plugin's click-target component
|
||||
/// carrying which tab the chip selects.
|
||||
pub fn spawn_tab_chip<M: Component>(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
marker: M,
|
||||
label: &str,
|
||||
active: bool,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let font = TextFont {
|
||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
parent
|
||||
.spawn((
|
||||
marker,
|
||||
Button,
|
||||
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(if active { BG_ELEVATED_HI } else { BG_BASE }),
|
||||
BorderColor::all(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
|
||||
HighContrastBorder::with_default(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new(label),
|
||||
font,
|
||||
TextColor(if active { TEXT_PRIMARY } else { TEXT_SECONDARY }),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic touch-scroll helper
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1118,4 +1258,62 @@ mod tests {
|
||||
"exactly one of the two stacked dismissible modals should remain"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase L: standard empty state
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// The helper spawns glyph + headline (+ detail when given) under one
|
||||
/// marked block, so every empty surface renders identically.
|
||||
#[test]
|
||||
fn empty_state_spawns_expected_structure() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins);
|
||||
{
|
||||
let world = app.world_mut();
|
||||
let mut commands = world.commands();
|
||||
commands.spawn(Node::default()).with_children(|parent| {
|
||||
spawn_empty_state(
|
||||
parent,
|
||||
"\u{2660}",
|
||||
"Nothing here.",
|
||||
Some("Do the thing."),
|
||||
None,
|
||||
);
|
||||
spawn_empty_state(parent, "!", "No detail variant.", None, None);
|
||||
});
|
||||
}
|
||||
app.update();
|
||||
|
||||
let blocks = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<EmptyStateBlock>>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(blocks, 2, "each call spawns exactly one marked block");
|
||||
|
||||
let texts: Vec<String> = app
|
||||
.world_mut()
|
||||
.query::<&Text>()
|
||||
.iter(app.world())
|
||||
.map(|t| t.0.clone())
|
||||
.collect();
|
||||
for expected in [
|
||||
"\u{2660}",
|
||||
"Nothing here.",
|
||||
"Do the thing.",
|
||||
"!",
|
||||
"No detail variant.",
|
||||
] {
|
||||
assert!(
|
||||
texts.iter().any(|t| t == expected),
|
||||
"missing text node {expected:?}; got {texts:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
texts.len(),
|
||||
5,
|
||||
"the detail-less variant must not spawn a detail node"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user